@pyric/cli 0.1.0-alpha.17 → 0.1.0-alpha.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -3
- package/README.md.orig +5 -1
- package/dist/bridge/server/bridge.d.ts.map +1 -1
- package/dist/bridge/server/bridge.js +4 -2
- package/dist/bridge/server/bridge.js.map +1 -1
- package/dist/bridge/server/mcp-contract.d.ts +2 -2
- package/dist/bridge/server/mcp-contract.d.ts.map +1 -1
- package/dist/bridge/server/mcp-contract.js +3 -0
- package/dist/bridge/server/mcp-contract.js.map +1 -1
- package/dist/bridge/server/peer.d.ts.map +1 -1
- package/dist/bridge/server/peer.js +1 -0
- package/dist/bridge/server/peer.js.map +1 -1
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +3 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/mcp-proxy.d.ts.map +1 -1
- package/dist/cli/mcp-proxy.js.map +1 -1
- package/dist/cli/rules.d.ts +1 -0
- package/dist/cli/rules.d.ts.map +1 -1
- package/dist/cli/rules.js +18 -4
- package/dist/cli/rules.js.map +1 -1
- package/dist/cli/serve.d.ts.map +1 -1
- package/dist/cli/serve.js +1 -0
- package/dist/cli/serve.js.map +1 -1
- package/dist/cli/service-command-records/storage-rules-resolve.d.ts +4 -0
- package/dist/cli/service-command-records/storage-rules-resolve.d.ts.map +1 -0
- package/dist/cli/service-command-records/storage-rules-resolve.js +3 -0
- package/dist/cli/service-command-records/storage-rules-resolve.js.map +1 -0
- package/dist/cli/service-commands.generated.d.ts +3 -0
- package/dist/cli/service-commands.generated.d.ts.map +1 -1
- package/dist/cli/service-commands.generated.js +2 -0
- package/dist/cli/service-commands.generated.js.map +1 -1
- package/dist/conformance/.generated/conformance-docs.d.ts.map +1 -1
- package/dist/conformance/.generated/conformance-docs.js +1 -1
- package/dist/conformance/.generated/conformance-docs.js.map +1 -1
- package/dist/functions-rtdb/child.d.ts +1 -0
- package/dist/functions-rtdb/child.d.ts.map +1 -1
- package/dist/functions-rtdb/child.js +11 -1
- package/dist/functions-rtdb/child.js.map +1 -1
- package/dist/functions-rtdb/development-runtime.d.ts +1 -0
- package/dist/functions-rtdb/development-runtime.d.ts.map +1 -1
- package/dist/functions-rtdb/development-runtime.js +1 -0
- package/dist/functions-rtdb/development-runtime.js.map +1 -1
- package/dist/remote/index.d.ts.map +1 -1
- package/dist/remote/index.js +5 -3
- package/dist/remote/index.js.map +1 -1
- package/dist/serve/rules.d.ts +3 -3
- package/dist/serve/rules.d.ts.map +1 -1
- package/dist/serve/rules.js +13 -5
- package/dist/serve/rules.js.map +1 -1
- package/dist/serve/runtime/chip.d.ts.map +1 -1
- package/dist/serve/runtime/chip.js +24 -3
- package/dist/serve/runtime/chip.js.map +1 -1
- package/dist/serve/runtime/status.d.ts +1 -0
- package/dist/serve/runtime/status.d.ts.map +1 -1
- package/dist/serve/runtime/status.js +71 -13
- package/dist/serve/runtime/status.js.map +1 -1
- package/dist/serve/site-ui/_astro/{RulesCodeEditor.CzwHdCi9.js → RulesCodeEditor.Bl-XMkhD.js} +1 -1
- package/dist/serve/site-ui/_astro/_example_.B9DG9HLJ.css +1 -0
- package/dist/serve/site-ui/_astro/_slug_.CupVK4TM.css +1 -0
- package/dist/serve/site-ui/_astro/_studio_.CfxiQWkk.css +1 -0
- package/dist/serve/site-ui/_astro/app.Ce3MTLQm.js +1 -0
- package/dist/serve/site-ui/_astro/app.taqzyjD0.js +11 -0
- package/dist/serve/site-ui/_astro/chess-showcase.Dh5D8kqe.js +354 -0
- package/dist/serve/site-ui/_astro/{example-runtime.D79PnoSk.js → example-runtime.CuyAjjiG.js} +2 -26
- package/dist/serve/site-ui/_astro/sandbox-controls.BP4M5f1M.js +1169 -0
- package/dist/serve/site-ui/_astro/seed.CSUkloMi.js +20 -0
- package/dist/serve/site-ui/_astro/writes.C57paAj2.js +99 -0
- package/dist/serve/site-ui/auth/index.html +2 -2
- package/dist/serve/site-ui/docs/_rhythm/index.html +1 -1
- package/dist/serve/site-ui/docs/agent/set-up-your-agent/index.html +73 -13
- package/dist/serve/site-ui/docs/agent/set-up-your-agent.md +77 -11
- package/dist/serve/site-ui/docs/agent/watch-and-review/index.html +1 -1
- package/dist/serve/site-ui/docs/agent/work-with-an-agent/index.html +1 -1
- package/dist/serve/site-ui/docs/ai-compat/index.html +1 -1
- package/dist/serve/site-ui/docs/api-reference/index.html +1 -1
- package/dist/serve/site-ui/docs/app-compat/index.html +1 -1
- package/dist/serve/site-ui/docs/auth-compat/index.html +1 -1
- package/dist/serve/site-ui/docs/build/ai-logic/index.html +19 -2
- package/dist/serve/site-ui/docs/build/ai-logic.md +28 -0
- package/dist/serve/site-ui/docs/build/authentication/index.html +1 -1
- package/dist/serve/site-ui/docs/build/cloud-firestore/index.html +30 -52
- package/dist/serve/site-ui/docs/build/cloud-firestore.md +40 -36
- package/dist/serve/site-ui/docs/build/cloud-messaging/index.html +1 -1
- package/dist/serve/site-ui/docs/build/cloud-storage/index.html +1 -1
- package/dist/serve/site-ui/docs/build/realtime-database/index.html +1 -1
- package/dist/serve/site-ui/docs/conformance-scores/index.html +22 -7
- package/dist/serve/site-ui/docs/conformance-scores.md +20 -5
- package/dist/serve/site-ui/docs/create-pyric-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/database-compat/index.html +1 -1
- package/dist/serve/site-ui/docs/examples/chess/index.html +645 -249
- package/dist/serve/site-ui/docs/examples/chess.md +45 -16
- package/dist/serve/site-ui/docs/examples/index.html +2 -2
- package/dist/serve/site-ui/docs/firestore-compat/index.html +1 -1
- package/dist/serve/site-ui/docs/functions-rtdb-compat/index.html +1 -1
- package/dist/serve/site-ui/docs/get-started/how-the-swap-works/index.html +1 -1
- package/dist/serve/site-ui/docs/get-started/nextjs/index.html +1 -1
- package/dist/serve/site-ui/docs/get-started/start-building/index.html +43 -5
- package/dist/serve/site-ui/docs/get-started/start-building.md +34 -3
- package/dist/serve/site-ui/docs/get-started/vite/index.html +1 -1
- package/dist/serve/site-ui/docs/index.json +66 -26
- package/dist/serve/site-ui/docs/messaging-compat/index.html +1 -1
- package/dist/serve/site-ui/docs/observe/resolve-runtime-status/index.html +1 -1
- package/dist/serve/site-ui/docs/observe/see-whats-happening/index.html +1 -1
- package/dist/serve/site-ui/docs/observe/shape-your-data/index.html +1 -1
- package/dist/serve/site-ui/docs/overview/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-admin-app-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-admin-auth-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-admin-database-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-admin-firestore-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-admin-messaging-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-admin-storage-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-ai-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-ai-scripting-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-app-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-auth-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-cli-assurance-browser-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-cli-assurance-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-cli-bridge-client-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-cli-bridge-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-cli-conformance-browser-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-cli-conformance-docs-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-cli-conformance-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-cli-credentials-node-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-cli-discover-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-cli-next-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-cli-register-app-bridge-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-cli-register-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-cli-remote-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-cli-serve-worker-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-cli-verify-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-cli-vite-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-database-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-firestore-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-messaging-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-messaging-sw-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-rules-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-sandbox-database-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-sandbox-firestore-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-sandbox-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-storage-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-ui-agents-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-ui-auth-hooks-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-ui-auth-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-ui-events-hooks-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-ui-events-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-ui-firestore-hooks-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-ui-firestore-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-ui-primitives-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-ui-rtdb-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-ui-rules-hooks-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-ui-rules-reference-api/index.html +1 -10
- package/dist/serve/site-ui/docs/pyric-ui-rules-reference-api.md +0 -9
- package/dist/serve/site-ui/docs/pyric-ui-storage-hooks-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-ui-storage-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-ui-traffic-hooks-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/pyric-ui-traffic-reference-api/index.html +1 -1
- package/dist/serve/site-ui/docs/rules-compat/index.html +1 -1
- package/dist/serve/site-ui/docs/secure/audit-your-rules/index.html +1 -1
- package/dist/serve/site-ui/docs/secure/firestore-rules-limits/index.html +1 -1
- package/dist/serve/site-ui/docs/secure/read-a-denial/index.html +1 -1
- package/dist/serve/site-ui/docs/secure/rtdb-rules-in-typescript/index.html +1 -1
- package/dist/serve/site-ui/docs/secure/rules-standard-library/index.html +1 -1
- package/dist/serve/site-ui/docs/secure/secure-it-with-rules/index.html +1 -1
- package/dist/serve/site-ui/docs/secure/simulate-and-lint/index.html +1 -1
- package/dist/serve/site-ui/docs/secure/write-a-rules-test-suite/index.html +1 -1
- package/dist/serve/site-ui/docs/ship/ship-to-production/index.html +1 -1
- package/dist/serve/site-ui/docs/ship/test-in-node/index.html +1 -1
- package/dist/serve/site-ui/docs/storage-compat/index.html +1 -1
- package/dist/serve/site-ui/docs/trust/how-we-know-it-matches-firebase/index.html +1 -1
- package/dist/serve/site-ui/docs/trust/rules-standard-library/index.html +1 -1
- package/dist/serve/site-ui/docs/trust/storage-rules-stdlib-research/index.html +1 -1
- package/dist/serve/site-ui/docs/trust/versioning-and-compatibility/index.html +1 -1
- package/dist/serve/site-ui/docs/tutorial/index.html +2 -1
- package/dist/serve/site-ui/docs/tutorial.md +2 -0
- package/dist/serve/site-ui/examples/chess/index.html +2 -2
- package/dist/serve/site-ui/firestore/index.html +2 -2
- package/dist/serve/site-ui/index.html +2 -2
- package/dist/serve/site-ui/llms.txt +2 -2
- package/dist/serve/site-ui/rtdb/index.html +2 -2
- package/dist/serve/site-ui/settings/index.html +2 -2
- package/dist/serve/site-ui/storage/index.html +2 -2
- package/dist/serve/site-ui/traffic/index.html +2 -2
- package/dist/serve/vite-functions-development.d.ts.map +1 -1
- package/dist/serve/vite-functions-development.js +1 -0
- package/dist/serve/vite-functions-development.js.map +1 -1
- package/dist/serve/vite-rules-source.d.ts.map +1 -1
- package/dist/serve/vite-rules-source.js +28 -7
- package/dist/serve/vite-rules-source.js.map +1 -1
- package/package.json +4 -4
- package/src/bridge/server/bridge.ts +5 -2
- package/src/bridge/server/mcp-contract.ts +3 -0
- package/src/bridge/server/peer.ts +2 -1
- package/src/cli/index.ts +3 -0
- package/src/cli/mcp-proxy.ts +6 -7
- package/src/cli/rules.ts +31 -4
- package/src/cli/serve.ts +1 -0
- package/src/cli/service-command-records/storage-rules-resolve.ts +4 -0
- package/src/cli/service-commands.generated.ts +2 -0
- package/src/conformance/.generated/conformance-docs.ts +1 -1
- package/src/functions-rtdb/child.ts +12 -1
- package/src/functions-rtdb/development-runtime.ts +2 -0
- package/src/remote/index.ts +7 -5
- package/src/serve/rules.ts +15 -5
- package/src/serve/runtime/chip.ts +24 -3
- package/src/serve/runtime/status.ts +76 -13
- package/src/serve/vite-functions-development.ts +1 -0
- package/src/serve/vite-rules-source.ts +29 -7
- package/dist/serve/site-ui/_astro/_example_.jevZ7OdV.css +0 -1
- package/dist/serve/site-ui/_astro/_slug_.BoKXYrnu.css +0 -1
- package/dist/serve/site-ui/_astro/_studio_.B6b_G1ev.css +0 -1
- package/dist/serve/site-ui/_astro/app.bnmVTkIY.js +0 -11
- package/dist/serve/site-ui/_astro/app.t6u-adB_.js +0 -1
- package/dist/serve/site-ui/_astro/chess-showcase.kC8dXLKY.js +0 -1
- package/dist/serve/site-ui/_astro/chess-source-explorer.C5k9c5yK.js +0 -1
- package/dist/serve/site-ui/_astro/chess-v2.CFHmd3UL.js +0 -354
- package/dist/serve/site-ui/_astro/index.DzVde04M.js +0 -1
- package/dist/serve/site-ui/_astro/jsx-runtime.D_zvdyIk.js +0 -9
- package/dist/serve/site-ui/_astro/reads.BYBRLbsE.js +0 -1
- package/dist/serve/site-ui/_astro/sandbox-controls.C9Aaazt8.js +0 -1252
- package/dist/serve/site-ui/_astro/seed.DANAIyRw.js +0 -20
- package/dist/serve/site-ui/examples/firestore-first-write/index.html +0 -2
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export const CONFORMANCE_DOCS_PAGES = [{ "slug": "conformance-scores", "label": "Public API coverage", "title": "Public API coverage", "markdown": "<!-- Generated from the conformance model (registry rows + surface contracts). Do not edit by hand; run bun run compat:generate. -->\n\n# Public API coverage\n\nThis is the share of Firebase's public API that Pyric supports. Not-implemented-yet, deprecated, and deferred APIs still count against the total. [How does Pyric know it works like Firebase?](../trust/how-we-know-it-matches-firebase/) explains the evidence and its limits.\n\n## Services\n\n<table class=\"compat-score-table\">\n<thead><tr><th>Service</th><th>Public API supported</th></tr></thead>\n<tbody>\n<tr>\n<th scope=\"row\" class=\"compat-score-name\"><a href=\"../app-compat/\">App</a></th>\n<td class=\"compat-score-cell\"><div class=\"compat-score-stack\"><div class=\"compat-score-bar\"><span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 81.3%\"></span></span><span class=\"compat-score-pct\">81.3%</span></div><div class=\"compat-score-basis\">13 of 16 public API</div></div></td>\n</tr>\n<tr>\n<th scope=\"row\" class=\"compat-score-name\"><a href=\"../ai-compat/\">AI Logic</a></th>\n<td class=\"compat-score-cell\"><div class=\"compat-score-stack\"><div class=\"compat-score-bar\"><span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 67.1%\"></span></span><span class=\"compat-score-pct\">67.1%</span></div><div class=\"compat-score-basis\">147 of 219 public API</div></div></td>\n</tr>\n<tr>\n<th scope=\"row\" class=\"compat-score-name\"><a href=\"../auth-compat/\">Auth</a></th>\n<td class=\"compat-score-cell\"><div class=\"compat-score-stack\"><div class=\"compat-score-bar\"><span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 63.8%\"></span></span><span class=\"compat-score-pct\">63.8%</span></div><div class=\"compat-score-basis\">95 of 149 public API</div></div></td>\n</tr>\n<tr>\n<th scope=\"row\" class=\"compat-score-name\"><a href=\"../firestore-compat/\">Firestore</a></th>\n<td class=\"compat-score-cell\"><div class=\"compat-score-stack\"><div class=\"compat-score-bar\"><span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 100%\"></span></span><span class=\"compat-score-pct\">100%</span></div><div class=\"compat-score-basis\">182 of 182 public API</div></div></td>\n</tr>\n<tr>\n<th scope=\"row\" class=\"compat-score-name\"><a href=\"../database-compat/\">Realtime Database</a></th>\n<td class=\"compat-score-cell\"><div class=\"compat-score-stack\"><div class=\"compat-score-bar\"><span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 100%\"></span></span><span class=\"compat-score-pct\">100%</span></div><div class=\"compat-score-basis\">59 of 59 public API</div></div></td>\n</tr>\n<tr>\n<th scope=\"row\" class=\"compat-score-name\"><a href=\"../storage-compat/\">Storage</a></th>\n<td class=\"compat-score-cell\"><div class=\"compat-score-stack\"><div class=\"compat-score-bar\"><span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 77.1%\"></span></span><span class=\"compat-score-pct\">77.1%</span></div><div class=\"compat-score-basis\">27 of 35 public API</div></div></td>\n</tr>\n<tr>\n<th scope=\"row\" class=\"compat-score-name\"><a href=\"../messaging-compat/\">Messaging</a></th>\n<td class=\"compat-score-cell\"><div class=\"compat-score-stack\"><div class=\"compat-score-bar\"><span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 100%\"></span></span><span class=\"compat-score-pct\">100%</span></div><div class=\"compat-score-basis\">13 of 13 public API</div></div></td>\n</tr>\n<tr>\n<th scope=\"row\" class=\"compat-score-name\"><a href=\"../functions-rtdb-compat/\">Functions · RTDB</a></th>\n<td class=\"compat-score-cell\"><span class=\"compat-score-tbd\">Gathering metrics. Total score TBD.</span></td>\n</tr>\n<tr>\n<th scope=\"row\" class=\"compat-score-name\"><a href=\"../rules-compat/\">Rules</a></th>\n<td class=\"compat-score-cell\"><span class=\"compat-score-tbd\">Gathering metrics. Total score TBD.</span></td>\n</tr>\n<tr class=\"compat-score-row--overall\">\n<th scope=\"row\" class=\"compat-score-name\">Overall</th>\n<td class=\"compat-score-cell\"><div class=\"compat-score-stack\"><div class=\"compat-score-bar\"><span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 79.6%\"></span></span><span class=\"compat-score-pct\">79.6%</span></div><div class=\"compat-score-basis\">536 of 673 public API</div></div></td>\n</tr>\n</tbody>\n</table>\n" }, { "slug": "app-compat", "label": "App", "title": "pyric/app compatibility matrix", "markdown": "<!-- Generated from the conformance model (registry rows + surface contracts). Do not edit by hand; run bun run compat:generate. -->\n\n# `pyric/app` compatibility matrix\n\n<div class=\"compat-stat\">\n<p class=\"compat-stat-figure\">\n<span class=\"compat-stat-pct\">81.3%</span>\n<span class=\"compat-stat-label\">of the public API supported</span>\n</p>\n<div class=\"compat-meters\">\n<span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 81.3%\"></span></span>\n</div>\n<p class=\"compat-stat-denom\">13 of 16 public API</p>\n</div>\n\n\n## Status legend\n\n| Status | Meaning |\n|---|---|\n| ✓ | **Conforming** — the mirror matches prod, locked by a passing replay |\n| ⚠ | **Diverged (documented)** — intentional difference with a written reason |\n| — | **Not implemented yet** — deliberately or deferred |\n| ? | **Unverified** — claim from docs not yet observed prod-side |\n\n## `initializeApp(config, name?)` — the app registry\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| initializeApp(config) | | Registers the default app under the name `'[DEFAULT]'`; `getApps()` has length 1 and `getApp()` (no arg) resolves the same instance | ✓ | oracle: [`app-registry-initializeapp-default`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-initializeapp-default.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L109) | 1 |\n| initializeApp(config, 'secondary') | | Registers a named app alongside the default; `getApp('secondary')` resolves it and `getApps()` has length 2 (default + named) | ✓ | oracle: [`app-registry-initializeapp-named`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-initializeapp-named.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L119) | 2 |\n| initializeApp(config) | | A same-name re-initialization with a DIFFERENT config throws `FirebaseError` code `app/duplicate-app`, with the app name embedded in the message | ✓ | oracle: [`app-registry-initializeapp-duplicate-name`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-initializeapp-duplicate-name.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L129) | 3 |\n| initializeApp(config) | | A same-name re-initialization with EQUAL config is idempotent — no throw, returns the existing instance, and `getApps()` stays length 1 | ✓ | oracle: [`app-registry-initializeapp-duplicate-config`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-initializeapp-duplicate-config.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L141) | 4 |\n| getApp() | | `getApp()` with no name resolves the default app instance; its name is `'[DEFAULT]'` | ✓ | oracle: [`app-registry-getapp-default`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-getapp-default.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L149) | 5 |\n| getApp('secondary') | | `getApp('secondary')` resolves the named app instance; its name is `'secondary'` | ✓ | oracle: [`app-registry-getapp-named`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-getapp-named.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L157) | 6 |\n| getApp(name) | | `getApp(name)` for a name that was never initialized throws `FirebaseError` code `app/no-app`, directing the caller to `initializeApp()` | ✓ | oracle: [`app-registry-getapp-unknown-name`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-getapp-unknown-name.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L166) | 7 |\n| getApps() | | `getApps()` returns an array containing every registered app by identity (the exact instances, not copies) | ✓ | oracle: [`app-registry-getapps-contents`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-getapps-contents.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L174) | 8 |\n| deleteApp(app) | | `deleteApp(app)` returns a Promise, deregisters the app (so `getApps()` shrinks), a later `getApp(name)` throws `app/no-app`, and the name can be re-initialized afterwards | ✓ | oracle: [`app-registry-deleteapp`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-deleteapp.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L185) | 9 |\n| deleteApp(app) | | `deleteApp` on an already-deleted app throws `FirebaseError` code `app/app-deleted` | ✓ | oracle: [`app-registry-deleteapp-double`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-deleteapp-double.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L205) | 10 |\n| SDK_VERSION | | `SDK_VERSION` is the Firebase client SDK semver string whose behavior pyric currently mirrors, pinned to the oracle version (`12.13.0`) | ✓ | oracle: [`app-registry-sdk-version`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-sdk-version.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L370) | 11 |\n| FirebaseError | | `FirebaseError` is an app-owned Error subclass: `instanceof Error`, `constructor.name` is `'FirebaseError'`, and it preserves `.code` and `.message` without loading `firebase/app` | ✓ | oracle: [`app-registry-firebaseerror-shape`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-firebaseerror-shape.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L379) | 12 |\n| onLog / setLogLevel | | `onLog(cb)` / `setLogLevel(level)` are a functioning app-owned diagnostic-logger seam: registering a handler returns undefined, raising the threshold takes effect, and a malformed `registerVersion` emits a `warn` entry (type `@firebase/app`) to the handler | ✓ | oracle: [`app-registry-onlog-setloglevel`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-onlog-setloglevel.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L390) | 13 |\n| registerVersion | | `registerVersion(library, version)` accepts a well-formed registration and returns undefined without throwing; malformed values emit the observed warning through the app-owned logger | ✓ | oracle: [`app-registry-registerversion`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-registerversion.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L417) | 14 |\n| initializeServerApp | | Not implemented; disposition `app.server-app` owns the reviewed availability and rationale | — | disposition:app.server-app | 15 |\n| initializeApp(options, settings) | | `initializeApp` snapshots options, accepts a settings object, initializes `automaticDataCollectionEnabled`, and leaves that app property mutable | ✓ | oracle: [`app-registry-initializeapp-settings-options`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-initializeapp-settings-options.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L279) | 16 |\n| initializeApp(equalOptions, 'secondary') | | Equal-config named apps are distinct app containers with equal option values and independent name-keyed registry identity | ✓ | oracle: [`app-registry-initializeapp-named-equal-config`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-initializeapp-named-equal-config.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L296) | 17 |\n| getAuth/getFirestore/getDatabase/getStorage(app) | | Equal-config named apps own distinct app-associated service handles while resolving the same configured RTDB and Storage backend locators | ✓ | oracle: [`app-registry-multi-app-service-containers`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-multi-app-service-containers.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L307) | 18 |\n| initializeApp(differentOptions, 'secondary') | | Production permits a differently configured named app; Pyric rejects it because one runtime currently owns exactly one sandbox backend | ⚠ | oracle: [`app-registry-initializeapp-named-different-config`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-initializeapp-named-different-config.json) (firebase 12.13.0) + replay pins `app/multiple-configs-not-supported`; [`packages/cli/test/e2e/app-multi-app.pw.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/e2e/app-multi-app.pw.ts#L148) proves the same lock is enforced by the authoritative SharedWorker across same-origin tabs, not only by one page registry (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L340)) | 19 |\n| deleteApp(app); initializeApp(differentOptions) | | Production permits a different configuration after deletion; Pyric retains the runtime backend lock and rejects the reinitialization | ⚠ | oracle: [`app-registry-delete-reinitialize-different-config`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-delete-reinitialize-different-config.json) (firebase 12.13.0) + replay pins `app/multiple-configs-not-supported` (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L351)) | 20 |\n| initializeApp() | | Without Hosting-provided defaults, omitting options throws FirebaseError code `app/no-options` with the production message | ✓ | oracle: [`app-registry-initializeapp-no-options`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-initializeapp-no-options.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L362) | 21 |\n| getAuth/getFirestore/getDatabase/getStorage() | | With a registered default app, each no-argument service factory resolves a service associated with that exact default app | ✓ | oracle: [`app-registry-default-service-factories`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-default-service-factories.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L100) | 22 |\n| initializeApp(equalOptions, name) | | Equal-config app instances connect to one logical backend: data written through one app is readable through another, while their active Auth sessions remain independent | ✓ | oracle: [`app-production-multi-app-topology`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-production-multi-app-topology.json) (firebase 12.13.0, real Chromium against production) + twin replay: [`production-multi-app-oracle.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/production-multi-app-oracle.test.ts#L15); served SharedWorker replay: [`app-multi-app.pw.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/e2e/app-multi-app.pw.ts#L148) | 23 |\n| deleteApp(app); app.name / app.options / app.automaticDataCollectionEnabled | | After deletion resolves, every public FirebaseApp property accessor throws `FirebaseError` code `app/app-deleted` | ✓ | oracle: [`app-registry-deleted-property-access`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-deleted-property-access.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L215) | 24 |\n| deleteApp(app); getAuth/getFirestore/getDatabase/getStorage/getAI(app) | | After deletion, fresh Auth/Firestore/RTDB/Storage factories reject; cached factories return retained handles; retained Auth sign-out resolves but a new anonymous sign-in rejects `app/app-deleted`; Firestore reads report termination, RTDB refuses new refs, Storage refs remain constructible, and `getAI(deletedApp)` returns an app-associated handle | ✓ | oracles: [`app-registry-deleted-service-factories`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-deleted-service-factories.json) and real-Chromium [`app-production-multi-app-topology`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-production-multi-app-topology.json) (firebase 12.13.0) + replays: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L237), [`deleted-service-lifecycle.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/deleted-service-lifecycle.test.ts#L23), and [`production-multi-app-oracle.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/production-multi-app-oracle.test.ts#L15) | 25 |\n| deleteApp(app) | | Deleting one app terminates its Firestore listener through the error callback with code `aborted`, silently stops its RTDB listener, and leaves equal-config sibling listeners and the shared backend usable | ✓ | oracle: [`app-production-multi-app-topology`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-production-multi-app-topology.json) (firebase 12.13.0, real Chromium against production) + twin replay: [`production-multi-app-oracle.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/production-multi-app-oracle.test.ts#L15); focused family tests: [`multi-app-listener-auth.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/multi-app-listener-auth.test.ts#L2) and served [`app-multi-app.pw.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/e2e/app-multi-app.pw.ts#L183) | 26 |\n| getAI(app, customEngine); deleteApp(app); retainedModel operation | | Served worker mode rejects page-local custom AnswerEngine objects; a model retained from a deleted app rejects, while an equal-config sibling model remains usable through its own app-scoped worker port | ? | `packages/cli/test/e2e/app-deletion.pw.ts` observes AI code `unsupported` for a custom engine, then exercises retained and sibling models through canonical served imports after deleting one app; `packages/pyric/src/ai/sandbox-plane.ts` guards every model operation. A credentialed production AI lifecycle capture is still needed | 27 |\n\n## Current gaps\n\n### Documented divergences\n\nKnown differences between Pyric and production Firebase. Each remains tracked as a non-conforming row.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">initializeApp(differentOptions, 'secondary')</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Production permits a differently configured named app; Pyric rejects it because one runtime currently owns exactly one sandbox backend</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">oracle: <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-initializeapp-named-different-config.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>app-registry-initializeapp-named-different-config</code></a> (firebase 12.13.0) + replay pins <code>app/multiple-configs-not-supported</code>; <a href=\"https://github.com/davideast/pyric/blob/main/packages/cli/test/e2e/app-multi-app.pw.ts#L148\" target=\"_blank\" rel=\"noopener noreferrer\"><code>packages/cli/test/e2e/app-multi-app.pw.ts</code></a> proves the same lock is enforced by the authoritative SharedWorker across same-origin tabs, not only by one page registry (Structured evidence: <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L340\" target=\"_blank\" rel=\"noopener noreferrer\"><code>oracle-conformance.test.ts</code></a>)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">deleteApp(app); initializeApp(differentOptions)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Production permits a different configuration after deletion; Pyric retains the runtime backend lock and rejects the reinitialization</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">oracle: <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-delete-reinitialize-different-config.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>app-registry-delete-reinitialize-different-config</code></a> (firebase 12.13.0) + replay pins <code>app/multiple-configs-not-supported</code> (Structured evidence: <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L351\" target=\"_blank\" rel=\"noopener noreferrer\"><code>oracle-conformance.test.ts</code></a>)</div></div></details>\n</div>\n\n### Not implemented yet\n\nTracked behavior that is not implemented in the current contract.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"unsupported\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">initializeServerApp</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Not implemented; disposition <code>app.server-app</code> owns the reviewed availability and rationale</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">disposition:app.server-app</div></div></details>\n</div>\n\n### Unverified\n\nTracked behavior whose available evidence does not yet establish the production result.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"unverified\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unverified\" role=\"img\" aria-label=\"Unverified\" title=\"Unverified\"></span><span class=\"compat-main\"><code class=\"compat-api\">getAI(app, customEngine); deleteApp(app); retainedModel operation</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Served worker mode rejects page-local custom AnswerEngine objects; a model retained from a deleted app rejects, while an equal-config sibling model remains usable through its own app-scoped worker port</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>packages/cli/test/e2e/app-deletion.pw.ts</code> observes AI code <code>unsupported</code> for a custom engine, then exercises retained and sibling models through canonical served imports after deleting one app; <code>packages/pyric/src/ai/sandbox-plane.ts</code> guards every model operation. A credentialed production AI lifecycle capture is still needed</div></div></details>\n</div>\n\n## Reviewed public-runtime gaps\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">app.server-app</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Server-app (SSR) initialization: a FirebaseServerApp carries per-request auth/heartbeat state with no decided sandbox mirror pattern yet. Deferred, not out of scope — tracked as an unsupported registry row (registry/app.ts app#15).</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>initializeServerApp</code></div>\n<div class=\"compat-note\">registry:app#15</div></div>\n</details>\n</div>\n" }, { "slug": "ai-compat", "label": "AI Logic", "title": "pyric/ai compatibility matrix", "markdown": "<!-- Generated from the conformance model (registry rows + surface contracts). Do not edit by hand; run bun run compat:generate. -->\n\n# `pyric/ai` compatibility matrix\n\n<div class=\"compat-stat\">\n<p class=\"compat-stat-figure\">\n<span class=\"compat-stat-pct\">67.1%</span>\n<span class=\"compat-stat-label\">of the public API supported</span>\n</p>\n<div class=\"compat-meters\">\n<span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 67.1%\"></span></span>\n</div>\n<p class=\"compat-stat-denom\">147 of 219 public API</p>\n</div>\n\n\n## Status legend\n\n| Status | Meaning |\n|---|---|\n| ✓ | **Conforming**: sandbox matches prod, locked by a passing probe |\n| ⚠ | **Diverged (documented)**: intentional difference with a written reason |\n\n## `getAI(target)` and dispatch\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getAI(target) | | `getAI(sandbox)` returns an AI handle bound to the sandbox target; a model minted from it answers through the in-process answer engine | ✓ | [`unit:instances.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/instances.test.ts#L22) test `ai#getai-sandbox-dispatch` (no capture; structural dispatch claim) | getai-sandbox-dispatch |\n| getAI(target) | | After package resolution selects the mirror, `getAI(app)` uses the app's sandbox and the returned handle carries the app | ✓ | [`unit:instances.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/instances.test.ts#L31) test `ai#getai-app-dispatch` (package-resolution dispatch claim) | getai-app-dispatch |\n| getAI(target) | | With no options the backend defaults to `GoogleAIBackend`, `backendType` is `GOOGLE_AI`, and the AI handle location is the empty string | ✓ | [`unit:instances.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/instances.test.ts#L47) test `ai#getai-default-backend` (matches upstream AIOptions default) | getai-default-backend |\n| getAI(target) | | Repeat `getAI` calls with the same target return a stable handle | ✓ | [`unit:instances.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/instances.test.ts#L54) test `ai#getai-idempotent` (no capture; structural claim) | getai-idempotent |\n| getAI(target, options) | | `getAI(sandbox, { backend: new GoogleAIBackend(), engine: { kind: \"scripted\" } })` selects the scripted engine explicitly and behaves identically to the zero-config default | ✓ | [`unit:instances.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/instances.test.ts#L58) test `ai#getai-engine-option` (engine seam per packages/conformance/docs/ai/cdd-deltas.md) | getai-engine-option |\n| VertexAIBackend | | `VertexAIBackend` carries `backendType` `VERTEX_AI`; its location and the resulting AI handle location default to `us-central1` | ✓ | [`unit:instances.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/instances.test.ts#L72) test `ai#backend-vertex` (matches upstream constructor default) | backend-vertex |\n| getGenerativeModel(ai, modelParams) | | A short model name such as `gemini-flash-lite-latest` normalizes to the `models/` resource name on `GenerativeModel.model` | ✓ | [`unit:instances.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/instances.test.ts#L80) test `ai#model-name-short` (upstream AIModel normalization on the GoogleAI backend) | model-name-short |\n| getGenerativeModel(ai, modelParams) | | A `models/`-prefixed name is accepted without double prefixing | ⚠ normalization | [`unit:instances.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/instances.test.ts#L86) test `ai#model-name-prefixed` (no capture; normalization claim) | model-name-prefixed |\n| getGenerativeModel(ai, modelParams) | | `getGenerativeModel` without `modelParams.model` throws an `AIError` with code `no-model` | ✓ | [`unit:instances.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/instances.test.ts#L93) test `ai#model-name-required` (upstream throw contract) | model-name-required |\n| getAI(sandbox) | | The sandbox target with the scripted engine performs no network I/O for generateContent | ✓ | [`unit:instances.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/instances.test.ts#L105) test `ai#getai-sandbox-no-network` (ruling 1 of the engine placement deltas: the scripted engine does no I/O anywhere) | getai-sandbox-no-network |\n\n## `GenerativeModel.generateContent` envelope\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| generateContent() | | The response envelope top-level key set is exactly `candidates`, `modelVersion`, `responseId`, `usageMetadata` | ✓ | Capture ai-generate-minimal-envelope replayed by packages/pyric/test/ai/generate-content.test.ts test `ai#generate-envelope-keys` (Structured evidence: [`ai-generate-minimal-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-minimal-envelope.json), [`generate-content.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/generate-content.test.ts#L4)) | generate-envelope-keys |\n| generateContent() | | The candidate key set is `content`, `finishReason`, `index`, and `index` is present on the wire (0 for the single candidate) | ✓ | Capture ai-generate-minimal-envelope (candidateHasIndexOnWire) replayed by packages/pyric/test/ai/generate-content.test.ts test `ai#generate-candidate-keys` (Structured evidence: [`ai-generate-minimal-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-minimal-envelope.json), [`generate-content.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/generate-content.test.ts#L4)) | generate-candidate-keys |\n| generateContent() | | Candidate content carries role `model` and the content key set is `parts`, `role` | ✓ | Capture ai-generate-minimal-envelope replayed by packages/pyric/test/ai/generate-content.test.ts test `ai#generate-role-model` (Structured evidence: [`ai-generate-minimal-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-minimal-envelope.json), [`generate-content.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/generate-content.test.ts#L4)) | generate-role-model |\n| generateContent() | | A normal completion finishes with `finishReason` `STOP` | ✓ | Capture ai-generate-minimal-envelope replayed by packages/pyric/test/ai/generate-content.test.ts test `ai#generate-finish-stop` (Structured evidence: [`ai-generate-minimal-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-minimal-envelope.json), [`generate-content.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/generate-content.test.ts#L4)) | generate-finish-stop |\n| generateContent() | | The usageMetadata key set on a minimal text call is `candidatesTokenCount`, `promptTokenCount`, `promptTokensDetails`, `serviceTier`, `totalTokenCount` | ✓ | Capture ai-generate-minimal-envelope replayed by packages/pyric/test/ai/generate-content.test.ts test `ai#generate-usage-key-set` (Structured evidence: [`ai-generate-minimal-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-minimal-envelope.json), [`generate-content.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/generate-content.test.ts#L4)) | generate-usage-key-set |\n| generateContent() | | `usageMetadata.serviceTier` rides the wire even though the 2.12.0 SDK typings do not declare it | ✓ | Capture ai-generate-minimal-envelope (usageServiceTierPresent) replayed by packages/pyric/test/ai/generate-content.test.ts test `ai#generate-usage-service-tier` (Structured evidence: [`ai-generate-minimal-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-minimal-envelope.json), [`generate-content.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/generate-content.test.ts#L4)) | generate-usage-service-tier |\n| generateContent() | | `modelVersion` and `responseId` are present nonempty strings; the sandbox mints them deterministically | ✓ | Capture ai-generate-minimal-envelope replayed by packages/pyric/test/ai/generate-content.test.ts test `ai#generate-modelversion-responseid` (Structured evidence: [`ai-generate-minimal-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-minimal-envelope.json), [`generate-content.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/generate-content.test.ts#L4)) | generate-modelversion-responseid |\n| generateContent(request) | | A plain string request is wrapped as a single user turn before it reaches the engine | ✓ | [`unit:generate-content.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/generate-content.test.ts#L82) test `ai#generate-string-request` (no capture; upstream request formatting claim) | generate-string-request |\n| generateContent(request) | | A top-level `systemInstruction` is accepted and the response envelope shape is unaffected | ✓ | Capture ai-system-instruction-accepted replayed by packages/pyric/test/ai/generate-content.test.ts test `ai#generate-system-instruction` (Structured evidence: [`ai-system-instruction-accepted`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-system-instruction-accepted.json), [`generate-content.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/generate-content.test.ts#L100)) | generate-system-instruction |\n| generateContent(request) | | `responseMimeType` `application/json` plus a `responseSchema` yields a text part that parses as JSON with the schema key set | ✓ | Capture ai-structured-output-shape replayed by packages/pyric/test/ai/generate-content.test.ts test `ai#generate-structured-output` (Structured evidence: [`ai-structured-output-shape`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-structured-output-shape.json), [`generate-content.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/generate-content.test.ts#L110)) | generate-structured-output |\n| generateContent(request) | | With `thinkingConfig` on the probe model, text parts carry `thoughtSignature` and no part is flagged `thought: true` | ✓ | Capture ai-thinking-thought-parts (partKeySets, anyThoughtPart false) replayed by packages/pyric/test/ai/generate-content.test.ts test `ai#generate-thinking-signature` (Structured evidence: [`ai-thinking-thought-parts`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-thinking-thought-parts.json), [`generate-content.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/generate-content.test.ts#L125)) | generate-thinking-signature |\n| generateContent(request, singleRequestOptions) | | A pre-aborted `SingleRequestOptions.signal` rejects the call | ✓ | [`unit:generate-content.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/generate-content.test.ts#L138) test `ai#generate-abort-signal` (no capture; upstream SingleRequestOptions contract) | generate-abort-signal |\n| generateContent() | | Token counts are minted without a tokenizer, and the minimal envelope omits `safetyRatings`, matching the captured candidate key set | ✓ | Capture ai-generate-minimal-envelope replayed by packages/pyric/test/ai/generate-content.test.ts test `ai#generate-decoration-synthesized` (Structured evidence: [`ai-generate-minimal-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-minimal-envelope.json), [`generate-content.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/generate-content.test.ts#L4)) | generate-decoration-synthesized |\n\n## `generateContentStream` framing and aggregation\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| generateContentStream() | | `result.stream` async-iterates response chunks via `for await`; each chunk is a complete GenerateContentResponse | ✓ | Capture ai-generate-stream-framing replayed by packages/pyric/test/ai/streaming.test.ts test `ai#stream-async-iterable` (Structured evidence: [`ai-generate-stream-framing`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-stream-framing.json), [`streaming.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/streaming.test.ts#L4)) | stream-async-iterable |\n| generateContentStream() wire framing | | Every SSE event is `data: ` prefixed and its payload parses as a complete JSON document | ✓ | Capture ai-generate-stream-framing (allEventsDataPrefixed) replayed byte-level by packages/pyric/test/ai/streaming.test.ts test `ai#stream-data-prefixed` (Structured evidence: [`ai-generate-stream-framing`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-stream-framing.json), [`streaming.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/streaming.test.ts#L4)) | stream-data-prefixed |\n| generateContentStream() wire framing | | SSE events are separated by CRLF CRLF | ✓ | Capture ai-generate-stream-framing (separatorIsCrlfCrlf) replayed byte-level by packages/pyric/test/ai/streaming.test.ts test `ai#stream-separator-crlf` (Structured evidence: [`ai-generate-stream-framing`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-stream-framing.json), [`streaming.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/streaming.test.ts#L4)) | stream-separator-crlf |\n| generateContentStream() | | `finishReason` appears only on the last chunk of a stream | ✓ | Capture ai-generate-stream-framing (finishReasonOnlyOnLastChunk) replayed by packages/pyric/test/ai/streaming.test.ts test `ai#stream-finish-last-chunk` (Structured evidence: [`ai-generate-stream-framing`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-stream-framing.json), [`streaming.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/streaming.test.ts#L4)) | stream-finish-last-chunk |\n| generateContentStream() | | `usageMetadata` rides every chunk, not only the last one | ✓ | Capture ai-generate-stream-framing (usageMetadataChunkIndexes covers all chunks) replayed by packages/pyric/test/ai/streaming.test.ts test `ai#stream-usage-every-chunk` (Structured evidence: [`ai-generate-stream-framing`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-stream-framing.json), [`streaming.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/streaming.test.ts#L4)) | stream-usage-every-chunk |\n| generateContentStream() | | Every chunk carries `candidates` or `usageMetadata` | ✓ | Capture ai-generate-stream-framing (everyEventHasCandidatesOrUsage) replayed by packages/pyric/test/ai/streaming.test.ts test `ai#stream-chunk-envelope` (Structured evidence: [`ai-generate-stream-framing`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-stream-framing.json), [`streaming.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/streaming.test.ts#L4)) | stream-chunk-envelope |\n| generateContentStream() | | `result.response` resolves to an aggregated response whose text is the concatenation of the streamed text parts | ✓ | [`unit:streaming.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/streaming.test.ts#L107) test `ai#stream-response-aggregate` (aggregation semantics; text values come from an explicit script) | stream-response-aggregate |\n| generateContentStream() | | The aggregated response carries the final chunk `finishReason` and `usageMetadata` | ⚠ metadata carry | [`unit:streaming.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/streaming.test.ts#L117) test `ai#stream-aggregate-final-meta` (aggregation semantics derived from the framing capture) | stream-aggregate-final-meta |\n\n## `ChatSession` history and streaming turns\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| GenerativeModel.startChat() | | `startChat` returns a `ChatSession` seeded with `StartChatParams.history` | ✓ | [`unit:upstream-ai-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/upstream-ai-probes.test.ts#L6) (I1 validateChatHistory accept/reject via startChat) + [`unit:chat-session.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/chat-session.test.ts#L32) test `ai#chat-startchat` | chat-startchat |\n| ChatSession.sendMessage() / getHistory() | | `sendMessage` appends the user turn and the model turn; `getHistory()` returns the ordered `Content[]` with alternating roles | ⚠ clone | [`unit:chat-session.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/chat-session.test.ts#L45) test `ai#chat-history-threads` (no capture; history threading claim) | chat-history-threads |\n| ChatSession.getHistory() | | Blocked prompts and blocked candidates are excluded from `getHistory()` | ⚠ blocked history | [`unit:chat-session.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/chat-session.test.ts#L61) test `ai#chat-history-excludes-blocked` (upstream JSDoc contract; exercised with a scripted blocked envelope) | chat-history-excludes-blocked |\n| ChatSession.sendMessage() | | A `sendMessage` result carries the same envelope facts as `generateContent`: the four top-level keys and role `model` | ✓ | Capture ai-generate-minimal-envelope replayed by packages/pyric/test/ai/chat-session.test.ts test `ai#chat-sendmessage-envelope` (Structured evidence: [`ai-generate-minimal-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-minimal-envelope.json), [`chat-session.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/chat-session.test.ts#L11)) | chat-sendmessage-envelope |\n| ChatSession.sendMessageStream() | | `sendMessageStream` returns a stream plus a response promise; history updates after aggregation completes | ✓ | [`unit:chat-session.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/chat-session.test.ts#L80) test `ai#chat-sendmessagestream` (no capture; streaming turn claim) | chat-sendmessagestream |\n| ChatSession.sendMessageStream() | | Exactly one user turn is recorded per `sendMessageStream` call; the mirror implements the 2.13.0 fixed semantics, not the installed 2.12.0 duplicate-user-turn bug | ⚠ 2.13.0 semantics | [`unit:chat-session.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/chat-session.test.ts#L93) test `ai#chat-stream-single-user-turn` (no capture; divergence pinned by ruling, see notes) | chat-stream-single-user-turn |\n| POSSIBLE_ROLES | | `POSSIBLE_ROLES` is exactly `[\"user\", \"model\", \"function\", \"system\"]` | ✓ | [`unit:chat-session.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/chat-session.test.ts#L108) test `ai#chat-role-vocabulary` (upstream constant; distinct from the production wire role vocabulary in ai-error-bad-role) | chat-role-vocabulary |\n\n## Function calling\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| functionCall parts | | A functionCall part carries the key set `args`, `id`, `name`, and `args` arrives as a parsed JSON object, not a string | ✓ | Capture ai-function-call-shape (functionCallKeySet, argsIsObjectNotString) replayed by packages/pyric/test/ai/function-calling.test.ts test `ai#fncall-part-shape` (Structured evidence: [`ai-function-call-shape`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-function-call-shape.json), [`function-calling.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/function-calling.test.ts#L3)) | fncall-part-shape |\n| toolConfig.functionCallingConfig | | Mode `ANY` forces a functionCall part in the response and the candidate finishes `STOP` | ✓ | Capture ai-function-call-shape (captured under mode ANY, finishReason STOP) replayed by packages/pyric/test/ai/function-calling.test.ts test `ai#fncall-mode-any` (Structured evidence: [`ai-function-call-shape`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-function-call-shape.json), [`function-calling.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/function-calling.test.ts#L3)) | fncall-mode-any |\n| functionCall parts | | `functionCall.id` is present on the GoogleAI wire; the mirror emits an id on synthesized calls | ✓ | Capture ai-function-call-shape (id in functionCallKeySet) replayed by packages/pyric/test/ai/function-calling.test.ts test `ai#fncall-id-present` (Structured evidence: [`ai-function-call-shape`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-function-call-shape.json), [`function-calling.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/function-calling.test.ts#L3)) | fncall-id-present |\n| functionResponse round trip | | A round trip that threads the model functionCall turn back verbatim, thoughtSignature preserved, is accepted: the answer has a text part and no further functionCall part | ✓ | Capture ai-function-response-round replayed by packages/pyric/test/ai/function-calling.test.ts test `ai#fncall-round-trip` (Structured evidence: [`ai-function-response-round`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-function-response-round.json), [`function-calling.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/function-calling.test.ts#L4)) | fncall-round-trip |\n| functionResponse round trip | | A replayed model functionCall turn lacking `thoughtSignature` is rejected 400 INVALID_ARGUMENT with the thought-signature message | ✓ | Capture ai-error-fncall-missing-thought-signature replayed by packages/pyric/test/ai/function-calling.test.ts test `ai#fncall-thought-signature-required` (Structured evidence: [`ai-error-fncall-missing-thought-signature`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-error-fncall-missing-thought-signature.json), [`function-calling.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/function-calling.test.ts#L4)) | fncall-thought-signature-required |\n| scripted engine synthesis | | The engine mints a `thoughtSignature` on every functionCall part it synthesizes, so scripted tool round trips replay cleanly | ✓ | [`unit:function-calling.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/function-calling.test.ts#L4) test `ai#fncall-signature-minted` (capture ai-error-fncall-missing-thought-signature cited as the motivating rejection) (Structured evidence: [`ai-error-fncall-missing-thought-signature`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-error-fncall-missing-thought-signature.json)) | fncall-signature-minted |\n\n## `countTokens`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| countTokens() | | The countTokens envelope key set is exactly `promptTokensDetails`, `totalTokens` | ✓ | Capture ai-counttokens-envelope replayed by packages/pyric/test/ai/errors-counttokens.test.ts test `ai#counttokens-envelope` (Structured evidence: [`ai-counttokens-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-counttokens-envelope.json), [`errors-counttokens.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/errors-counttokens.test.ts#L39)) | counttokens-envelope |\n| countTokens() | | An identical payload returns an identical `totalTokens` across calls | ✓ | Capture ai-counttokens-envelope (deterministicAcrossTwoCalls) replayed by packages/pyric/test/ai/errors-counttokens.test.ts test `ai#counttokens-deterministic` (Structured evidence: [`ai-counttokens-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-counttokens-envelope.json), [`errors-counttokens.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/errors-counttokens.test.ts#L40)) | counttokens-deterministic |\n\n## Error envelopes\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| error envelope | | A model name production has never served fails 404 NOT_FOUND with the error key set `code`, `message`, `status` and no details | ✓ | Capture ai-error-unknown-model replayed by packages/pyric/test/ai/errors-counttokens.test.ts test `ai#error-unknown-model` (Structured evidence: [`ai-error-unknown-model`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-error-unknown-model.json), [`errors-counttokens.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/errors-counttokens.test.ts#L59)) | error-unknown-model |\n| error envelope | | A retired model family (Gemini 1.5) fails 404 NOT_FOUND with an ErrorInfo detail and a retirement message distinct from unknown-model | ✓ | Capture ai-error-retired-model replayed by packages/pyric/test/ai/errors-counttokens.test.ts test `ai#error-retired-model` (Structured evidence: [`ai-error-retired-model`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-error-retired-model.json), [`errors-counttokens.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/errors-counttokens.test.ts#L70)) | error-retired-model |\n| error envelope | | An invalid API key fails 400 INVALID_ARGUMENT, not 401, with ErrorInfo plus LocalizedMessage details and the message `API key not valid. Please pass a valid API key.` | ✓ | Capture ai-error-bad-api-key replayed by packages/pyric/test/ai/errors-counttokens.test.ts test `ai#error-bad-api-key` (Structured evidence: [`ai-error-bad-api-key`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-error-bad-api-key.json), [`errors-counttokens.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/errors-counttokens.test.ts#L82)) | error-bad-api-key |\n| error envelope | | An empty `contents` array fails 400 INVALID_ARGUMENT with the message `contents is not specified` | ✓ | Capture ai-error-empty-contents replayed by packages/pyric/test/ai/errors-counttokens.test.ts test `ai#error-empty-contents` (Structured evidence: [`ai-error-empty-contents`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-error-empty-contents.json), [`errors-counttokens.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/errors-counttokens.test.ts#L116)) | error-empty-contents |\n| error envelope | | An invalid content role fails 400 INVALID_ARGUMENT and the message lists the production role vocabulary: SYSTEM, SYSTEM_1, USER, ASSISTANT, DEVELOPER, CONTEXT, USER_CONTEXT, MODEL, USER | ✓ | Capture ai-error-bad-role replayed by packages/pyric/test/ai/errors-counttokens.test.ts test `ai#error-bad-role` (Structured evidence: [`ai-error-bad-role`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-error-bad-role.json), [`errors-counttokens.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/errors-counttokens.test.ts#L125)) | error-bad-role |\n| AIError | | HTTP failures surface as `AIError` with an `AIErrorCode` code and `customErrorData` carrying `status`, `statusText`, and `errorDetails` | ✓ | [`unit:errors-counttokens.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/errors-counttokens.test.ts#L83) test `ai#error-aierror-shape` (capture ai-error-bad-api-key cited as the sample envelope) (Structured evidence: [`ai-error-bad-api-key`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-error-bad-api-key.json)) | error-aierror-shape |\n| AIErrorCode | | `AIErrorCode` exposes the 14 documented codes, from `error` through `unsupported` | ✓ | [`unit:errors-counttokens.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/errors-counttokens.test.ts#L154) test `ai#error-code-vocabulary` (upstream constant vocabulary) | error-code-vocabulary |\n\n## Response helpers (`EnhancedGenerateContentResponse`)\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| response.text() | | `text()` concatenates the text parts of the first candidate | ✓ | [`unit:upstream-ai-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/upstream-ai-probes.test.ts#L11) (I3 text() across mixed parts) + [`unit:helpers-schema.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/helpers-schema.test.ts#L33) test `ai#helper-text` | helper-text |\n| response.text() | | `text()` throws on bad finish reasons such as `SAFETY` and on a blocked prompt | ✓ | [`unit:helpers-schema.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/helpers-schema.test.ts#L51) test `ai#helper-text-throws` (exercised with a scripted SAFETY envelope) | helper-text-throws |\n| response.functionCalls() | | `functionCalls()` returns the `FunctionCall` array from the functionCall parts, args as parsed objects | ✓ | [`unit:upstream-ai-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/upstream-ai-probes.test.ts#L113) (I3 text+functionCall mix) + Capture ai-function-call-shape replayed by [`unit:helpers-schema.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/helpers-schema.test.ts#L70) test `ai#helper-functioncalls` (Structured evidence: [`ai-function-call-shape`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-function-call-shape.json)) | helper-functioncalls |\n| response.thoughtSummary() | | `thoughtSummary()` returns undefined when no part is flagged `thought: true`, the captured lite-model case | ✓ | [`unit:upstream-ai-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/upstream-ai-probes.test.ts#L11) (I3 thoughtSummary from thought parts) + Capture ai-thinking-thought-parts replayed by [`unit:helpers-schema.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/helpers-schema.test.ts#L85) test `ai#helper-thoughtsummary` (Structured evidence: [`ai-thinking-thought-parts`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-thinking-thought-parts.json)) | helper-thoughtsummary |\n| response.inlineDataParts() | | `inlineDataParts()` returns the `InlineDataPart` array when inlineData parts exist and undefined when none do | ✓ | [`unit:helpers-schema.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/helpers-schema.test.ts#L93) test `ai#helper-inlinedataparts` (exercised with a scripted raw envelope) | helper-inlinedataparts |\n| response helpers | | Helpers tolerate omitted decoration: an envelope without `usageMetadata`, `finishReason`, or `safetyRatings` still serves `text()` without throwing | ✓ | [`unit:helpers-schema.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/helpers-schema.test.ts#L124) test `ai#helper-tolerates-missing-decor` (exercised with a scripted bare envelope) | helper-tolerates-missing-decor |\n\n## `Schema` builders\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Schema.object() | | `Schema.object` serializes to type `object` with `properties`, and `required` is derived by excluding `optionalProperties` | ✓ | [`unit:upstream-ai-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/upstream-ai-probes.test.ts#L8) (I2 empty optionalProperties + propertyOrdering) + [`unit:helpers-schema.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/helpers-schema.test.ts#L139) test `ai#schema-object-tojson` | schema-object-tojson |\n| Schema.enumString() | | `Schema.enumString` serializes the enum values with type `string` and format `enum` | ⚠ format | [`unit:helpers-schema.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/helpers-schema.test.ts#L153) test `ai#schema-string-enum` (upstream toJSON request shape; GoogleAI accepts only enum and date-time formats) | schema-string-enum |\n| Schema.string()/integer()/number()/boolean()/array() | | Each primitive builder serializes its `SchemaType`, and `array` carries `items` | ✓ | [`unit:helpers-schema.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/helpers-schema.test.ts#L160) test `ai#schema-primitives` (upstream toJSON request shape) | schema-primitives |\n| Schema.anyOf() | | `Schema.anyOf` returns an `AnyOfSchema` whose JSON carries an `anyOf` array of sub-schemas and no top-level type | ✓ | [`unit:upstream-ai-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/upstream-ai-probes.test.ts#L8) (I2 empty anyOf → invalid-schema) + [`unit:helpers-schema.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/helpers-schema.test.ts#L170) test `ai#schema-anyof` | schema-anyof |\n| generationConfig.responseSchema | | A built `Schema` serializes into `generationConfig.responseSchema` on the request and drives JSON output | ✓ | Capture ai-structured-output-shape replayed by packages/pyric/test/ai/helpers-schema.test.ts test `ai#schema-rides-request` (Structured evidence: [`ai-structured-output-shape`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-structured-output-shape.json), [`helpers-schema.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/helpers-schema.test.ts#L179)) | schema-rides-request |\n\n## Sandbox answer engine: scripted\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| scripted engine | | With no script the engine returns a deterministic synthesized response derived from the request, wire-true in shape: the captured envelope key sets hold | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L10) test `ai#scripted-zero-config` (capture ai-generate-minimal-envelope cited as the shape source) (Structured evidence: [`ai-generate-minimal-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-minimal-envelope.json)) | scripted-zero-config |\n| scripted engine | | The same unscripted request twice yields an identical envelope, candidates and usage included | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L47) test `ai#scripted-deterministic` (determinism claim from the scripted authoring deltas) | scripted-deterministic |\n| script(ai, entries) | | Script entries without matchers are consumed in FIFO queue order | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L57) test `ai#scripted-queue-order` (ruling 2 of the scripted authoring deltas) | scripted-queue-order |\n| script(ai, entries) | | Entries match by substring, regex, or predicate on the request; a matching entry wins over the plain queue | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L66) test `ai#scripted-matchers` (ruling 2 of the scripted authoring deltas) | scripted-matchers |\n| script(ai, entries) | | A raw Gemini envelope entry is returned verbatim, so an observation `behavior.raw` pastes in directly and captures are the corpus | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L79) test `ai#scripted-raw-envelope` (ruling 3 of the scripted authoring deltas) | scripted-raw-envelope |\n| script(ai, entries) | | A `text` shorthand expands to a wire-true envelope: finishReason STOP, usageMetadata with serviceTier, modelVersion, responseId | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L10) test `ai#scripted-shorthand-text` (capture ai-generate-minimal-envelope cited as the expansion target) (Structured evidence: [`ai-generate-minimal-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-minimal-envelope.json)) | scripted-shorthand-text |\n| script(ai, entries) | | A `functionCall` shorthand expands to a model turn whose functionCall part carries a minted `thoughtSignature` | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L116) test `ai#scripted-shorthand-functioncall` (capture ai-error-fncall-missing-thought-signature cited as the motivating rejection) (Structured evidence: [`ai-error-fncall-missing-thought-signature`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-error-fncall-missing-thought-signature.json)) | scripted-shorthand-functioncall |\n| script(ai, entries) | | A chunk-array shorthand declares chunk boundaries and the engine applies the captured framing, so authors never hand-write SSE | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L11) test `ai#scripted-stream-chunks` (capture ai-generate-stream-framing cited as the framing source) (Structured evidence: [`ai-generate-stream-framing`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-stream-framing.json)) | scripted-stream-chunks |\n| script(ai, entries) | | Scripted text is the one place generated text values may be asserted: `response.text()` returns the scripted string exactly | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L142) test `ai#scripted-text-assertable` (evidence tier ruling 1: generated text is never compared anywhere else) | scripted-text-assertable |\n\n## Sandbox answer engine: openai translation\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| openai engine | | Gemini `contents` and `systemInstruction` translate to OpenAI chat messages, and the OpenAI response translates back to a Gemini envelope with role `model` | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L185) test `ai#openai-request-translation` (translation exercised against a local OpenAI-compatible mock) | openai-request-translation |\n| openai engine | | OpenAI `tool_call` ids are matched FIFO against Gemini functionResponse parts when replaying tool history | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L198) test `ai#openai-fifo-tool-ids` (lossy translation edge from ticket #96) | openai-fifo-tool-ids |\n| openai engine | | Streamed OpenAI tool_call deltas are buffered; the Gemini stream emits whole functionCall parts with parsed args, never partial fragments | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L230) test `ai#openai-buffered-fncalls` (lossy translation edge from ticket #96) | openai-buffered-fncalls |\n| openai engine | | The OpenAI `[DONE]` sentinel is never forwarded as a Gemini chunk; every emitted chunk is a parseable Gemini envelope | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L254) test `ai#openai-done-not-forwarded` (lossy translation edge from ticket #96) | openai-done-not-forwarded |\n| openai engine | | Parts flagged `thought: true` in history are skipped when replaying to an OpenAI upstream | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L271) test `ai#openai-thought-parts-skipped` (lossy translation edge from ticket #96) | openai-thought-parts-skipped |\n\n## Current gaps\n\n### Documented divergences\n\nKnown differences between Pyric and production Firebase. Each remains tracked as a non-conforming row.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">getGenerativeModel(ai, modelParams)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">A <code>models/</code>-prefixed name is accepted without double prefixing</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/instances.test.ts#L86\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:instances.test.ts</code></a> test <code>ai#model-name-prefixed</code> (no capture; normalization claim)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">generateContentStream()</code><span class=\"compat-sub\"><span class=\"compat-behavior\">The aggregated response carries the final chunk <code>finishReason</code> and <code>usageMetadata</code></span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/streaming.test.ts#L117\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:streaming.test.ts</code></a> test <code>ai#stream-aggregate-final-meta</code> (aggregation semantics derived from the framing capture)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">ChatSession.sendMessage() / getHistory()</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>sendMessage</code> appends the user turn and the model turn; <code>getHistory()</code> returns the ordered <code>Content[]</code> with alternating roles</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/chat-session.test.ts#L45\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:chat-session.test.ts</code></a> test <code>ai#chat-history-threads</code> (no capture; history threading claim)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">ChatSession.getHistory()</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Blocked prompts and blocked candidates are excluded from <code>getHistory()</code></span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/chat-session.test.ts#L61\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:chat-session.test.ts</code></a> test <code>ai#chat-history-excludes-blocked</code> (upstream JSDoc contract; exercised with a scripted blocked envelope)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">ChatSession.sendMessageStream()</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Exactly one user turn is recorded per <code>sendMessageStream</code> call; the mirror implements the 2.13.0 fixed semantics, not the installed 2.12.0 duplicate-user-turn bug</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/chat-session.test.ts#L93\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:chat-session.test.ts</code></a> test <code>ai#chat-stream-single-user-turn</code> (no capture; divergence pinned by ruling, see notes)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">Schema.enumString()</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>Schema.enumString</code> serializes the enum values with type <code>string</code> and format <code>enum</code></span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/helpers-schema.test.ts#L153\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:helpers-schema.test.ts</code></a> test <code>ai#schema-string-enum</code> (upstream toJSON request shape; GoogleAI accepts only enum and date-time formats)</div></div></details>\n</div>\n\n## Reviewed public-runtime gaps\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Out of scope\" title=\"Out of scope\"></span><span class=\"compat-main\"><code class=\"compat-api\">ai.imagen-retirement</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Imagen is deprecated upstream; all Imagen models shut down as early as June 2026 (upstream 2.11.0 deprecation). Mirroring an API whose production counterpart is retiring would freeze dead behavior.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>ImagenAspectRatio</code> <code>ImagenImageFormat</code> <code>ImagenModel</code> <code>ImagenPersonFilterLevel</code> <code>ImagenSafetyFilterLevel</code> <code>getImagenModel</code></div>\n<div class=\"compat-note\">upstream:firebase/ai@2.11.0</div></div>\n</details>\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">ai.hybrid-inference</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Hybrid/on-device inference is deferred, not out of scope — the sandbox runs in the browser and can model the on-device path through the answer-engine seam; the mode has not been wired yet.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>InferenceMode</code></div>\n<div class=\"compat-note\">upstream:firebase/ai</div></div>\n</details>\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">ai.inference-source</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Deferred with hybrid/on-device inference — the source marker is meaningful once the hybrid mode lands.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>InferenceSource</code></div>\n<div class=\"compat-note\">disposition:ai.hybrid-inference</div></div>\n</details>\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">ai.live-api</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Live API is deferred, not out of scope — it is a bidirectional websocket protocol the sandbox can model with a scripted session engine, the same seam pattern the REST plane already uses; the work has not happened yet.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>LiveGenerativeModel</code> <code>LiveResponseType</code> <code>LiveSession</code> <code>getLiveGenerativeModel</code></div>\n<div class=\"compat-note\">upstream:firebase/ai</div></div>\n</details>\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">ai.server-templates</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Server-side templates are deferred, not out of scope — hosting template state is exactly the kind of server infrastructure the sandbox exists to model; the template store has not been built yet.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>TemplateGenerativeModel</code> <code>getTemplateGenerativeModel</code></div>\n<div class=\"compat-note\">upstream:firebase/ai</div></div>\n</details>\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Out of scope\" title=\"Out of scope\"></span><span class=\"compat-main\"><code class=\"compat-api\">ai.template-imagen-retirement</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Template-served Imagen models retire with the rest of Imagen (deprecated upstream, June 2026 shutdown); the template transport does not outlive the model family it serves.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>TemplateImagenModel</code> <code>getTemplateImagenModel</code></div>\n<div class=\"compat-note\">upstream:firebase/ai@2.11.0</div></div>\n</details>\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">ai.live-audio</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Deferred with the rest of the Live API family — the browser audio helper rides the Live session seam once that lands.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>startAudioConversation</code></div>\n<div class=\"compat-note\">disposition:ai.live-api</div></div>\n</details>\n</div>\n" }, { "slug": "auth-compat", "label": "Auth", "title": "pyric/auth compatibility matrix", "markdown": "<!-- Generated from the conformance model (registry rows + surface contracts). Do not edit by hand; run bun run compat:generate. -->\n\n# `pyric/auth` compatibility matrix\n\n<div class=\"compat-stat\">\n<p class=\"compat-stat-figure\">\n<span class=\"compat-stat-pct\">63.8%</span>\n<span class=\"compat-stat-label\">of the public API supported</span>\n</p>\n<div class=\"compat-meters\">\n<span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 63.8%\"></span></span>\n</div>\n<p class=\"compat-stat-denom\">95 of 149 public API</p>\n</div>\n\n\n## Status legend\n\n| Status | Meaning |\n|---|---|\n| ✓ | **Conforming** — sandbox matches prod, locked by a passing probe |\n| ⚠ | **Diverged (documented)** — intentional difference with a written reason |\n| — | **Not implemented yet** — deliberately or pending |\n| ? | **Unverified** — claim from docs that we haven't yet observed prod-side |\n\n## `getAuth(target)` — initializer\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getAuth(target) | | Returns a stable `Auth` handle for repeated calls with the same input | ✓ | [`unit:sandbox-anonymous.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-anonymous.test.ts#L13) + canonical Node register child ([`register-child.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/register/register-child.test.ts#L152)) | 1 |\n| getAuth(target) | | `getAuth(sandbox)` dispatches to the sandbox backend | ✓ | [`unit:sandbox-anonymous.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-anonymous.test.ts#L13) | 2 |\n| getAuth(target) | | Without sandbox package swapping, canonical `firebase/auth` imports remain Firebase and never enter this mirror | ? | Direct mirror rejection is locked by `unit:package-resolution.test.ts`; an unswapped production-resolution observation is still needed | 3 |\n| getAuth(target) | | After package swapping, bare `getAuth()` resolves the registered default sandbox app; without swapping, Firebase retains its `app/no-app` behavior when no default app exists | ✓ (wrap) | canonical Node register child ([`register-child.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/register/register-child.test.ts#L152)) + oracle: [`packages/conformance/observations/auth/auth-bare-getauth-no-default-app.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-bare-getauth-no-default-app.json) (`code: 'app/no-app'` against blockingfun, fb-js-sdk 12.13.0 — confirms unswapped Firebase behavior) | 4 |\n| getAuth(target) | | `auth.currentUser` is a live getter, not a snapshot — reads through to the backend on every access | ✓ | implicit in [`unit:sandbox-anonymous.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-anonymous.test.ts#L13) | 5 |\n\n## Multi-app Auth session topology\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getAuth(app) | | Equal-config Firebase apps have independent active Auth sessions; signing in or out on one app does not change another app's currentUser | ✓ | oracle: [`app-production-multi-app-topology`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-production-multi-app-topology.json) (firebase 12.13.0, real Chromium against production) + twin replay: [`packages/pyric/test/app/production-multi-app-oracle.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/production-multi-app-oracle.test.ts#L15) | 183 |\n| browserLocalPersistence across same-named app instances in sibling tabs | | After the sibling receives the LOCAL persistence event, same-named Firebase app instances in sibling tabs keep independent active Auth sessions; an anonymous sign-in in one tab does not update the sibling tab's currentUser during the bounded post-signal observation window | ✓ | oracle: [`app-production-cross-tab-auth-persistence`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-production-cross-tab-auth-persistence.json) (firebase 12.13.0, two real Chromium pages in one browser context) + served twin: [`packages/cli/test/e2e/app-multi-app.pw.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/e2e/app-multi-app.pw.ts#L12) | 184 |\n\n## `signInAnonymously(auth)` — anonymous\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| signInAnonymously(auth) | | Returns `UserCredential` with `providerId: null`, `operationType: 'signIn'`, and a `User` with `isAnonymous: true`, `email: null`, `displayName: null` | ✓ | [`unit:sandbox-anonymous.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-anonymous.test.ts#L30), `playground:auth-anonymous` (bundled) + `playground:auth-row-6-anon-credential-shape` (one-claim) + oracle: [`packages/conformance/observations/auth/auth-anonymous-credential-providerid.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-anonymous-credential-providerid.json) (`providerId: null` against blockingfun, fb-js-sdk 12.13.0). Prior matrix language said `providerId: 'anonymous'`; corrected after empirical observation. Sandbox aligned to prod in the same commit. | 6 |\n| signInAnonymously(auth) | | Auto-generates a uid for fresh sign-ins (sandbox format: `anonymous-{N}`) | ⚠ format | [`unit:sandbox-anonymous.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-anonymous.test.ts#L7) — prod uids are 28-char base64-ish; sandbox uses a readable counter for debuggability | 7 |\n| signInAnonymously(auth) | | If an anonymous user is already signed in, returns the SAME user (no fresh uid mint) | ✓ | [`unit:sandbox-anonymous.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-anonymous.test.ts#L7) (\"idempotent while signed in\") — fix from #399 | 8 |\n| signInAnonymously(auth) | | After `signOut`, a subsequent `signInAnonymously` mints a fresh uid | ✓ | [`unit:sandbox-anonymous.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-anonymous.test.ts#L7), `playground:auth-anonymous` (bundled) + `playground:auth-row-9-anon-fresh-uid-after-signout` (one-claim) | 9 |\n| signInAnonymously(auth) | | Fires `onAuthStateChanged` exactly once per state transition (no same-value double-fire) | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L24), `playground:auth-anonymous` (bundled) + [`playground:auth-row-10-onauthstatechanged-one-per-transition`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-10-onauthstatechanged-one-per-transition.json) (one-claim) — fix from #399 + oracle: `packages/conformance/observations/auth/auth-row-10-onauthstatechanged-one-per-transition.json` (against blockingfun, fb-js-sdk 12.13.0: subscribe → 1 initial null fire → `signInAnonymously` → +1 → `signOut` → +1 → `signInAnonymously` → +1. `eachTransitionFiredExactlyOnce: true` — every transition produces exactly one fire) | 10 |\n| signInAnonymously(auth) | | Writes through to `sandbox.currentUser` so rules engines see `request.auth.uid` | ✓ | [`unit:sandbox-anonymous.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-anonymous.test.ts#L7), `playground:auth-anonymous` (bundled) + `playground:auth-row-11-anon-uid-visible-to-rules` (one-claim) | 11 |\n| signInAnonymously(auth) | | Anonymous users persist across page reload via configured `Persistence` (prod only — sandbox has no persistence layer) | ⚠ | divergence: sandbox memory only; within one tab the user persists, across reload they don't | 12 |\n\n## `signInWithEmailAndPassword(auth, email, password)` — password\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| signInWithEmailAndPassword(auth, email, password) | | Returns `UserCredential` with `providerId: null` (NOT `'password'` — only OAuth/phone responses carry a providerId; upstream `providerIdForResponse` returns null for email/password), `operationType: 'signIn'`, and a `User` with the stored uid + email | ✓ | [`unit:sandbox-email-password.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-password.test.ts#L29) + oracle: [`packages/conformance/observations/auth/auth-createUser-operationType.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-createUser-operationType.json) (`providerId: null` against blockingfun, fb-js-sdk 12.13.0). Prior matrix language said `providerId: 'password'`; corrected after the oracle contradicted it (AUTH-B2). | 13 |\n| signInWithEmailAndPassword(auth, email, password) | | Throws `auth/user-not-found` when the email isn't seeded / hasn't been created | ✓ | [`unit:sandbox-email-password.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-password.test.ts#L4) + oracle: [`packages/conformance/observations/auth/auth-user-not-found-error-code.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-user-not-found-error-code.json) (`code: 'auth/user-not-found'` against blockingfun, fb-js-sdk 12.13.0; matches sandbox) | 14 |\n| signInWithEmailAndPassword(auth, email, password) | | Throws `auth/wrong-password` when the password doesn't match | ✓ | [`unit:sandbox-email-password.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-password.test.ts#L4), `playground:auth-email-password` (bundled) + `playground:auth-row-15-wrong-password-rejects` (one-claim) + oracle: [`packages/conformance/observations/auth/auth-wrong-password-error-code.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-wrong-password-error-code.json) (`code: 'auth/wrong-password'` against blockingfun, fb-js-sdk 12.13.0; matches sandbox) | 15 |\n| signInWithEmailAndPassword(auth, email, password) | | An EMPTY password throws `auth/missing-password` (message \"A non-empty password must be provided\"), fired before the user-DB lookup so it can't be used to enumerate seeded emails. Upstream maps the `MISSING_PASSWORD` server error (`core/errors.ts:92,282,563`). ⚠ best-known semantics — message text not yet captured against a live project (STOP-flagged for an oracle pass; the `.code` is the load-bearing part). | ⚠ | [`unit:sandbox-cluster-b9-b12.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-cluster-b9-b12.test.ts#L18) (locks AUTH-B11) | 15a |\n| signInWithEmailAndPassword(auth, email, password) | | Re-signing in after `signOut` returns the **same** uid (passwords persist within the sandbox lifetime) | ✓ | `playground:auth-email-password` (bundled) + `playground:auth-row-16-resignin-same-uid` (one-claim) | 16 |\n| signInWithEmailAndPassword(auth, email, password) | | Fires `onAuthStateChanged` with the new user once | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L25) + oracle: [`packages/conformance/observations/auth/auth-row-17-signin-email-password-fires-once.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-17-signin-email-password-fires-once.json) (against blockingfun, fb-js-sdk 12.13.0: createUser → signOut → subscribe (1 initial null fire) → `signInWithEmailAndPassword` → `firesForSignIn: 1` with the signed-in uid, `lastFireUidMatches: true`) | 17 |\n| signInWithEmailAndPassword(auth, email, password) | | Email validation (RFC 5322ish) — rejects empty, missing `@`, missing local-part, missing domain with `auth/invalid-email`. Runs on both `signInWithEmailAndPassword` and `createUserWithEmailAndPassword` before any user-DB lookup, so consumers shipping malformed input see the same error sandbox vs prod. | ✓ | [`unit:sandbox-email-password.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-password.test.ts#L151) + oracle: [`packages/conformance/observations/auth/auth-row-18-invalid-email-error-code.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-18-invalid-email-error-code.json) (`code: 'auth/invalid-email'`, message `Firebase: Error (auth/invalid-email).` against blockingfun, fb-js-sdk 12.13.0) | 18 |\n| signInWithEmailAndPassword(auth, email, password) | | Password strength requirements — rejects passwords shorter than 6 chars with `auth/weak-password` on `createUserWithEmailAndPassword`. Strength is NOT enforced on `signInWithEmailAndPassword` so previously-seeded weak passwords still let the user in (matches prod's separation of registration vs sign-in). | ✓ | [`unit:sandbox-email-password.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-password.test.ts#L225) + oracle: [`packages/conformance/observations/auth/auth-row-19-weak-password-error-code.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-19-weak-password-error-code.json) (`code: 'auth/weak-password'`, message `Firebase: Password should be at least 6 characters (auth/weak-password).` against blockingfun, fb-js-sdk 12.13.0; matrix language \"≥6 chars per prod default\" empirically confirmed) | 19 |\n| signInWithEmailAndPassword(auth, email, password) | | Disabled accounts (`sandbox.updateUser(…, {disabled: true})`) reject sign-in with `auth/user-disabled` and prod's documented message (`The user account has been disabled by an administrator.`). Sandbox checks disabled BEFORE the password compare (anti-probing); the exact prod ordering of disabled-vs-wrong-password needs an oracle capture | ✓ code / ? ordering | [`unit:sandbox-user-admin.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-user-admin.test.ts#L19) (\"disabled users\") | 69 |\n\n## `createUserWithEmailAndPassword(auth, email, password)` — register\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| createUserWithEmailAndPassword(auth, email, password) | | Creates a new user, signs them in automatically (currentUser becomes the new user) | ✓ | `playground:auth-email-password` (bundled) + `playground:auth-row-20-create-user-auto-signs-in` (one-claim), [`unit:sandbox-email-password.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-password.test.ts#L6) | 20 |\n| createUserWithEmailAndPassword(auth, email, password) | | Returns `UserCredential` with `operationType: 'signIn'` (NOT `'register'` — matches prod) | ✓ | [`unit:sandbox-email-password.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-password.test.ts#L29) + oracle: [`packages/conformance/observations/auth/auth-createUser-operationType.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-createUser-operationType.json) (`operationType: 'signIn'` against blockingfun, fb-js-sdk 12.13.0; matches sandbox) (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L145)) | 21 |\n| createUserWithEmailAndPassword(auth, email, password) | | Throws `auth/email-already-in-use` when the email is already registered | ✓ | [`unit:sandbox-email-password.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-password.test.ts#L6) + oracle: [`packages/conformance/observations/auth/auth-email-already-in-use-error-code.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-email-already-in-use-error-code.json) (`code: 'auth/email-already-in-use'` against blockingfun, fb-js-sdk 12.13.0; matches sandbox) | 22 |\n| createUserWithEmailAndPassword(auth, email, password) | | The created user has `isAnonymous: false`, `email: <input>`, `displayName: null` | ✓ | [`unit:sandbox-email-password.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-password.test.ts#L6) + `playground:auth-row-23-create-user-shape` (one-claim) | 23 |\n| createUserWithEmailAndPassword(auth, email, password) | | Fires `onAuthStateChanged` with the new user once | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L23) + oracle: [`packages/conformance/observations/auth/auth-row-24-createuser-fires-once.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-24-createuser-fires-once.json) (against blockingfun, fb-js-sdk 12.13.0: subscribe (1 initial null fire) → `createUserWithEmailAndPassword` → `firesForCreate: 1` with the newly-created uid, `lastFireUidMatches: true`) | 24 |\n\n## `signOut(auth)`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| signOut(auth) | | Sets `currentUser` to `null` synchronously after resolution | ✓ | `playground:auth-anonymous` (bundled) + `playground:auth-row-25-signout-currentuser-null` (one-claim), [`unit:sandbox-anonymous.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-anonymous.test.ts#L7) + oracle: [`packages/conformance/observations/auth/auth-row-25-signout-currentuser-null-sync.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-25-signout-currentuser-null-sync.json) (`currentUserIsNullSync: true` against blockingfun, fb-js-sdk 12.13.0 — `auth.currentUser` read in the synchronous continuation immediately after `await signOut(auth)` is already `null`, with no microtask/macrotask required to settle) | 25 |\n| signOut(auth) | | Fires `onAuthStateChanged` with `null` exactly once | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L26), `playground:auth-anonymous` (bundled) + [`playground:auth-row-26-signout-fires-null-once`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-26-signout-fires-null-once.json) (one-claim) + oracle: `packages/conformance/observations/auth/auth-row-26-signout-fires-null-once.json` (against blockingfun, fb-js-sdk 12.13.0: subscribe (1 initial null fire) → `signInAnonymously` → `signOut` → `firesForSignOut: 1` with `lastFireUidWasNull: true`) | 26 |\n| signOut(auth) | | Idempotent — `signOut` on already-signed-out user is a no-op (no listener fire) | ✓ | [`playground:auth-signout-idempotent`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-signout-idempotent.json) + oracle-confirmed: `packages/conformance/observations/auth/auth-signout-idempotent.json` (`threw: false, redundantSignOutFiredListener: false` against blockingfun) | 27 |\n| signOut(auth) | | Clears the active session's persistence in prod; sandbox has no persistence | ⚠ | divergence: same memory-only constraint as the anonymous persistence row | 28 |\n\n## `onAuthStateChanged(auth, observer)`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| onAuthStateChanged(auth, observer) | | Fires immediately on subscribe with the current value (microtask-deferred) | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L5) + oracle: [`packages/conformance/observations/auth/auth-row-29-onauthstatechanged-initial-fire-timing.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-29-onauthstatechanged-initial-fire-timing.json) (`firedSynchronously: 0, firedAfterMicrotask: 1` against blockingfun, fb-js-sdk 12.13.0 — initial fire does NOT arrive in the synchronous tick of `onAuthStateChanged(...)`; it lands after the first microtask flush) | 29 |\n| onAuthStateChanged(auth, observer) | | Fires on every subsequent identity change | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L5), `playground:auth-anonymous` + oracle: [`packages/conformance/observations/auth/auth-row-30-onauthstatechanged-fires-on-every-transition.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-30-onauthstatechanged-fires-on-every-transition.json) (against blockingfun, fb-js-sdk 12.13.0: subscribe → `signIn` → `signOut` → `signIn` → `signOut`, each of the 4 transitions produced exactly 1 fire; `eachTransitionFiredExactlyOnce: true`) | 30 |\n| onAuthStateChanged(auth, observer) | | Dedups by uid: a same-uid update (profile-shape change, or a same-uid re-sign-in) does NOT re-fire `onAuthStateChanged` — only an actual uid change does. Mirrors upstream `notifyAuthListeners`'s `lastNotifiedUid` gate (`auth_impl.ts:718-723`). (`onIdTokenChanged` still fires on those same-uid updates — see row 38a.) | ✓ | [`unit:sandbox-uid-dedup.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-uid-dedup.test.ts#L7) (locks AUTH-B7 / B8) | 30a |\n| onAuthStateChanged(auth, observer) | | **No duplicate fire** when subscribe is followed by a synchronous `setCurrentUser` — dedup ensures observer sees the new value once, not twice. Sandbox-only concern: prod has no synchronous state-change API, so the dedup window can't be exercised against the cloud SDK; subscribe-then-async-signIn naturally fires twice (initial + new value) because the microtask between them flushes the initial fire | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L5) (regression test from #399), `playground:auth-anonymous` + oracle baseline: [`packages/conformance/observations/auth/auth-row-31-onauthstatechanged-no-dup-on-sync-transition.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-31-onauthstatechanged-no-dup-on-sync-transition.json) (against blockingfun, fb-js-sdk 12.13.0: subscribe + async `signInAnonymously` in the same tick produces `totalFires: 2` — `{uid: null, ts: 0}` then `{uid: <user>, ts: ~400ms}`. Confirms prod cannot exhibit the same-tick race; the dedup behavior remains a sandbox-only property) | 31 |\n| onAuthStateChanged(auth, observer) | | Returned `Unsubscribe` removes the observer; subsequent state changes do NOT fire it | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L5), `playground:auth-listener-unsub` (bundled) + [`playground:auth-row-32-unsubscribe-stops-fires`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-32-unsubscribe-stops-fires.json) (one-claim) + oracle: `packages/conformance/observations/auth/auth-row-32-unsubscribe-stops-fires.json` (against blockingfun, fb-js-sdk 12.13.0: subscribe → 1 fire on `signInAnonymously` → call `unsub()` → `signOut` + `signInAnonymously` + `signOut` produce zero further fires; `postUnsubFires: 0, unsubscribeStoppedFires: true`) | 32 |\n| onAuthStateChanged(auth, observer) | | Multiple subscribers all fire on each change | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L5) + oracle: [`packages/conformance/observations/auth/auth-row-33-multiple-subscribers-all-fire.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-33-multiple-subscribers-all-fire.json) (against blockingfun, fb-js-sdk 12.13.0: two subscribers registered back-to-back each see 1 initial null fire, +1 on `signInAnonymously`, +1 on `signOut`; `bothFiredOnSignIn: true, bothFiredOnSignOut: true`) | 33 |\n| onAuthStateChanged(auth, observer) | | Registry is array-backed (matches upstream `util/subscribe.ts`): the SAME observer fn registered N times produces N independent registrations that each fire, and one `Unsubscribe` removes exactly one registration. A resubscribe of a previously-unsubscribed fn fires its initial value again. (Per-registration initial-fire bookkeeping, not a shared per-observer dedup.) | ✓ | [`unit:sandbox-listener-registry.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listener-registry.test.ts#L19) (locks AUTH-B3 + AUTH-B4) | 33a |\n| onAuthStateChanged(auth, observer) | | Unsubscribing during emission does not skip remaining subscribers (snapshotted iteration) | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L5) | 34 |\n| onAuthStateChanged(auth, observer) | | A throwing observer doesn't block other observers from firing | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L5) + oracle: [`packages/conformance/observations/auth/auth-row-35-throwing-observer-doesnt-block-others.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-35-throwing-observer-doesnt-block-others.json) (`secondObserverContinuedFiring: true` against blockingfun, fb-js-sdk 12.13.0 — observer #1 throws on every call, observer #2 still counts the initial fire AND the post-sign-in fire) | 35 |\n| onAuthStateChanged(auth, observer) | | Observer object form (`{next, error, complete}`) works alongside the function form | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L5) + oracle: [`packages/conformance/observations/auth/auth-row-36-observer-object-form-works.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-36-observer-object-form-works.json) (against blockingfun, fb-js-sdk 12.13.0: one observer as `NextFn`, another as `{next, error, complete}`. Both forms see 1 initial null fire, +1 on `signInAnonymously`, +1 on `signOut`; `bothFormsFiredOnSignIn: true, bothFormsFiredOnSignOut: true`) | 36 |\n| onAuthStateChanged(auth, observer) | | Setting the same user twice does NOT double-fire (structural-equality no-op). Sandbox-internal `setCurrentUser` claim; the prod analog is `signInAnonymously` called twice in a row (per fix #399, the second call returns the same user). | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L5) + oracle: [`packages/conformance/observations/auth/auth-row-37-same-user-no-double-fire.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-37-same-user-no-double-fire.json) (against blockingfun, fb-js-sdk 12.13.0: first `signInAnonymously` fires the listener once; second call returns the same uid (`sameUserAcrossCalls: true`) and does NOT produce a fresh fire (`secondSignInProducedFire: false`). Prod also recognizes the same-user no-op) | 37 |\n\n## `onIdTokenChanged(auth, observer)`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| onIdTokenChanged(auth, observer) | | Fires on user change (sandbox shares the auth-state path) | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L12) + oracle: [`packages/conformance/observations/auth/auth-row-38-onidtokenchanged-fires-on-user-change.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-38-onidtokenchanged-fires-on-user-change.json) (against blockingfun, fb-js-sdk 12.13.0: subscribe → 1 initial fire (null) → `signInAnonymously` → +1 (user₁) → `signOut` → +1 (null) → `signInAnonymously` → +1 (user₂ with fresh uid). Every identity transition produces exactly one fire, matching `onAuthStateChanged`) | 38 |\n| onIdTokenChanged(auth, observer) | | Fires on EVERY sign-in, including a same-uid re-sign-in (no `signOut` first) — which mints a fresh token, so a subsequent `getIdToken()` returns a new string. Mirrors upstream `notifyAuthListeners`, which calls `idTokenSubscription.next` on every identity update (`auth_impl.ts:716`). `onAuthStateChanged` stays silent on the same-uid case (row 30a). | ✓ | [`unit:sandbox-uid-dedup.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-uid-dedup.test.ts#L5) (locks AUTH-B8) | 38a |\n| onIdTokenChanged(auth, observer) | | Fires on token refresh (`getIdToken(true)`) | ✓ | [`unit:sandbox-token-refresh.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-token-refresh.test.ts#L12) — was ⚠ (documented divergence); aligned to prod in commit — sandbox now mints a fresh token on forceRefresh and fires `onIdTokenChanged` (NOT `onAuthStateChanged`, since identity is unchanged). Oracle: [`packages/conformance/observations/auth/auth-onidtokenchanged-force-refresh.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-onidtokenchanged-force-refresh.json) defines the target shape (`refreshFiredListener: true` against blockingfun; subscribe → null fire → `signInAnonymously` → +1 → `getIdToken(true)` → +1 for a total of 3 fires). | 39 |\n| onIdTokenChanged(auth, observer) | | Initial-fire semantics match `onAuthStateChanged` | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L12) + oracle: [`packages/conformance/observations/auth/auth-row-40-onidtokenchanged-matches-onauthstatechanged-initial-fire.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-40-onidtokenchanged-matches-onauthstatechanged-initial-fire.json) (against blockingfun, fb-js-sdk 12.13.0: subscribing both listeners in the same tick yields `sync: {auth: 0, idToken: 0}` → `microtask: {auth: 1, idToken: 1}` → no further fires. Both listeners share the microtask-deferred initial-fire timing) | 40 |\n\n## `setPersistence(auth, persistence)`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| setPersistence(auth, persistence) | | Accepts `inMemoryPersistence` / `browserSessionPersistence` / `browserLocalPersistence` markers without throwing | ✓ | [`unit:types.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/types.test.ts#L2) | 41 |\n| setPersistence(auth, persistence) | | Returns `Promise<void>` | ✓ | [`unit:types.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/types.test.ts#L2) | 42 |\n| setPersistence(auth, persistence) | | Actually changes where the auth state is persisted | ⚠ | divergence: sandbox is a no-op. Prod respects the marker. | 43 |\n| setPersistence(auth, persistence) | | An unrecognized persistence marker is rejected with `auth/argument-error` rather than silently coerced to LOCAL | ✓ | [`unit:sandbox-cluster-b9-b12.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-cluster-b9-b12.test.ts#L17) (locks AUTH-B12) | 43a |\n\n## `signInWithPopup(auth, provider)` / `signInWithCredential(auth, credential)`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| signInWithPopup(auth, provider)` / `signInWithCredential(auth, credential) | | Returns the pre-staged `UserCredential` registered via `sandbox.mockSignInResult(auth, …)` | ✓ | [`unit:sandbox-providers.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-providers.test.ts#L2) | 44 |\n| signInWithPopup(auth, provider)` / `signInWithCredential(auth, credential) | | Throws `auth/no-mock-configured` when no mock is pre-staged | ✓ | [`unit:sandbox-providers.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-providers.test.ts#L2) | 45 |\n| signInWithPopup(auth, provider)` / `signInWithCredential(auth, credential) | | Mock is consumed after one read (subsequent call without a fresh stage throws again) | ✓ | [`unit:sandbox-providers.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-providers.test.ts#L2) | 46 |\n| signInWithPopup(auth, provider)` / `signInWithCredential(auth, credential) | | After successful sign-in, `currentUser` becomes the mock's `user`, listeners fire | ✓ | [`unit:sandbox-providers.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-providers.test.ts#L2) | 47 |\n| signInWithPopup(auth, provider)` / `signInWithCredential(auth, credential) | | The credential's rich `User` (email / displayName / isAnonymous) survives the transition — popup/redirect/credential/`setUser` do NOT clobber it down to the bare `AuthState`; `cred.user === auth.currentUser` (reference identity, matches upstream `_updateCurrentUser(userCredential.user)`) | ✓ | [`unit:sandbox-user-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-user-identity.test.ts#L21) (locks AUTH-B1) | 47a |\n| signInWithPopup(auth, provider)` / `signInWithCredential(auth, credential) | | Opens a popup window in prod | ⚠ | divergence: sandbox skips the popup; mock pre-stage replaces the popup result | 48 |\n| signInWithPopup(auth, provider)` / `signInWithCredential(auth, credential) | | Cancels with `auth/popup-closed-by-user` when the user dismisses the popup (prod) | — | not modeled — would require the host to expose a \"cancel\" affordance on the mock | 49 |\n| signInWithPopup(auth, provider)` / `signInWithCredential(auth, credential) | | Provider-flow sign-ins (popup / redirect / credential) record the flow's `providerId` on the identity in the user DB (upsert for unknown uids; append-if-missing for known ones) and reject disabled accounts with `auth/user-disabled` before any state change | ✓ | [`unit:sandbox-user-admin.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-user-admin.test.ts#L20) (\"provider tracking\", \"disabled users\") — provider recording is sandbox bookkeeping for `listIdentities`/`listUsers`; prod's auto-link semantics are narrower (same-email Google auto-link only) and not modeled | 70 |\n\n## `signInWithRedirect` / `getRedirectResult` / resolver seam\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| signInWithRedirect` / `getRedirectResult` / resolver seam | | `signInWithRedirect(auth, provider, resolver?)` resolves the flow (per-call resolver → injected → one-shot mock → `auth/argument-error`), signs the user in, and stashes the credential for one `getRedirectResult` | ✓ | [`unit:sandbox-resolver.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-resolver.test.ts#L5) | 49a |\n| signInWithRedirect` / `getRedirectResult` / resolver seam | | `getRedirectResult(auth)` returns the stashed credential once, then `null` (one-shot, matches prod) | ✓ | [`unit:sandbox-resolver.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-resolver.test.ts#L5) | 49b |\n| signInWithRedirect` / `getRedirectResult` / resolver seam | | `sandbox.setAuthFlowResolver(auth, resolver \\| null)` installs / clears the popup/redirect resolver (the analog of browser `getAuth` wiring `browserPopupRedirectResolver`) | ✓ | [`unit:sandbox-resolver.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-resolver.test.ts#L5) | 49c |\n| signInWithRedirect` / `getRedirectResult` / resolver seam | | `sandbox.listIdentities(auth)` snapshots every seeded/created identity for a host account-picker (sandbox-only — no `firebase/auth` equivalent) | ✓ | [`unit:sandbox-resolver.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-resolver.test.ts#L5) | 49d |\n\n## `Auth` surface + error shape\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Auth` surface + error shape | | `auth.signOut()` method form works alongside the free `signOut(auth)` function (`firebase/auth`'s `Auth` exposes both) (AUTH-GAP) | ✓ | [`unit:auth-gap-surface.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/auth-gap-surface.test.ts#L2) | 49e |\n| Auth` surface + error shape | | Sandbox auth errors are real `FirebaseError` instances (`err instanceof FirebaseError`) carrying the prod message wrapper `Firebase: <message> (<auth/...>).` — e.g. `Firebase: Error (auth/invalid-email).`, matching the oracle (AUTH-GAP) | ✓ | [`unit:auth-gap-surface.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/auth-gap-surface.test.ts#L2) | 49f |\n\n## Provider classes (`GoogleAuthProvider`, `EmailAuthProvider`, etc.)\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | Exports the same constructor signatures as `firebase/auth` for each provider | ✓ | type-only smoke in [`unit:types.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/types.test.ts#L8) | 50 |\n| | | `Provider.credential(...)` static factories produce `AuthCredential`-shaped objects | ✓ | [`unit:sandbox-providers.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-providers.test.ts#L14) | 51 |\n| | | `GoogleAuthProvider.providerId === 'google.com'` (and per-provider analogs) | ✓ | [`unit:sandbox-providers.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-providers.test.ts#L18) | 52 |\n| | | Custom scopes / params / language code | — | sandbox ignores; prod forwards | 53 |\n\n## `User` methods\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| User` methods | | `user.getIdToken()` returns a stable opaque token in sandbox (`sandbox-id-token-…`) | ✓ | [`unit:sandbox-anonymous.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-anonymous.test.ts#L4) | 54 |\n| User` methods | | `user.getIdToken(true)` (forceRefresh) returns a NEW token; subsequent `getIdToken(false)` returns the cached new token | ✓ | [`unit:sandbox-token-refresh.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-token-refresh.test.ts#L8) — was ⚠ (documented divergence); aligned to prod in commit — sandbox now mints a fresh token on forceRefresh and fires `onIdTokenChanged`. Oracle: [`packages/conformance/observations/auth/auth-getidtoken-force-refresh.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-getidtoken-force-refresh.json) defines the target shape (`forceRefreshReturnedDifferentString: true`, `token1EqualsToken2: true` against blockingfun — the refreshed token is cached, so a subsequent non-forced read returns it, not yet another fresh one). Sandbox tokens stay `sandbox-id-token-<uid>-<serial>:<json>` strings; prod's are real JWTs. | 55 |\n| User` methods | | `user.getIdTokenResult()` returns claims | ✓ | [`unit:sandbox-providers.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-providers.test.ts#L24) (custom-claims path) | 56 |\n| User` methods | | `user.uid`, `user.email`, `user.displayName`, `user.isAnonymous` reflect the source | ✓ | `playground:auth-anonymous`, `playground:auth-email-password` | 57 |\n| User` methods | | `user.emailVerified` — present on every sandbox-minted `User` (default `false`; sandbox has no verification flow). Prod passes the real value through (no longer stripped). The admin record (`sandbox.listUsers`) carries it too | ✓ | [`unit:auth-gap-surface.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/auth-gap-surface.test.ts#L2) (locks AUTH-GAP) | 58 |\n| User` methods | | `user.photoURL` / `user.phoneNumber` — present (sandbox default `null`; prod passes through, no longer stripped) | ✓ | [`unit:auth-gap-surface.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/auth-gap-surface.test.ts#L2) | 58a |\n| User` methods | | `user.providerId` (aggregate, `'firebase'`) + `user.providerData: UserInfo[]` — sandbox synthesizes one provider entry for non-anonymous users, empty for anonymous; prod passes the real array through (no longer stripped). The admin record carries the emulator-shaped `providerUserInfo` | ✓ | [`unit:auth-gap-surface.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/auth-gap-surface.test.ts#L2) | 58b |\n| User` methods | | `user.metadata.creationTime` / `lastSignInTime` | — | client `User.metadata` not exposed (AUTH-GAP); the admin record carries `createdAt`/`lastLoginAt` (ISO) | 59 |\n| User` methods | | `IdTokenResult.signInProvider` reflects the session's provider per flow (`'anonymous'` / `'password'` / `'google.com'` / …); claims include the reserved `firebase.sign_in_provider` (custom claims can't shadow it) | ✓ | [`unit:sandbox-user-admin.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-user-admin.test.ts#L9) (\"IdTokenResult.signInProvider\") — prod shape is documented SDK behavior; no oracle capture yet (Structured evidence: [`auth-signinprovider-per-flow`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-signinprovider-per-flow.json)) | 68 |\n| User` methods | | Custom-claims changes (`sandbox.updateUser` / re-seed) reach an active session on the next FORCED token refresh, not immediately — claims are read live from the user DB at mint time (prod's refresh-propagation story; AUTH-B10) | ✓ | [`unit:sandbox-user-admin.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-user-admin.test.ts#L9), [`unit:sandbox-cluster-b9-b12.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-cluster-b9-b12.test.ts#L24) (Structured evidence: [`auth-claims-forced-refresh-propagation`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-claims-forced-refresh-propagation.json)) | 75 |\n| User` methods | | `user.reload()` / `user.delete()` / `user.toJSON()` / `user.refreshToken` / `user.tenantId` | — | not modeled by the sandbox; retained as an explicit unsupported behavior row rather than synthesized (AUTH-GAP) | 61 |\n| updateProfile(user, {displayName, photoURL}) | | `updateProfile(user, {displayName, photoURL})` mutates the held user + stored record in place (null clears a field; omitted fields untouched) without an extra `onAuthStateChanged` fire. Profile survives `signOut` → `signInWithEmailAndPassword`. | ✓ | [`unit:sandbox-update-profile.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-update-profile.test.ts#L2) (in-place mutation, listUsers persist, null clear, no extra listener); re-sign-in persistence: [`unit:upstream-auth-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/upstream-auth-probes.test.ts#L8) | 62 |\n\n## `beforeAuthStateChanged(auth, callback, onAbort?)`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| beforeAuthStateChanged(auth, callback, onAbort?) | | Registers a BLOCKING gate that runs before a real sign-in/sign-out transition commits. Callbacks run in registration order; a callback that throws (or returns a rejected promise) aborts the transition entirely: the pending `signInWith…`/`signOut` call rejects with `auth/login-blocked`, `currentUser` is unchanged, and `onAuthStateChanged`/`onIdTokenChanged` do NOT fire. Covers every sign-in path that exists in `pyric/auth`: `signInAnonymously`, `signInWithEmailAndPassword`, `createUserWithEmailAndPassword`, `signInWithPopup`, `signInWithRedirect`, `signInWithCredential`, `signInWithCustomToken`, and `signOut`. Modeled after the real `@firebase/auth` `AuthMiddlewareQueue.runMiddleware` (`auth_impl.ts`), read directly from the installed SDK source, not just its `.d.ts`. | ✓ | [`unit:sandbox-before-auth-state-changed.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-before-auth-state-changed.test.ts#L2) — implementation cross-checked against `@firebase/auth`'s `AuthMiddlewareQueue` source (registration-order queue, per-callback try/await, `auth/login-blocked` wrap). Custom-token allow/block: [`unit:upstream-auth-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/upstream-auth-probes.test.ts#L9). No live-oracle capture (no observable client-visible signal to probe beyond the documented+sourced contract already read). | 76 |\n| beforeAuthStateChanged(auth, callback, onAbort?) | | `onAbort` semantics: when a callback throws, every `onAbort` registered by a callback that ALREADY SUCCEEDED in the same pass runs, in REVERSE registration order — matches upstream's rollback-stack (`runMiddleware`'s `onAbortStack`). An `onAbort` that itself throws is swallowed so it can't mask the original block reason or skip the remaining rollbacks. | ✓ | [`unit:sandbox-before-auth-state-changed.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-before-auth-state-changed.test.ts#L2) (\"onAbort runs (in reverse order)…\", \"a callback whose own onAbort throws…\") | 76a |\n| beforeAuthStateChanged(auth, callback, onAbort?) | | Fires on BOTH directions: a real sign-in (`nextUser` non-null) and a real sign-out (`nextUser === null`) — a throwing callback blocks `signOut` too, leaving the previous user signed in. Matches upstream, where the same middleware queue gates `_updateCurrentUser` and `signOut`. | ✓ | [`unit:sandbox-before-auth-state-changed.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-before-auth-state-changed.test.ts#L2) (\"fires on sign-out too…\") | 76b |\n| beforeAuthStateChanged(auth, callback, onAbort?) | | `sandbox.setUser` (the sandbox-only test driver) BYPASSES the gate entirely — it is a raw identity force with no prod analog (same bypass it already has for provider enforcement / `signInProvider` tracking), so no registered `beforeAuthStateChanged` callback runs and none can block it. | ⚠ | [`unit:sandbox-before-auth-state-changed.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-before-auth-state-changed.test.ts#L2) (\"sandbox.setUser test driver bypasses the gate\") | 76c |\n| beforeAuthStateChanged(auth, callback, onAbort?) | | Served-worker path (SharedWorker-backed auth in `@pyric/cli`): the worker owns the shared user pool and commits transitions on its own side of the port, so a page-local `beforeAuthStateChanged` registration can't actually gate a worker-driven sign-in. Rather than silently accept a callback that could never run, registering THROWS immediately (`auth/operation-not-supported-in-this-environment`) — same defensive pattern as `signInWithCredential` over the worker. | ⚠ | `packages/cli/src/serve/worker/client.ts` (`beforeAuthStateChanged` throws `makeUnsupported`) | 76d |\n\n## User-management and session exports\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| initializeAuth(app, deps?) | | Aliases `getAuth(app)` and returns the same stable `Auth` handle — an app that calls `initializeAuth` instead of `getAuth` gets an equivalent, working instance. The optional `Dependencies` arg (persistence / popupRedirectResolver) is accepted for signature parity but not applied (persistence is already a documented sandbox no-op). Repeated calls return the cached handle rather than throwing `auth/already-initialized` | ✓ | [`unit:fruit-aliases.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/fruit-aliases.test.ts#L13) — returns the same instance as `getAuth`, with a live `currentUser` | 82 |\n| deleteUser(user) | | Deletes the account from the user store AND signs the user out if they are current (fires `onAuthStateChanged(null)`), matching prod where deleting the signed-in user clears `auth.currentUser`. Real behavior: a subsequent `signInWithEmailAndPassword` for that identity throws `auth/user-not-found`; `reload(user)` after delete rejects with `auth/user-token-expired` | ✓ | [`unit:fruit-aliases.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/fruit-aliases.test.ts#L17) — user removed from the store, sign-out fired, re-sign-in throws `auth/user-not-found`; post-delete `reload` → `auth/user-token-expired`: [`unit:upstream-auth-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/upstream-auth-probes.test.ts#L7) | 83 |\n| updateEmail(user, newEmail) | | Changes the stored email (via the same path as `sandbox.updateUser`, rejecting `auth/email-already-in-use` / `auth/invalid-email`) and mutates the held `user` in place, so the next sign-in resolves against the new email. Leniency vs prod: the sandbox does NOT enforce `auth/requires-recent-login` and is not routed through `verifyBeforeUpdateEmail` (which the real SDK requires when email-enumeration protection is on) | ⚠ email really changes; no requires-recent-login / verifyBeforeUpdateEmail enforcement | [`unit:fruit-aliases.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/fruit-aliases.test.ts#L18) — stored email changes; re-sign-in works with the new email, fails with the old | 84 |\n| updatePassword(user, newPassword) | | Sets the stored password (validated for strength). The sandbox DOES store and verify passwords, so this is a real mutation: a subsequent `signInWithEmailAndPassword` with the new password succeeds and the old one throws `auth/wrong-password`. Leniency vs prod: no `auth/requires-recent-login` enforcement | ⚠ password really changes + is verified; no requires-recent-login enforcement | [`unit:fruit-aliases.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/fruit-aliases.test.ts#L19) — new password signs in, old password throws `auth/wrong-password` | 85 |\n| reload(user) | | Re-reads the stored record into the `user` object in place, so a change made out of band (e.g. `sandbox.updateUser`) is reflected on the held reference — matching prod's server refresh. If the identity is absent from the store (including after `deleteUser`), rejects with `auth/user-token-expired` | ✓ | [`unit:fruit-aliases.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/fruit-aliases.test.ts#L20) — an out-of-band `sandbox.updateUser` displayName change is visible on the held user after `reload`; missing-store / post-delete: [`unit:upstream-auth-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/upstream-auth-probes.test.ts#L7) | 86 |\n| updateCurrentUser(auth, user) | | Sets the sandbox's current user (pass `null` to sign out), firing `onAuthStateChanged` — `auth.currentUser` reflects the passed user afterward | ✓ | [`unit:fruit-aliases.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/fruit-aliases.test.ts#L21) — `auth.currentUser` becomes the passed user; `null` signs out | 87 |\n| useDeviceLanguage(auth) | | Accepted no-op — the sandbox has no device locale to read, so there is no language to set; accepted so init code that calls it compiles + runs | ⚠ no device locale in the sandbox | [`unit:fruit-aliases.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/fruit-aliases.test.ts#L22) — resolves/returns without error | 88 |\n\n## `ActionCodeURL` / email-link / action-code — the out-of-band family\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| ActionCodeURL.parseLink(link) / parseActionCodeURL(link) | | Parses an out-of-band action link. The `mode` query param maps to a NORMALIZED operation (`mode=resetPassword` -> `PASSWORD_RESET`, `mode=signIn` -> `EMAIL_SIGNIN`), `oobCode` surfaces as `code`, `lang` as `languageCode`, and `continueUrl` comes out URL-DECODED. A link missing `mode`, missing `oobCode`, carrying an unknown mode, or that is not a URL at all parses to `null` — the parse NEVER throws. `parseActionCodeURL` and `ActionCodeURL.parseLink` agree. | ✓ | ORACLE-BACKED and project-independent: this is a PURE client-side parse (no network, no project, no mailbox), so the sandbox owes prod an exact match and there is no room for a divergence. Oracle: [`auth-actioncodeurl-parse`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-actioncodeurl-parse.json) against firebase-js-sdk 12.13.0. Replayed in [`unit:oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L563). (Structured evidence: [`sandbox-email-link.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L15)) | 150 |\n| isSignInWithEmailLink(auth, link) | | Pure predicate over the link string — no network. `true` only for a link whose mode is `signIn` AND which carries an `oobCode`; `false` for a password-reset link, for `mode=signIn` with no code, for garbage, and for the empty string. Never throws. | ✓ | ORACLE-BACKED, project-independent (no server involved): [`auth-issigninwithemaillink-predicate`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-issigninwithemaillink-predicate.json) captured all five cases against prod. Replayed in [`unit:oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L599). (Structured evidence: [`sandbox-email-link.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L22)) | 151 |\n| applyActionCode(auth, code) | | Redeems an out-of-band code and performs its state change (`VERIFY_EMAIL` sets `emailVerified`, `VERIFY_AND_CHANGE_EMAIL` moves the account to the new address). Throws `auth/invalid-action-code` for a code the project never issued, for the empty string, and for a code already redeemed (single-use). A `PASSWORD_RESET` code is refused — `confirmPasswordReset` owns that one. | ✓ | ORACLE-BACKED on the reject path: [`auth-action-code-invalid`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-action-code-invalid.json) confirmed prod emits `auth/invalid-action-code` for both a bogus code and the empty string (this endpoint is NOT gated on the password provider, so it answered honestly). The APPLY path (a real code, redeemed) cannot be probed from a client — it needs a code from a real inbox — and is unit-backed end to end against the sandbox outbox in [`unit:sandbox-email-link.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L16). (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L609)) | 152 |\n| sendSignInLinkToEmail(auth, email, settings) | | ActionCodeSettings validation, enforced CLIENT-side before any request leaves the process: a missing or unparseable `url` throws `auth/invalid-continue-uri` (NOT `auth/missing-continue-uri`, despite the constant's name), and `handleCodeInApp` other than `true` throws `auth/argument-error`. On success the sandbox mints a single-use code and posts the message to the outbox. | ✓ | ORACLE-BACKED on both client-side arms (project-independent — prod threw before any network call): [`auth-sendsigninlinktoemail-settings-validation`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-sendsigninlinktoemail-settings-validation.json) captured `missingUrl: auth/invalid-continue-uri` and `handleCodeInAppFalse: auth/argument-error`. Replayed in [`unit:oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L624); the send path is unit-backed in [`unit:sandbox-email-link.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L26). | 153 |\n| signInWithEmailLink(auth, email, link) | | Redeems the code in a sign-in link. A link carrying no `oobCode` throws `auth/argument-error` CLIENT-side (the SDK never reaches the server to ask about a code it cannot find in the link). A first-time sign-in for an address CREATES the account, `getAdditionalUserInfo(cred).isNewUser` is `true`, and the account arrives `emailVerified: true` — redeeming a code mailed to that address is proof of control. The code is single-use. | ✓ | ORACLE-BACKED on the client-side reject ([`auth-signinwithemaillink-invalid-link`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-signinwithemaillink-invalid-link.json): `noOobCode: auth/argument-error`). The REDEMPTION path could not be probed: the oracle project has email-link sign-in disabled, so the server arm answered `auth/operation-not-allowed`. That half is unit-backed end to end against the sandbox outbox ([`unit:sandbox-email-link.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L28) drives send -> read the outbox -> sign in). (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L644)) | 154 |\n| sendPasswordResetEmail(auth, email) | | For an address NO account owns, prod RESOLVES silently — it does not throw `auth/user-not-found`. Email Enumeration Protection is on by default and refusing to leak account existence is the point. The sandbox matches: it resolves and mails nothing. A malformed address still throws `auth/invalid-email`. For a real account, the sandbox mails a reset code; `confirmPasswordReset` redeems it and the new password signs in while the old one throws `auth/wrong-password`. | ✓ | ORACLE-BACKED on the enumeration-protection behavior — the fact most likely to be got wrong: [`auth-sendpasswordresetemail-unknown-user`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-sendpasswordresetemail-unknown-user.json) captured `resolvedForUnknownUser: true`, `unknownUserCode: null`, `malformedEmailCode: auth/invalid-email`. A shim that threw `auth/user-not-found` here would hand agent code an account oracle production deliberately removed. Reset round trip unit-backed in [`unit:sandbox-email-link.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L25). (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L656)) | 155 |\n| sendEmailVerification(user) | | Throws `auth/missing-email` for a user with no email on the account (an anonymous user). For a real account the mail goes out and NOTHING ELSE HAPPENS: `user.emailVerified` stays `false`. Verification happens when the code in the message is redeemed (`applyActionCode`), not when it is sent. | ✓ | ORACLE-BACKED on the anonymous reject: [`auth-sendemailverification-shape`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-sendemailverification-shape.json) captured `anonymousUserCode: auth/missing-email` against prod. The send-does-not-verify property is the one the whole flow turns on and is unit-backed ([`unit:sandbox-email-link.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L24) asserts `emailVerified` is still false after the send and true only after the code is applied) — it cannot be oracle-confirmed end to end because confirming it would require reading a real inbox. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L676)) | 156 |\n| verifyBeforeUpdateEmail(user, newEmail) | | Mails a code to the NEW address and returns. The account's email does NOT change until that code is redeemed — the single guarantee separating this API from a bare `updateEmail`: the user must prove control of the new address before it becomes theirs. On redemption the account moves and the new address arrives `emailVerified: true`. `checkActionCode` on the code reports `data.email` = the new address and `data.previousEmail` = the old one. | ✓ | UNIT-BACKED, not oracle-backed, and the reason is recorded rather than hidden: the probe ([`auth-verifybeforeupdateemail-shape`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-verifybeforeupdateemail-shape.json)) ran against the real project and came back `auth/operation-not-allowed` on every arm, because the oracle project has the Email/Password provider DISABLED and the probe could not even create the user whose email it would change. The capture is committed showing exactly that. Behavior is proven end to end against the sandbox in [`unit:sandbox-email-link.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L30). (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L89)) | 157 |\n| sandbox.takeAuthMail(auth) / sandbox.setAuthMailResolver(auth, resolver) | | The sandbox IS the mail server. Every send posts a real message (operation, recipient, single-use code, and the full action link) to an outbox; `sandbox.takeAuthMail` reads it — the program's substitute for a human opening an inbox. The mailed link round-trips through the public `isSignInWithEmailLink` / `parseActionCodeURL` unchanged, and its code is the code the redeemer accepts, so the round trip production cannot complete without a human closes in-process. An installed `AuthMailResolver` is additionally notified per message; a resolver that THROWS does not fail the send that produced it. | ✓ | [`unit:sandbox-email-link.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L102) — sandbox-only driver (no `firebase/auth` counterpart; this is the seam that makes the family testable at all). | 158 |\n| checkActionCode(auth, code) / verifyPasswordResetCode(auth, code) / confirmPasswordReset(auth, code, newPassword) | | `checkActionCode` and `verifyPasswordResetCode` INSPECT a code without burning it — a check must not destroy the code the subsequent apply needs. `confirmPasswordReset` redeems it and sets the password, running the same strength check `createUserWithEmailAndPassword` runs; a weak new password throws `auth/weak-password` and does NOT burn the code (a typo must not destroy the user's one reset link). A code staged as expired throws `auth/expired-action-code`. | ✓ | UNIT-BACKED, stated honestly: these three endpoints answered `auth/operation-not-allowed` in the oracle run (`auth-action-code-invalid`) because the oracle project's disabled password provider replies before the invalid-code contract can. That is a fact about the project's configuration, not about the API, so it is NOT asserted as evidence. The error codes used here are matched to the oracle-CAPTURED `AuthErrorCodes` map (`INVALID_OOB_CODE = auth/invalid-action-code`, `EXPIRED_OOB_CODE = auth/expired-action-code` — see auth#172). Behavior proven in [`unit:sandbox-email-link.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L17). | 159 |\n| sendSignInLinkToEmail | `auth/unauthorized-continue-uri` (domain allowlist) | Prod rejects a continue URL whose domain is not on the project's authorized-domains list. The sandbox has NO domain allowlist and does not invent one: it accepts any parseable continue URL. A continue URL that would be rejected in production is accepted here. | ⚠ | The sandbox has no project config to hold an authorized-domains list, so there is nothing to check against; inventing an allowlist would mean inventing a policy the user never set. The probe attempted the server arm (`auth-sendsigninlinktoemail-settings-validation`, `unauthorizedDomain`) but the oracle project has email-link sign-in disabled, so it answered `auth/operation-not-allowed` rather than `auth/unauthorized-continue-uri` — the divergence is declared from the documented contract, not from a capture we do not have. (Structured evidence: [`sandbox-email-link.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L26)) | 163 |\n\n## `linkWithCredential` / `linkWithPopup` / `linkWithRedirect` / `unlink` — account linking\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| linkWithCredential(user, credential) | | The ANONYMOUS UPGRADE. Linking an email credential onto an anonymous account upgrades it IN PLACE: the uid is PRESERVED, `isAnonymous` flips to `false`, the email is set, and `providerData` gains the provider. Preserving the uid is what keeps the data the user created while anonymous theirs. Returns a `UserCredential` with `operationType: 'link'` and `getAdditionalUserInfo().isNewUser === false` (a link never creates an identity). The linked credential then works as a first-class `signInWithEmailAndPassword`. | ✓ | UNIT-BACKED, not oracle-backed — stated plainly. The probe ([`auth-link-email-credential-to-anonymous`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-link-email-credential-to-anonymous.json)) ran against the real project and returned `linkCode: auth/operation-not-allowed`: the oracle project has the Email/Password provider DISABLED, so no email credential can be minted there and the flow cannot be reached. The capture is committed showing exactly that rather than being dropped. Behavior proven in [`unit:sandbox-linking-reauth.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-linking-reauth.test.ts#L25), including the uid-preservation invariant. | 160 |\n| linkWithCredential | conflict codes | `auth/provider-already-linked` when the account already carries the provider (one identity per provider). `auth/email-already-in-use` when the email credential belongs to a DIFFERENT account — an address can back only one identity, so the link cannot be granted without stealing it. | ✓ | UNIT-BACKED. The probe ([`auth-link-conflicts`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-link-conflicts.json)) ran and every arm returned `auth/operation-not-allowed` (same disabled Email/Password provider on the oracle project), so the conflict codes could not be observed against prod. They are matched instead to the oracle-CAPTURED `AuthErrorCodes` map (`PROVIDER_ALREADY_LINKED = auth/provider-already-linked`, `CREDENTIAL_ALREADY_IN_USE = auth/credential-already-in-use` — auth#172) and proven in [`unit:sandbox-linking-reauth.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-linking-reauth.test.ts#L25). NOTE: for an EMAIL credential the sandbox emits `auth/email-already-in-use`, the code prod uses for an address collision; `credential-already-in-use` remains the OAuth-credential case. | 161 |\n| unlink(user, providerId) | | Detaches a provider and returns the updated user with a SHRUNKEN `providerData`. Unlinking a provider that was never linked throws `auth/no-such-provider`. Unlinking `'password'` takes the password with it, so `signInWithEmailAndPassword` for that account stops working. Unlinking the LAST provider does NOT re-anonymize the account — `isAnonymous` describes how an identity was born, not what it currently carries. | ✓ | ORACLE-BACKED on the reject path — the ONE linking fact the oracle could reach, because it needs no email credential: [`auth-unlink-provider`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-unlink-provider.json) captured `noSuchProviderCode: auth/no-such-provider` against prod on an anonymous user. Replayed in [`unit:oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L685). The detach path is unit-backed ([`unit:sandbox-linking-reauth.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-linking-reauth.test.ts#L33)). | 162 |\n| linkWithPopup(user, provider, resolver?) / linkWithRedirect(...) | | Route through the SAME `AuthFlowResolver` seam as `signInWithPopup`, with `authType: 'link'` on the request so a host UI can present 'link your Google account' rather than 'sign in'. The resolved credential names the provider to attach; the sandbox performs the attach and the uid is preserved. A disabled provider throws `auth/operation-not-allowed` AHEAD of the resolver check — a code deliberately distinct from `auth/argument-error`, which keeps meaning 'enabled, but no resolver/mock wired'. | ✓ | [`unit:sandbox-linking-reauth.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-linking-reauth.test.ts#L26) — asserts the resolver sees `authType: 'link'`, the uid is preserved, and the two error codes stay distinct. The OAuth arm cannot be oracle-probed at all (it needs a real IdP popup and a human), which is precisely why it goes through the resolver seam. | 164 |\n\n## `reauthenticateWithCredential` / `reauthenticateWithPopup` / `reauthenticateWithRedirect` — re-authentication\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| reauthenticateWithCredential(user, credential) | | Really re-verifies: an email credential is checked against the stored password exactly as `signInWithEmailAndPassword` checks it. A wrong password throws `auth/wrong-password`. A credential belonging to a DIFFERENT account throws `auth/user-mismatch` — checked BEFORE the password compare, so it cannot leak whether the other account's password was right. On success a fresh ID token is minted (a new `authTime`), and the returned `UserCredential` carries `operationType: 'reauthenticate'`. | ✓ | UNIT-BACKED, and the reason it is not oracle-backed is recorded rather than glossed: the probe ([`auth-reauthenticate-with-credential`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-reauthenticate-with-credential.json)) ran against the real project and could not even create the two accounts it needs — `setupCode: auth/operation-not-allowed`, because the oracle project has the Email/Password provider DISABLED. The capture is committed showing that. `auth/user-mismatch` is matched to the oracle-captured `AuthErrorCodes` map (auth#172). Behavior proven in [`unit:sandbox-linking-reauth.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-linking-reauth.test.ts#L27). | 170 |\n| reauthenticate* | `auth/requires-recent-login` is NOT enforced | In production the POINT of re-authentication is the `auth/requires-recent-login` gate: `updateEmail` / `updatePassword` / `deleteUser` refuse to run on a session whose sign-in is older than a few minutes, and re-auth is how you clear it. The sandbox does NOT enforce that gate — those three mutations already run on a session of any age (a pre-existing documented divergence), so there is no gate here for re-auth to clear. What re-auth DOES do here is real but narrower: it genuinely re-verifies the credential, mints a fresh token, and returns `operationType: 'reauthenticate'`. Code that calls it runs unchanged against prod, where it also clears the gate. | ⚠ | Declared divergence, not a bug: inventing a recent-login gate would break every existing sandbox flow (which never re-authenticates) while proving nothing. [`unit:sandbox-linking-reauth.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-linking-reauth.test.ts#L27) pins the behavior that IS provided (real credential re-verification + a fresh token). | 176 |\n| reauthenticateWithPopup(user, provider, resolver?) / reauthenticateWithRedirect(...) | | Route through the shared resolver seam with `authType: 'reauth'`. The identity the resolver produces MUST be the user being re-authenticated — a resolver that hands back a different uid throws `auth/user-mismatch`. That check is the entire security content of the flow; without it 're-authentication' would accept anyone. | ✓ | [`unit:sandbox-linking-reauth.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-linking-reauth.test.ts#L28) — asserts the resolver sees `authType: 'reauth'` and that an impostor identity is rejected with `auth/user-mismatch`. | 177 |\n\n## Constants, credentials, and inert config tokens\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getAdditionalUserInfo(userCredential) | | Returns `{ isNewUser, profile, providerId }`. For a fresh anonymous sign-in prod reports `{ isNewUser: true, providerId: null, profile: {} }` — `providerId` is NULL, not `'anonymous'`, because anonymous is not a federated provider. `isNewUser` is `true` for `createUserWithEmailAndPassword`, a first-time email-link sign-in, and a custom-token sign-in that minted the account; `false` for a returning `signInWithEmailAndPassword` and for every `link` / `reauthenticate`. | ✓ | ORACLE-BACKED on the anonymous shape: [`auth-additional-user-info-shape`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-additional-user-info-shape.json) captured `{isNewUser: true, providerId: null, profile: {}}` against prod. Replayed in [`unit:oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L695). Email create/sign-in + custom-token mint/return isNewUser matrix (providerId null per AUTH-B2): [`unit:upstream-auth-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/upstream-auth-probes.test.ts#L6). Email-link + link/reauth arms: [`unit:sandbox-email-link.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L20), [`unit:sandbox-linking-reauth.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-linking-reauth.test.ts#L22). | 171 |\n| ProviderId / SignInMethod / OperationType / ActionCodeOperation / AuthErrorCodes | | The constant maps, value for value. `OperationType.SIGN_IN === 'signIn'`, `SignInMethod.EMAIL_LINK === 'emailLink'`, `ProviderId.GOOGLE === 'google.com'`, `ActionCodeOperation.PASSWORD_RESET === 'PASSWORD_RESET'`, and the 106-entry `AuthErrorCodes` map (`INVALID_OOB_CODE === 'auth/invalid-action-code'`, `PROVIDER_ALREADY_LINKED === 'auth/provider-already-linked'`, `NO_SUCH_PROVIDER === 'auth/no-such-provider'`, `USER_MISMATCH === 'auth/user-mismatch'`, …). | ✓ | ORACLE-BACKED, snapshotted straight from the shipped SDK: [`auth-mechanical-surface-constants`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-mechanical-surface-constants.json) against firebase-js-sdk 12.13.0, replayed value-for-value in [`unit:oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L711). This matters more than it looks: consumer code COMPARES against these constants, so a mirror that got a string wrong would turn every such comparison into a silent `false` — a worse failure than the export simply being absent, because it typechecks and runs. NOTE: the capture's `persistenceTypes` block is deliberately NOT asserted — the harness runs under Node, where firebase/auth stubs the browser-only persistence tokens to `type: 'NONE'` (it reports 'NONE' even for `browserLocalPersistence`, which is unambiguously 'LOCAL'); asserting it would be asserting a harness artifact. See auth#178. | 172 |\n| signInWithCustomToken(auth, customToken) | | Throws `auth/invalid-custom-token` for a malformed token and for the empty string. The sandbox accepts a token in the two shapes it can honestly read: a JSON (optionally base64url) `{uid, claims}` payload — exactly what `admin.auth().createCustomToken` signs, so the pyric-admin mint and this redeem compose — or a real three-part JWT whose payload segment carries `uid`/`sub`. The SIGNATURE IS NOT VERIFIED: the sandbox has no service-account key. The identity is created if it does not exist, and the credential carries `providerId: null` (custom-token sign-in is not a federated provider). | ⚠ | ORACLE-BACKED on the reject path: [`auth-signinwithcustomtoken-invalid`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-signinwithcustomtoken-invalid.json) captured `auth/invalid-custom-token` for both a malformed token and the empty string, replayed in [`unit:oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L742). Diverged on the ACCEPT path, declared rather than hidden: prod verifies an RS256 signature against the project's service-account key and the sandbox has no key, so it reads the token's claims WITHOUT verifying them. A forged token that prod would reject is accepted here. The happy path cannot be oracle-probed from a Web SDK client at all (it needs an Admin-SDK-signed JWT). | 173 |\n| validatePassword(auth, password) | | Returns a `PasswordValidationStatus` against the project's password policy, WITHOUT attempting a sign-up (so a UI can show live strength feedback). The sandbox's policy is `minPasswordLength: 6`, `maxPasswordLength: 4096`, `enforcementState: 'ENFORCE'`, with every character-class requirement UNSET — so a password this function calls valid is exactly one `createUserWithEmailAndPassword` will accept. The character-class fields are `undefined`, not `false`: upstream distinguishes 'not required' from 'required and unmet', and reporting `false` would claim the password failed a rule the project never had. | ✓ | ORACLE-BACKED: [`auth-validatepassword-status-shape`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-validatepassword-status-shape.json) captured prod's live policy (minPasswordLength 6, maxPasswordLength 4096, ENFORCE, character classes unset) and the status shape for a weak and a strong password. Replayed in [`unit:oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L753). The 6-char minimum agrees with the separately oracle-pinned `auth/weak-password` threshold on the create path, so the two surfaces draw the same line here exactly as they do in prod. | 174 |\n| fetchSignInMethodsForEmail(auth, email) | | Not implemented; disposition `auth.email-enumeration` owns the reviewed availability and rationale. | — | disposition:auth.email-enumeration; the [`auth-fetchsigninmethodsforemail-deprecated`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-fetchsigninmethodsforemail-deprecated.json) capture is retained but inconclusive because the oracle project's Email/Password provider is disabled. | 175 |\n| inMemoryPersistence / browserSessionPersistence / browserLocalPersistence / indexedDBLocalPersistence / browserCookiePersistence / browserPopupRedirectResolver / debugErrorMap / prodErrorMap | | Inert configuration tokens, accepted so the idiomatic `initializeAuth(app, { persistence: indexedDBLocalPersistence })` and `setPersistence(auth, browserCookiePersistence)` compile, run, and behave identically. The `.type` discriminant matches upstream exactly (`NONE` / `SESSION` / `LOCAL` / `LOCAL` / `COOKIE`) because consumer code branches on it. `browserPopupRedirectResolver` is accepted and ignored — the sandbox has its own first-class pluggable equivalent (`sandbox.setAuthFlowResolver`). `prodErrorMap` is accepted and DELIBERATELY not honored: installing it upstream strips error messages, and doing that in a sandbox whose purpose is to tell a developer what went wrong would be actively hostile. | ⚠ | In an in-memory sandbox the persistence CHOICE has no observable consequence, so accepting the token and recording the mode is the honest behavior — the same inert-token pattern `pyric/firestore` uses for its cache-factory tokens. [`unit:types.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/types.test.ts#L1) + [`unit:sandbox-email-link.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L1) cover the exports. Oracle caveat recorded on auth#172: the `persistenceTypes` block of `auth-mechanical-surface-constants` reports `NONE` for every token including `browserLocalPersistence` — a Node-build stub artifact, not the browser contract, so the `.type` values here follow the documented `Persistence.type` union instead. | 178 |\n| revokeAccessToken(auth, token) | | Accepted no-op. In production this reaches OUTSIDE Firebase entirely — it tells the identity provider (in practice Apple) to revoke an OAuth access token, which is a call landing on Apple's servers. There is no external IdP behind a sandbox sign-in, so there is no token out there to revoke and nothing this call could truthfully do. It resolves (so the account-deletion flow Apple requires an app to ship runs end to end against the sandbox) and changes no sandbox state (because claiming otherwise would be a lie). | ⚠ | [`unit:types.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/types.test.ts#L2) — the export resolves. Deliberately not oracle-probed: a successful probe would revoke a real token at a real IdP, which is a side effect on someone else's system that a conformance run has no business causing. | 179 |\n| AuthCredential / EmailAuthCredential / OAuthCredential | | Real classes, not markers. `EmailAuthProvider.credential(email, password)` returns an `EmailAuthCredential` that CARRIES THE SECRET — which is what makes `linkWithCredential`, `reauthenticateWithCredential`, and `signInWithCredential` decidable in the sandbox with no resolver and no mock (the backend already stores and verifies passwords). `credentialWithLink` carries the link instead, and `signInMethod` discriminates (`'password'` vs `'emailLink'`). The backing secret is non-enumerable, so a spread or `Object.keys` walk does not pick it up; `toJSON()` DOES carry it, matching upstream (whose `fromJSON` needs it to round-trip). `OAuthCredential` carries the IdP tokens, which the sandbox cannot verify — those flows keep going through the resolver seam. | ✓ | [`unit:sandbox-linking-reauth.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-linking-reauth.test.ts#L323) — pins the secret-carrying behavior, the enumerable/toJSON split (so nobody 'hardens' it into a divergence later), and that a real email credential now signs in via `signInWithCredential` instead of throwing the sandbox-only `auth/no-mock-configured` it used to. | 180 |\n| TwitterAuthProvider / SAMLAuthProvider | | Provider marker classes. `TwitterAuthProvider.credential(token, secret)` takes a token AND a secret — Twitter is the one OAuth 1.0a provider in the set, where the OAuth 2.0 providers take a single access token. `SAMLAuthProvider`'s constructor ENFORCES the `saml.` providerId prefix (throwing `auth/argument-error` otherwise), because that id is what routes an assertion to the right configured IdP and a typo there would silently target nothing. SAML has no client-constructible credential, so the class has no `credential()` factory. | ✓ | [`unit:types.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/types.test.ts#L1) — construction, PROVIDER_ID values, and the SAML prefix guard. | 181 |\n| User.providerData | | One entry PER LINKED PROVIDER, read from the identity's stored record. Previously the sandbox synthesized a single `{providerId: 'password'}` entry for EVERY non-anonymous user, so a Google popup sign-in reported its provider as `'password'` and a linked account could never show more than one. Consumer code branches on this array (that is what it is for — 'is this account linked to Google?'), so the synthesized version was actively misleading. Empty for anonymous users; after `unlink` of the last provider it is genuinely empty (it does not resurrect the removed provider). | ✓ | Was a BUG until this climb, and worth naming as one: the array was fabricated, not read. Now fixed and locked. [`unit:sandbox-linking-reauth.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-linking-reauth.test.ts#L54) now pins that a Google link surfaces `google.com` in `providerData`, that `unlink` shrinks it, and that unlinking the last provider leaves it empty rather than falling back to a synthesized `'password'` entry. | 182 |\n\n## Current gaps\n\n### Documented divergences\n\nKnown differences between Pyric and production Firebase. Each remains tracked as a non-conforming row.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">signInAnonymously(auth)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Auto-generates a uid for fresh sign-ins (sandbox format: <code>anonymous-{N}</code>)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-anonymous.test.ts#L7\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:sandbox-anonymous.test.ts</code></a> — prod uids are 28-char base64-ish; sandbox uses a readable counter for debuggability</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">signInAnonymously(auth)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Anonymous users persist across page reload via configured <code>Persistence</code> (prod only — sandbox has no persistence layer)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence: sandbox memory only; within one tab the user persists, across reload they don't</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">signInWithEmailAndPassword(auth, email, password)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">An EMPTY password throws <code>auth/missing-password</code> (message \"A non-empty password must be provided\"), fired before the user-DB lookup so it can't be used to enumerate seeded emails. Upstream maps the <code>MISSING_PASSWORD</code> server error (<code>core/errors.ts:92,282,563</code>). ⚠ best-known semantics — message text not yet captured against a live project (STOP-flagged for an oracle pass; the <code>.code</code> is the load-bearing part).</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-cluster-b9-b12.test.ts#L18\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:sandbox-cluster-b9-b12.test.ts</code></a> (locks AUTH-B11)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">signOut(auth)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Clears the active session's persistence in prod; sandbox has no persistence</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence: same memory-only constraint as the anonymous persistence row</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">setPersistence(auth, persistence)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Actually changes where the auth state is persisted</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence: sandbox is a no-op. Prod respects the marker.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">signInWithPopup(auth, provider)` / `signInWithCredential(auth, credential)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Opens a popup window in prod</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence: sandbox skips the popup; mock pre-stage replaces the popup result</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">beforeAuthStateChanged(auth, callback, onAbort?)</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>sandbox.setUser</code> (the sandbox-only test driver) BYPASSES the gate entirely — it is a raw identity force with no prod analog (same bypass it already has for provider enforcement / <code>signInProvider</code> tracking), so no registered <code>beforeAuthStateChanged</code> callback runs and none can block it.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-before-auth-state-changed.test.ts#L2\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:sandbox-before-auth-state-changed.test.ts</code></a> (\"sandbox.setUser test driver bypasses the gate\")</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">beforeAuthStateChanged(auth, callback, onAbort?)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Served-worker path (SharedWorker-backed auth in <code>@pyric/cli</code>): the worker owns the shared user pool and commits transitions on its own side of the port, so a page-local <code>beforeAuthStateChanged</code> registration can't actually gate a worker-driven sign-in. Rather than silently accept a callback that could never run, registering THROWS immediately (<code>auth/operation-not-supported-in-this-environment</code>) — same defensive pattern as <code>signInWithCredential</code> over the worker.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>packages/cli/src/serve/worker/client.ts</code> (<code>beforeAuthStateChanged</code> throws <code>makeUnsupported</code>)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">updateEmail(user, newEmail)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Changes the stored email (via the same path as <code>sandbox.updateUser</code>, rejecting <code>auth/email-already-in-use</code> / <code>auth/invalid-email</code>) and mutates the held <code>user</code> in place, so the next sign-in resolves against the new email. Leniency vs prod: the sandbox does NOT enforce <code>auth/requires-recent-login</code> and is not routed through <code>verifyBeforeUpdateEmail</code> (which the real SDK requires when email-enumeration protection is on)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/fruit-aliases.test.ts#L18\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:fruit-aliases.test.ts</code></a> — stored email changes; re-sign-in works with the new email, fails with the old</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">updatePassword(user, newPassword)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Sets the stored password (validated for strength). The sandbox DOES store and verify passwords, so this is a real mutation: a subsequent <code>signInWithEmailAndPassword</code> with the new password succeeds and the old one throws <code>auth/wrong-password</code>. Leniency vs prod: no <code>auth/requires-recent-login</code> enforcement</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/fruit-aliases.test.ts#L19\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:fruit-aliases.test.ts</code></a> — new password signs in, old password throws <code>auth/wrong-password</code></div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">useDeviceLanguage(auth)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Accepted no-op — the sandbox has no device locale to read, so there is no language to set; accepted so init code that calls it compiles + runs</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/fruit-aliases.test.ts#L22\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:fruit-aliases.test.ts</code></a> — resolves/returns without error</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">sendSignInLinkToEmail</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Prod rejects a continue URL whose domain is not on the project's authorized-domains list. The sandbox has NO domain allowlist and does not invent one: it accepts any parseable continue URL. A continue URL that would be rejected in production is accepted here.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">The sandbox has no project config to hold an authorized-domains list, so there is nothing to check against; inventing an allowlist would mean inventing a policy the user never set. The probe attempted the server arm (<code>auth-sendsigninlinktoemail-settings-validation</code>, <code>unauthorizedDomain</code>) but the oracle project has email-link sign-in disabled, so it answered <code>auth/operation-not-allowed</code> rather than <code>auth/unauthorized-continue-uri</code> — the divergence is declared from the documented contract, not from a capture we do not have. (Structured evidence: <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L26\" target=\"_blank\" rel=\"noopener noreferrer\"><code>sandbox-email-link.test.ts</code></a>)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">reauthenticate*</code><span class=\"compat-sub\"><span class=\"compat-behavior\">In production the POINT of re-authentication is the <code>auth/requires-recent-login</code> gate: <code>updateEmail</code> / <code>updatePassword</code> / <code>deleteUser</code> refuse to run on a session whose sign-in is older than a few minutes, and re-auth is how you clear it. The sandbox does NOT enforce that gate — those three mutations already run on a session of any age (a pre-existing documented divergence), so there is no gate here for re-auth to clear. What re-auth DOES do here is real but narrower: it genuinely re-verifies the credential, mints a fresh token, and returns <code>operationType: 'reauthenticate'</code>. Code that calls it runs unchanged against prod, where it also clears the gate.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Declared divergence, not a bug: inventing a recent-login gate would break every existing sandbox flow (which never re-authenticates) while proving nothing. <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-linking-reauth.test.ts#L27\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:sandbox-linking-reauth.test.ts</code></a> pins the behavior that IS provided (real credential re-verification + a fresh token).</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">signInWithCustomToken(auth, customToken)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Throws <code>auth/invalid-custom-token</code> for a malformed token and for the empty string. The sandbox accepts a token in the two shapes it can honestly read: a JSON (optionally base64url) <code>{uid, claims}</code> payload — exactly what <code>admin.auth().createCustomToken</code> signs, so the pyric-admin mint and this redeem compose — or a real three-part JWT whose payload segment carries <code>uid</code>/<code>sub</code>. The SIGNATURE IS NOT VERIFIED: the sandbox has no service-account key. The identity is created if it does not exist, and the credential carries <code>providerId: null</code> (custom-token sign-in is not a federated provider).</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">ORACLE-BACKED on the reject path: <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-signinwithcustomtoken-invalid.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>auth-signinwithcustomtoken-invalid</code></a> captured <code>auth/invalid-custom-token</code> for both a malformed token and the empty string, replayed in <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L742\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:oracle-conformance.test.ts</code></a>. Diverged on the ACCEPT path, declared rather than hidden: prod verifies an RS256 signature against the project's service-account key and the sandbox has no key, so it reads the token's claims WITHOUT verifying them. A forged token that prod would reject is accepted here. The happy path cannot be oracle-probed from a Web SDK client at all (it needs an Admin-SDK-signed JWT).</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">inMemoryPersistence / browserSessionPersistence / browserLocalPersistence / indexedDBLocalPersistence / browserCookiePersistence / browserPopupRedirectResolver / debugErrorMap / prodErrorMap</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Inert configuration tokens, accepted so the idiomatic <code>initializeAuth(app, { persistence: indexedDBLocalPersistence })</code> and <code>setPersistence(auth, browserCookiePersistence)</code> compile, run, and behave identically. The <code>.type</code> discriminant matches upstream exactly (<code>NONE</code> / <code>SESSION</code> / <code>LOCAL</code> / <code>LOCAL</code> / <code>COOKIE</code>) because consumer code branches on it. <code>browserPopupRedirectResolver</code> is accepted and ignored — the sandbox has its own first-class pluggable equivalent (<code>sandbox.setAuthFlowResolver</code>). <code>prodErrorMap</code> is accepted and DELIBERATELY not honored: installing it upstream strips error messages, and doing that in a sandbox whose purpose is to tell a developer what went wrong would be actively hostile.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">In an in-memory sandbox the persistence CHOICE has no observable consequence, so accepting the token and recording the mode is the honest behavior — the same inert-token pattern <code>pyric/firestore</code> uses for its cache-factory tokens. <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/types.test.ts#L1\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:types.test.ts</code></a> + <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L1\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:sandbox-email-link.test.ts</code></a> cover the exports. Oracle caveat recorded on auth#172: the <code>persistenceTypes</code> block of <code>auth-mechanical-surface-constants</code> reports <code>NONE</code> for every token including <code>browserLocalPersistence</code> — a Node-build stub artifact, not the browser contract, so the <code>.type</code> values here follow the documented <code>Persistence.type</code> union instead.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">revokeAccessToken(auth, token)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Accepted no-op. In production this reaches OUTSIDE Firebase entirely — it tells the identity provider (in practice Apple) to revoke an OAuth access token, which is a call landing on Apple's servers. There is no external IdP behind a sandbox sign-in, so there is no token out there to revoke and nothing this call could truthfully do. It resolves (so the account-deletion flow Apple requires an app to ship runs end to end against the sandbox) and changes no sandbox state (because claiming otherwise would be a lie).</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/types.test.ts#L2\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:types.test.ts</code></a> — the export resolves. Deliberately not oracle-probed: a successful probe would revoke a real token at a real IdP, which is a side effect on someone else's system that a conformance run has no business causing.</div></div></details>\n</div>\n\n### Not implemented yet\n\nTracked behavior that is not implemented in the current contract.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"unsupported\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">signInWithPopup(auth, provider)` / `signInWithCredential(auth, credential)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Cancels with <code>auth/popup-closed-by-user</code> when the user dismisses the popup (prod)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">not modeled — would require the host to expose a \"cancel\" affordance on the mock</div></div></details>\n<details class=\"compat-row\" data-status=\"unsupported\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\">Custom scopes / params / language code</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">sandbox ignores; prod forwards</div></div></details>\n<details class=\"compat-row\" data-status=\"unsupported\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">User` methods</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>user.metadata.creationTime</code> / <code>lastSignInTime</code></span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">client <code>User.metadata</code> not exposed (AUTH-GAP); the admin record carries <code>createdAt</code>/<code>lastLoginAt</code> (ISO)</div></div></details>\n<details class=\"compat-row\" data-status=\"unsupported\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">User` methods</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>user.reload()</code> / <code>user.delete()</code> / <code>user.toJSON()</code> / <code>user.refreshToken</code> / <code>user.tenantId</code></span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">not modeled by the sandbox; retained as an explicit unsupported behavior row rather than synthesized (AUTH-GAP)</div></div></details>\n<details class=\"compat-row\" data-status=\"unsupported\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">fetchSignInMethodsForEmail(auth, email)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Not implemented; disposition <code>auth.email-enumeration</code> owns the reviewed availability and rationale.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">disposition:auth.email-enumeration; the <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-fetchsigninmethodsforemail-deprecated.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>auth-fetchsigninmethodsforemail-deprecated</code></a> capture is retained but inconclusive because the oracle project's Email/Password provider is disabled.</div></div></details>\n</div>\n\n### Unverified\n\nTracked behavior whose available evidence does not yet establish the production result.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"unverified\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unverified\" role=\"img\" aria-label=\"Unverified\" title=\"Unverified\"></span><span class=\"compat-main\"><code class=\"compat-api\">getAuth(target)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Without sandbox package swapping, canonical <code>firebase/auth</code> imports remain Firebase and never enter this mirror</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Direct mirror rejection is locked by <code>unit:package-resolution.test.ts</code>; an unswapped production-resolution observation is still needed</div></div></details>\n</div>\n\n## Reviewed public-runtime gaps\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">auth.mfa-phone-recaptcha</code><span class=\"compat-sub\"><span class=\"compat-behavior\">MFA / phone / reCAPTCHA is deferred, not out of scope — reCAPTCHA/SMS are external infrastructure Pyric can mock with the OAuth resolver pattern, and TOTP is pure algorithm work.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>FactorId</code> <code>PhoneAuthCredential</code> <code>PhoneAuthProvider</code> <code>PhoneMultiFactorGenerator</code> <code>RecaptchaVerifier</code> <code>TotpMultiFactorGenerator</code> <code>TotpSecret</code> <code>getMultiFactorResolver</code> <code>initializeRecaptchaConfig</code> <code>linkWithPhoneNumber</code> <code>multiFactor</code> <code>reauthenticateWithPhoneNumber</code> <code>signInWithPhoneNumber</code></div>\n<div class=\"compat-note\">registry:auth#180</div></div>\n</details>\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Out of scope\" title=\"Out of scope\"></span><span class=\"compat-main\"><code class=\"compat-api\">auth.email-enumeration</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Deprecated upstream as a security retraction, not a cosmetic one: the shipped @firebase/auth declaration states it \"returns an empty list when Email Enumeration Protection is enabled, irrespective of the number of authentication methods available\", and that \"migrating off of this method is recommended as a security best-practice\". Enumeration protection is on by default, so against a modern project the production function always returns []. A mirror that reproduced the pre-deprecation behavior would be MORE capable than prod and would silently mislead agent code that branches on the result; a mirror that reproduced the post-deprecation behavior would be a no-op. Same criterion applied to Imagen in firebase/ai — an API whose production counterpart is retiring is out of scope, because mirroring it freezes dead behavior.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>fetchSignInMethodsForEmail</code></div>\n<div class=\"compat-note\">registry:auth#175, upstream:firebase/auth</div></div>\n</details>\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">auth.phone-mutation</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Phone number mutation is deferred alongside the rest of the phone-auth family — buildable via a mocked SMS/verification-code resolver, the same shape as the OAuth resolver.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>updatePhoneNumber</code></div>\n<div class=\"compat-note\">registry:auth#181</div></div>\n</details>\n</div>\n" }, { "slug": "firestore-compat", "label": "Firestore", "title": "pyric/firestore compatibility matrix", "markdown": "<!-- Generated from the conformance model (registry rows + surface contracts). Do not edit by hand; run bun run compat:generate. -->\n\n# `pyric/firestore` compatibility matrix\n\n<div class=\"compat-stat\">\n<p class=\"compat-stat-figure\">\n<span class=\"compat-stat-pct\">100%</span>\n<span class=\"compat-stat-label\">of the public API supported</span>\n</p>\n<div class=\"compat-meters\">\n<span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 100%\"></span></span>\n</div>\n<p class=\"compat-stat-denom\">182 of 182 public API</p>\n</div>\n\n\n## Status legend\n\n| Status | Meaning |\n|---|---|\n| ✓ | **Conforming** — sandbox matches prod, locked by a passing probe |\n| ⚠ | **Diverged (documented)** — intentional difference with a written reason |\n\n## `getFirestore(target)` — initializer\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getFirestore(target) | | `getFirestore(ctx)` returns a tagged sandbox-target handle (frozen identity) | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L16) | 1 |\n| getFirestore(target) | | `getFirestore(sandbox)` returns a tagged sandbox-live handle (per-op identity) | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L2) | 2 |\n| getFirestore(target) | | Package resolution owns production selection: direct `pyric/firestore` rejects a real `FirebaseApp`, while inactive canonical `firebase/firestore` imports remain the real Firebase SDK | ✓ | [`unit:package-resolution.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/package-resolution.test.ts#L8), [`node-register:register-child.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/register/register-child.test.ts#L140) (inactive canonical imports are not rewritten) | 3 |\n| getFirestore(target) | | `getFirestore(undefined)` is wrapped in the playground preview to default to the sandbox; production's unactivated canonical SDK still throws `app/no-app`, while a direct mirror call rejects the missing sandbox owner | ✓ (wrap) | `playground:firestore-bare-getfirestore` — fix from PR #397 + oracle: [`packages/conformance/observations/firestore/firestore-bare-getfirestore-no-default-app.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-bare-getfirestore-no-default-app.json) (`code: 'app/no-app'` against blockingfun, fb-js-sdk 12.13.0 — confirms prod throw shape) | 4 |\n| getFirestore(target) | | Two `getFirestore(sandbox)` calls share state (same underlying `LocalEnvironment`) | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L2) (\"two handles share the same sandbox\") | 5 |\n| getFirestore(target) | | Handle dispatch by `TARGET_SYMBOL` brand — refs/queries route to their owning target via `refToTarget` WeakMap | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L16) (\"throws TypeError for refs not produced by this package\") | 6 |\n\n## Path constructors — `doc` / `collection` / `collectionGroup`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Path constructors | | `doc(db, path)` returns a tagged `DocumentReference` with `id` / `path` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L18) | 7 |\n| Path constructors | | `doc(db, 'a', 'b', 'c', 'd')` joins variadic path segments | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L330) | 8 |\n| Path constructors | | `collection(db, path)` returns a tagged `CollectionReference` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L19) | 9 |\n| Path constructors | | `doc(coll, id)` appends under a collection ref | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L18) | 10 |\n| Path constructors | | `doc(coll)` (no id) mints an auto-id `DocumentReference` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L18) | 11 |\n| Path constructors | | `collection(docRef, name)` builds a subcollection ref | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L19) | 12 |\n| Path constructors | | `collectionGroup(db, id)` returns a query spanning every collection with that id | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L19) (\"gathers documents across every parent collection\") | 13 |\n| Path constructors | | Unknown ref (not produced by this package) → `TypeError` with \"unrecognized reference\" | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L18) | 14 |\n| Path constructors | | Held doc/coll ref under `sandbox-live` re-resolves to the chainable under the current user at op time (via rebuild closure) | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L26) (\"held doc ref re-resolves under the current user\") | 15 |\n\n## `getDoc(ref)` — single-doc read\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getDoc(ref) | | Returns `DocumentSnapshot` with `id`, `exists` (method form), `data()` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L21) | 16 |\n| getDoc(ref) | | `snap.exists` is normalized to method form (`snap.exists()` returns boolean) to match the modular SDK | ✓ | `playground:firestore-onsnapshot` (bundled, assertion-shape compat) + `playground:firestore-row-17-snap-exists-method` (one-claim) | 17 |\n| getDoc(ref) | | `snap.data()` returns `undefined` for missing doc | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L21) | 18 |\n| getDoc(ref) | | `snap.ref` is tagged so it routes through `targetOf` in follow-up ops | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L21) | 19 |\n| getDoc(ref) | | Re-evaluates rules under current user on every call (sandbox-live) — read denied throws `permission-denied` | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L28) (\"doc read denied when current user lacks read access\"), oracle: [`packages/conformance/observations/firestore/firestore-read-denied-error-code.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-read-denied-error-code.json) (prod `getDoc` on a denied path throws a `FirebaseError` with `.code === 'permission-denied'`, `.message === 'Missing or insufficient permissions.'`, `instanceof Error`) (Structured evidence: [`query-rules-enforcement.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/admin-compat/query-rules-enforcement.test.ts#L4)) | 20 |\n| getDoc(ref) | | Rules-denied modular writes throw a `FirebaseError('permission-denied')`, matching the production error class and code | ✓ | Oracle-locked by [`packages/conformance/observations/firestore/firestore-rules-denied-error.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-rules-denied-error.json): prod throws a `FirebaseError` (name + constructor name both `FirebaseError`), `.code === 'permission-denied'`, and the value is an `instanceof FirebaseError` and `Error`. `oracle:firestore#21` replays the same denied modular write against the sandbox and asserts that complete class shape. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L569)) | 21 |\n\n## `getDocs(query)` — bulk read\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getDocs(query) | | Returns `QuerySnapshot` with `size`, `empty`, `docs` (`QueryDocumentSnapshot[]`) | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L22), `playground:firestore-query` (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L41)) | 22 |\n| getDocs(query) | | Each `snap.docs[i].ref` is tagged for follow-up ops | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L22) | 23 |\n| getDocs(query) | | Sandbox-live: re-evaluates filters under the current user (different docs visible per identity) | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L29) (\"query results re-evaluate under the current user\") | 24 |\n| getDocs(query) | | **Query reads enforce security rules (FS-B1)** — a deny-all / auth-gated rule set throws `permission-denied`. Pre-FS-B1 query reads went through the rules-bypassing `listDocuments` and returned the whole collection. | ✓ | [`unit:admin-compat/query-rules-enforcement.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/admin-compat/query-rules-enforcement.test.ts#L4) (deny-all + auth-gated `getDocs`/aggregate), [`unit:admin-compat/per-op-auth.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/admin-compat/per-op-auth.test.ts#L158) (\"Query.get enforces rules\") | 24a |\n| getDocs(query) | | **Enforcement follows production's QUERY-PROOF model (RULES-B11)** — \"rules are not filters\": a doc-data-dependent `list` rule (`resource.data.visibility == 'public'`, `resource.data.owner == request.auth.uid`) is ALLOWED when the query's `where()` equalities discharge it and the whole query is `permission-denied` otherwise — never silently truncated to the readable subset. Per-doc `get` rules do NOT filter query results (the `list` rule alone governs queries — granular-operations docs). Applies to `getDocs`, aggregates, and `onSnapshot` alike. Pre-fix: rules-as-filters (per-doc `get` omission) + blanket denial of every doc-data-dependent list, even provable ones. | ✓ | [`unit:firestore/query-proof-enforcement.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/query-proof-enforcement.test.ts#L19) (provable/unprovable getDocs + onSnapshot, owner-pinned uid, get-rules-don't-filter, request.query.limit; verified failing pre-fix), [`unit:simulator/local-environment.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/simulator/local-environment.test.ts#L617) (Slice 6 — flipped from per-doc-filter assertions); prod truth: firebase.google.com/docs/firestore/security/rules-query | 24b |\n| getDocs(query) | | Query-proof **prover scope is conservative, with full accounting and a fail-closed classifier** — user functions are inlined (nested helper-calling-helper and multi-parameter helpers included, matching production's rules-function inlining), and a `list` rule is provable only when its **entire** doc-dependence reduces to top-level AND-conjunct `resource.data.<field> == <literal>` equalities (with `request.auth.uid` pinned to the caller) each discharged by a `where(field, '==', value)` filter. Doc-dependence is decided by a fail-closed classifier: an expression counts as doc-independent only when every node is positively recognized as such — any touch of the `resource` root in any syntactic form (`resource.data`, `resource['data']`, slices, `resource.id`, path-literal segments feeding `exists`/`get` lookups) and any unrecognized node shape classifies doc-dependent. Any doc-dependent conjunct that is not a discharged equality — disjunctions over doc data, inequality/range predicates (`resource.data.score > 10` + `where('score','>',10)`), `in` / negated-`in` membership checks, `get(key, default)`, `keys().hasOnly(...)`, `is` type checks, nested-path predicates, data-keyed document lookups — makes the whole query conservatively DENY, even when every equality conjunct is discharged; production's prover may allow some of these. Divergence is deny-only: mixed equality + non-equality shapes are rejected up front rather than trusted to a residual evaluation that absent-tolerant predicates could pass vacuously. | ⚠ | [`unit:rules/simulator/query-proof.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/query-proof.test.ts#L7) (function-inlining provable cases; full-accounting rejections for mixed equality + `in`/`get`/`hasOnly`/range/`is` shapes in inline and helper form; fail-closed classifier probes: bracket-access `resource['data']`, `exists()` keyed by `resource.data` through path-literal segments, slice access, `resource.id`, and an unrecognized node shape), [`unit:firestore/query-proof-enforcement.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/query-proof-enforcement.test.ts#L19) (seeded end-to-end denials of the demonstrated attacks: forbidden-field absence check in member and bracket form, banned-owner `exists()` lookup, slice conjunct — each returning zero documents) | 24c |\n| getDocs(query) | | Empty result for a collection with no docs (`size === 0`, `empty === true`) | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L22) | 25 |\n\n## `setDoc(ref, data[, options])` — full write\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| setDoc(ref, data[, options]) | | No options → replaces the existing document entirely | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L24) (\"setDoc default replaces\") | 26 |\n| setDoc(ref, data[, options]) | | `{ merge: true }` → **deep-merges nested maps** (FS-B6), preserving unspecified fields at every level: `setDoc({a:{b:2}}, {merge:true})` over `{a:{c:1}}` yields `{a:{b:2,c:1}}`. Pre-FS-B6 the wrapper shallow-replaced the whole `a` map. | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L24), [`unit:admin-compat/field-path-merge.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/admin-compat/field-path-merge.test.ts#L2) (FS-B6 nested deep-merge; verified failing pre-fix) | 27 |\n| setDoc(ref, data[, options]) | | `{ mergeFields: [...] }` → writes only the listed **dot-separated field paths** into the existing doc (FS-B6); other keys in `data` are ignored, other fields in the existing doc preserved. `mergeFields: ['a.b']` reaches into a nested map. | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L24), [`unit:admin-compat/field-path-merge.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/admin-compat/field-path-merge.test.ts#L2) (dotted mergeField); mask edges (delete/transform outside mask, empty mask, deleteField in mask): [`unit:upstream-write-aggregate-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/upstream-write-aggregate-probes.test.ts#L19) | 28 |\n| setDoc(ref, data[, options]) | | Passing both `merge` and `mergeFields` — `mergeFields` wins on sandbox (matches JS SDK effective behavior) | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L24) | 29 |\n| setDoc(ref, data[, options]) | | Sentinels (`serverTimestamp`, `increment`, `arrayUnion`, `arrayRemove`, `deleteField`) resolve in the same call | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L24), `playground:firestore-sentinels`, oracle: [`packages/conformance/observations/firestore/firestore-row-30-sentinels-in-setdoc.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-30-sentinels-in-setdoc.json) — `setDoc({createdAt: serverTimestamp(), count: 5, tags: ['a']})` followed by `getDoc` returns `createdAt` as a `Timestamp` instance (constructor name `Timestamp`, has `seconds` + `nanoseconds`), `count === 5` (number), `tags === ['a']`. Sentinels resolve server-side and the follow-up read sees concrete values, not the sentinel placeholders. | 30 |\n| setDoc(ref, data[, options]) | | Converter (via `withConverter`) runs `toFirestore(data)` before the write | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L24) (\"withConverter on a DocumentReference round-trips\") | 31 |\n| setDoc(ref, data[, options]) | | Rules-denied `setDoc` throws a `FirebaseError` with code `permission-denied`, matching production | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L24) (\"getDoc denies when rules reject\"), `playground:rules-data-validation`, oracle: [`packages/conformance/observations/firestore/firestore-write-denied-error-code.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-write-denied-error-code.json) (prod `setDoc` on a denied path throws a `FirebaseError` with `.code === 'permission-denied'`, `.message === '7 PERMISSION_DENIED: Missing or insufficient permissions.'`, `instanceof Error`); `oracle:firestore#32` pins the sandbox error shape (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L557)) | 32 |\n\n## `updateDoc(ref, data)` — partial write\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| updateDoc(ref, data) | | Merges `data` into the existing doc; missing fields preserved. **Top-level keys are dot-separated FieldPaths** (FS-B5): `updateDoc({'a.b': 2})` sets the nested leaf `a.b` (preserving `a.c`), not a literal `\"a.b\"` key; a single-segment map value replaces that field wholesale; `deleteField()` at a dotted path removes the nested leaf. | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L25), [`unit:admin-compat/field-path-merge.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/admin-compat/field-path-merge.test.ts#L2) (FS-B5 dot-path nested write + delete; verified failing pre-fix) (Structured evidence: [`firestore-updatedoc-dotpath-fieldpath`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-updatedoc-dotpath-fieldpath.json)) | 33 |\n| updateDoc(ref, data) | | Throws `not-found` (sandbox) / `FirebaseError('not-found')` (prod) on missing doc | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L25) (implicit in writes-fail-on-missing tests), oracle: [`packages/conformance/observations/firestore/firestore-updatedoc-missing-error.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-updatedoc-missing-error.json) (prod throws `FirebaseError` with `code: 'not-found'`, message `\"5 NOT_FOUND: No document to update: …\"`) | 34 |\n| updateDoc(ref, data) | | Does NOT run a converter — partial updates don't have a typed home (matches JS SDK) | ✓ | (documented in `withConverter` block) | 35 |\n| updateDoc(ref, data) | | Sentinels resolve mid-update (`increment(1)` against an existing numeric field, etc.) | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L25), `playground:firestore-sentinels`, oracle: [`packages/conformance/observations/firestore/firestore-row-36-sentinels-in-updatedoc.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-36-sentinels-in-updatedoc.json) — after `setDoc({count: 5, tags: ['a'], oldField: 'keep-then-remove'})` then `updateDoc({count: increment(3), tags: arrayUnion('b'), oldField: deleteField()})`, the follow-up `getDoc` returns `count: 8`, `tags: ['a', 'b']`, and `oldField` absent from the doc (the deleteField sentinel actually removes the key). All three sentinels apply in one mid-update commit. | 36 |\n| updateDoc(ref, data) | | Sandbox-live: each call re-evaluates auth (alice → bob between writes uses bob's auth) | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L31) (\"updateDoc re-evaluates auth per call\") | 37 |\n\n## `deleteDoc(ref)`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| deleteDoc(ref) | | Removes the document; subsequent `getDoc` returns `exists()===false` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L26) | 38 |\n| deleteDoc(ref) | | Idempotent — `deleteDoc` on missing doc resolves without throwing (matches JS SDK) | ✓ | `unit:deletedoc-missing.test.ts`, [`playground:firestore-deletedoc-missing`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-deletedoc-missing.json), oracle: `packages/conformance/observations/firestore/firestore-deletedoc-missing.json` | 39 |\n| deleteDoc(ref) | | Rules-denied delete throws `permission-denied` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L26) (rules-reject branch), oracle: [`packages/conformance/observations/firestore/firestore-delete-denied-error-code.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-delete-denied-error-code.json) (prod `deleteDoc` on a denied path throws a `FirebaseError` with `.code === 'permission-denied'`, `.message === '7 PERMISSION_DENIED: Missing or insufficient permissions.'`, `instanceof Error`) | 40 |\n\n## `addDoc(coll, data)` — auto-id write\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| addDoc(coll, data) | | Returns a tagged `DocumentReference` with auto-id | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L27) | 41 |\n| addDoc(coll, data) | | Returned ref is usable in subsequent ops (`getDoc`, `setDoc`, `onSnapshot`) | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L27), oracle: [`packages/conformance/observations/firestore/firestore-row-42-adddoc-returned-ref-usable.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-42-adddoc-returned-ref-usable.json) — `addDoc(coll, {v:1})` returned a ref whose `.id` is a 20-char auto-id; `getDoc(ref)` returned `{v:1}` (round-trip), `setDoc(ref, {v:2})` overwrote without error, follow-up `getDoc` returned `{v:2}`, and `onSnapshot(ref, cb)` registered cleanly and fired once with `{exists:true, v:2}`. All four follow-up ops succeed on the returned ref without re-tagging. | 42 |\n| addDoc(coll, data) | | Sandbox-live: returned ref is a *live* ref (rebuild closure recorded) so follow-ups re-resolve auth | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L32) (\"addDoc result is a tagged live ref\") | 43 |\n| addDoc(coll, data) | | Converter on the parent collection propagates onto the returned ref | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L27) (\"addDoc through a converted collection\") | 44 |\n| addDoc(coll, data) | | Auto-id format — production and sandbox mint 20-character IDs from `[A-Za-z0-9]` | ✓ | Oracle-locked: [`packages/conformance/observations/firestore/firestore-adddoc-autoid-format.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-adddoc-autoid-format.json) records production's 20-character alphanumeric shape; [`packages/pyric/test/firestore/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L185) generates 25 sandbox IDs and checks both invariants against that observation. | 45 |\n\n## `withConverter` — typed refs\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| withConverter | | `withConverter(docRef, converter)` returns a shell that runs `toFirestore` on writes, `fromFirestore` on reads | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L51) | 46 |\n| withConverter | | `withConverter(collRef, converter)` propagates onto `doc(typedColl, id)` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L51) | 47 |\n| withConverter | | `withConverter(collRef, converter)` propagates through `query(typedColl, …)` + `getDocs()` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L51) | 48 |\n| withConverter | | `withConverter(ref, null)` strips the converter, returns the underlying untyped view | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L51) | 49 |\n| withConverter | | Original untyped ref keeps its identity after `withConverter(ref, c)` (two views, one path) | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L51) | 50 |\n| withConverter | | `setDoc` through a converted ref invokes `toFirestore(data)` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L51) | 51 |\n| withConverter | | `getDoc` through a converted ref invokes `fromFirestore(snapshot)`; `.data()` returns the typed model | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L51) | 52 |\n| withConverter | | `updateDoc` through a converted ref does NOT invoke the converter | ✓ | (documented constraint; matches JS SDK) | 53 |\n\n## Query construction — `query` / `where` / `or` / `and` / `orderBy` / `limit`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Query construction | | `query(coll, where(…), orderBy(…), limit(…))` composes constraints in order | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L29), `playground:firestore-query` | 54 |\n| Query construction | | `where(field, op, value)` — all 10 ops: `<`, `<=`, `==`, `>=`, `>`, `!=`, `in`, `not-in`, `array-contains`, `array-contains-any` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L30) (canonical query test); membership ops + OR/`in`/`array-contains` composites: [`unit:upstream-query-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/upstream-query-probes.test.ts#L9) | 55 |\n| Query construction | | **Existence + null filter guards (FS-B7)** — a doc missing the filter field is never returned by `==`/`<`/`<=`/`>`/`>=`/`in`/`!=`/`not-in`; `!=` and `not-in` additionally exclude null-valued docs and require the field to exist; a `null` in a `not-in` operand list matches nothing. Pre-FS-B7, `!=`/`not-in` matched missing-field and null docs. | ✓ | [`unit:admin-compat/inequality-existence-guards.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/admin-compat/inequality-existence-guards.test.ts#L2) (verified failing pre-fix) | 55a |\n| Query construction | | `or(...)` composite — at least one sub-filter matches | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L28) (\"or() matches docs where any sub-filter matches\"), oracle: [`packages/conformance/observations/firestore/firestore-or-composite.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-or-composite.json) (4 seeded docs; `or(where('x','==',1), where('y','==',2))` returned the exact union `{match-both, match-x, match-y}` — no implicit index required against cloud Firestore) | 56 |\n| Query construction | | `and(...)` composite — every sub-filter matches | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L2) (\"and() requires every sub-filter\"), oracle: [`packages/conformance/observations/firestore/firestore-and-composite.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-and-composite.json) (4 seeded docs; `and(where('x','==',1), where('y','==',2))` returned only the intersection `{match-both}`) | 57 |\n| Query construction | | Nested `or` / `and` — full composite tree | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L2) (\"nested or/and — the canonical composite pattern\"), oracle: [`packages/conformance/observations/firestore/firestore-nested-or-and-composite.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-nested-or-and-composite.json) (6 seeded docs; `or(and(where('x','==',1), where('y','==',2)), where('z','==',3))` returned `{inner-and-match, outer-z-match, both-branches}` — exact boolean union as predicted) | 58 |\n| Query construction | | `orderBy(field, 'asc'\\|'desc')` — direction parameter | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L31) | 59 |\n| Query construction | | **Canonical type-order comparison (FS-B3)** — orderBy + range filters compare by Firestore's canonical type order (`null < bool < number < timestamp < string < bytes < ref < geopoint < array < map`), then within-type; numbers sort numerically (not lexicographically), NaN sorts as the smallest number, and range filters (`<`/`<=`/`>`/`>=`) only match same-type values. Pre-FS-B3 the comparator fell back to `String(a).localeCompare(String(b))`. | ✓ | [`unit:firestore/sandbox/query-value-order.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox/query-value-order.test.ts#L2) (cross-type ranking, numeric sort, NaN, timestamps, arrays; verified failing pre-fix) | 59a |\n| Query construction | | **orderBy excludes missing-field docs (FS-B3)** — a doc lacking an orderBy field is omitted from the result (matches prod); pre-fix it was sorted in via `compareValues(undefined, …)`. | ✓ | [`unit:firestore/sandbox/query-value-order.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox/query-value-order.test.ts#L2) (\"excludes the missing-field doc\") | 59b |\n| Query construction | | **Implicit orderBy + `__name__` tiebreak (FS-B8)** — the query's sort is normalized to: explicit orderBy clauses, then an implicit order on each inequality-filtered field, then a final document-key (`__name__`) clause. Equal-valued docs sort deterministically by key; a `where('x','>',v)` with no explicit orderBy returns docs ordered by `x`. Mirrors `clones/.../core/query.ts:queryNormalizedOrderBy`. Pre-FS-B8 equal-valued docs were nondeterministic and inequality results came back in insertion order. | ✓ | [`unit:admin-compat/implicit-order-name.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/admin-compat/implicit-order-name.test.ts#L2) (key tiebreak, snapshot-cursor disambiguation, implicit inequality order; verified failing pre-fix) | 59c |\n| Query construction | | `limit(n)` — caps result count | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L32) | 60 |\n| Query construction | | `limitToLast(n)` returns the trailing n documents in an ordered result; without `orderBy`, it throws a `FirestoreError` with production's `unimplemented` code | ✓ | Oracle-locked by [`packages/conformance/observations/firestore/firestore-limittolast-preconditions.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-limittolast-preconditions.json): production's no-orderBy precondition throws code `unimplemented`, while ordered trailing-window semantics return `[\"b\"]`. `oracle:firestore#61` replays both claims. Cursor composition + descending: [`unit:upstream-query-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/upstream-query-probes.test.ts#L10); cursor/empty-snapshot preconditions: [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L560) + [`unit:admin-compat/cursors.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/admin-compat/cursors.test.ts#L198). (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L331)) | 61 |\n| Query construction | | Composite filters AND with other constraints — `query(coll, or(...), orderBy(...), limit(...))` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L29) | 62 |\n| Query construction | | Passing `orderBy` / `limit` into `or()` / `and()` → `TypeError` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L2) | 63 |\n| Query construction | | Zero-arg `or()` / `and()` → `TypeError` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L2) | 64 |\n| Query construction | | Chained queries re-tag for further constraints (`query(query(coll, where), orderBy)`) | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L29) (\"chained queries are taggable\") | 65 |\n| Query construction | | Index validation against `firestore.indexes.json` — sandbox uses `LocalEnvironment`'s lint pass; prod has its own server-side validation | ⚠ | divergence: sandbox can mis-pass a query that prod would reject at the server with `failed-precondition` if no index exists | 66 |\n\n## Cursor pagination — `startAt` / `startAfter` / `endAt` / `endBefore`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Cursor pagination | | `startAt(...values)` — inclusive value cursor (one positional per `orderBy` clause) | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L577), oracle: [`packages/conformance/observations/firestore/firestore-cursor-startat-inclusive.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-cursor-startat-inclusive.json) (5 seeded docs at pos=[1..5]; `query(c, orderBy('pos'), startAt(3))` returned exactly `[pos-3, pos-4, pos-5]` — the cursor doc IS included) | 67 |\n| Cursor pagination | | `startAfter(...values)` — exclusive value cursor | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L586), oracle: [`packages/conformance/observations/firestore/firestore-cursor-startafter-exclusive.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-cursor-startafter-exclusive.json) (5 seeded docs at pos=[1..5]; `query(c, orderBy('pos'), startAfter(3))` returned exactly `[pos-4, pos-5]` — the cursor doc is EXCLUDED) | 68 |\n| Cursor pagination | | `endAt(...values)` — inclusive end cursor | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L595), oracle: [`packages/conformance/observations/firestore/firestore-cursor-endat-inclusive.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-cursor-endat-inclusive.json) (5 seeded docs at pos=[1..5]; `query(c, orderBy('pos'), endAt(3))` returned exactly `[pos-1, pos-2, pos-3]` — the cursor doc IS included) | 69 |\n| Cursor pagination | | `endBefore(...values)` — exclusive end cursor | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L604), oracle: [`packages/conformance/observations/firestore/firestore-cursor-endbefore-exclusive.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-cursor-endbefore-exclusive.json) (5 seeded docs at pos=[1..5]; `query(c, orderBy('pos'), endBefore(3))` returned exactly `[pos-1, pos-2]` — the cursor doc is EXCLUDED) | 70 |\n| Cursor pagination | | `startAt(snapshot)` overload — extracts orderBy field values from the snapshot, positioning against the NORMALIZED orderBy (implicit `__name__`), so it disambiguates equal-valued docs and is **legal without an explicit orderBy** (FS-B8). A VALUE cursor with more values than explicit orderBy clauses throws `invalid-argument` (\"Too many arguments\"). | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L577), [`unit:admin-compat/implicit-order-name.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/admin-compat/implicit-order-name.test.ts#L15) (snapshot cursor w/o orderBy), [`unit:admin-compat/cursors.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/admin-compat/cursors.test.ts#L6) (value-cursor too-many-args throws with `.code`) (Structured evidence: [`firestore-startat-snapshot-implicit-name`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-startat-snapshot-implicit-name.json)) | 71 |\n| Cursor pagination | | `endAt(snapshot)` overload | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L595) (\"endAt(snapshot) trims to-and-including the anchor\") | 72 |\n| Cursor pagination | | `startAfter + limit` — canonical pagination pattern | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L586) | 73 |\n\n## Aggregates — `getCountFromServer` / `getAggregateFromServer` / `count` / `sum` / `average`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Aggregates | | `getCountFromServer(query)` returns `{ data: () => ({ count: N }) }` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L71); collectionGroup: [`unit:upstream-write-aggregate-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/upstream-write-aggregate-probes.test.ts#L21) | 74 |\n| Aggregates | | `getCountFromServer` honors `where` filters | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L791) | 75 |\n| Aggregates | | `getAggregateFromServer(query, spec)` returns `{ data: () => Record<alias, number\\|null> }` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L807); collectionGroup + nested paths: [`unit:upstream-write-aggregate-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/upstream-write-aggregate-probes.test.ts#L22) | 76 |\n| Aggregates | | `count()` / `sum(field)` / `average(field)` compose under one spec — `field` may be a dotted nested path | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L7); nested `sum('metadata.pages')`: [`unit:upstream-write-aggregate-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/upstream-write-aggregate-probes.test.ts#L23) | 77 |\n| Aggregates | | `average` returns `null` on empty input (matches JS SDK) | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L807) | 78 |\n| Aggregates | | Aggregates count documents server-side without paying read cost per doc in prod; sandbox computes locally (no cost model) | ⚠ | divergence: cost behavior differs, observable shape identical. Oracle-locked: [`packages/conformance/observations/firestore/firestore-count-aggregate-shape.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-count-aggregate-shape.json) — `getCountFromServer().data()` returns `{ count: <number> }` (single key, no other fields). Empty query returns `count: 0` (not `null`/`undefined`); seeded 3 docs returns `count: 3`; filtered query honors the `where` constraint (`count: 2`). | 79 |\n\n## `onSnapshot(refOrQuery, …)` — listeners\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| onSnapshot(refOrQuery, …) | | `onSnapshot(docRef, cb)` fires the initial snapshot **asynchronously** — never synchronously during the registering call. Prod empirically lands after a `setTimeout(0)` macrotask (the fire travels the network listener channel); the sandbox defers through its delivery scheduler (microtask). The matrix contract is \"asynchronous, never during register\", not \"exactly the next microtask\" | ✓ | Aligned via the listener delivery scheduler (`src/sandbox/firestore/local-environment.ts`): the initial fire is enqueued and delivered on a microtask, never during register — closing the divergence this row previously documented (the sandbox used to fire synchronously during registration; the sync-body tests were migrated to the flush/await idiom). Machine-checked against [`packages/conformance/observations/firestore/firestore-row-80-onsnapshot-fires-initial.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-80-onsnapshot-fires-initial.json) (`firstFireSyncDuringRegister: false`, fire count + contents) in `oracle-conformance.test.ts`; also [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L330), `playground:firestore-onsnapshot` (bundled) + `playground:firestore-row-80-onsnapshot-fires-initial` (one-claim). | 80 |\n| onSnapshot(refOrQuery, …) | | `onSnapshot(query, cb)` fires on collection writes; `QuerySnapshot.docChanges()` reports `added` / `modified` / `removed` with `oldIndex` / `newIndex` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L34), oracle: [`packages/conformance/observations/firestore/firestore-row-81-onsnapshot-query-fires-on-write.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-81-onsnapshot-query-fires-on-write.json) — listener on `query(coll)` saw 1 initial fire (empty, `size:0`), then one fire per write: `addDoc` → `size:1`, `setDoc(coll, 'known-id')` → `size:2`, `deleteDoc(addedRef)` → `size:1`. Total 4 fires, each reflecting the current collection state. Every collection-level write produces a distinct fire. (Note: this oracle used a *filterless* `query(coll)`, which masked FS-B2 — see row 81a.) Modular `docChanges` indexes: [`unit:upstream-transform-txn-listener-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/upstream-transform-txn-listener-probes.test.ts#L12) | 81 |\n| onSnapshot(refOrQuery, …) | | **Filtered listeners honor `where` / `orderBy` / `limit` (FS-B2)** — `onSnapshot(query(coll, where(…), orderBy(…), limit(…)), cb)` delivers the same membership as `getDocs(sameQuery)`: non-matching docs are excluded on the initial fire and on writes; ordering + limit are applied. Pre-FS-B2 the `SnapshotTarget` dropped all constraints and delivered the whole collection. | ✓ | [`unit:onsnapshot-query-constraints.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/onsnapshot-query-constraints.test.ts#L2) (filtered/ordered/limited listeners; verified failing pre-fix) | 81a |\n| onSnapshot(refOrQuery, …) | | **Listener `.data()` matches `getDoc` shape (FS-B10)** — the `onSnapshot` doc + query snapshot path runs the same read-path translation as `getDoc`/`getDocs`, so `snap.data().createdAt` is a compat `Timestamp` (`{seconds, nanoseconds}`), not the rules-internal wrapper (`{seconds, nanos}` + `typeName`, no `nanoseconds`). Pre-FS-B10 a listener leaked the internal shape while the single-doc read returned the compat shape. | ✓ | [`unit:simulator/listener-read-translation.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/simulator/listener-read-translation.test.ts#L2) (doc + query listener Timestamp shape; verified failing pre-fix) | 81b |\n| onSnapshot(refOrQuery, …) | | Initial fire for a missing doc has `exists() === false` and `data() === undefined` | ✓ | `playground:firestore-onsnapshot` (bundled) + [`playground:firestore-row-82-onsnapshot-missing-initial`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-82-onsnapshot-missing-initial.json) (one-claim), oracle: `packages/conformance/observations/firestore/firestore-row-82-onsnapshot-missing-initial.json` — single initial fire with `snap.exists() === false`, `snap.data() === undefined`, `hasPendingWrites: false`, `fromCache: false`. The missing-doc fire is server-confirmed, not a cache speculation. | 82 |\n| onSnapshot(refOrQuery, …) | | Returned `Unsubscribe` stops further fires | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L34), oracle: [`packages/conformance/observations/firestore/firestore-row-83-unsubscribe-stops-fires.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-83-unsubscribe-stops-fires.json) — pre-unsubscribe write fired the listener (initial fire + write fire = 2 fires); after `unsub()`, a subsequent `setDoc` produced 0 additional fires (`postUnsubFireCount: 0`). Unsubscribe is durable; no fires arrive on the released callback after a 1.5s settle window. | 83 |\n| onSnapshot(refOrQuery, …) | | Observer object form `{next, error, complete}` works alongside the function form. **Partial observers are accepted — `{ error: fn }` with no `next` registers and routes denials to `error` (FS-B14, `isPartialObserver` semantics from upstream `api/observer.ts`); pre-fix it was misrouted as `SnapshotListenOptions` and threw \"missing next handler\".** | ✓ | oracle: [`packages/conformance/observations/firestore/firestore-row-84-observer-object-form.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-84-observer-object-form.json) — registered two listeners on the same doc: one as a bare function `(snap) => …`, one as `{next, error, complete}`. Both fired once on initial (`{v:0}`) and again after a write (`{v:1}`), capturing identical data. `error` never fired (no rule denial), `complete` never fired on `unsub()` (Firebase treats unsubscribe as a teardown, not a \"complete\" signal — the observer's `complete` callback is reserved for terminal stream end, which `onSnapshot` does not produce). The two registration shapes are interchangeable for fire dispatch. [`unit:onsnapshot-observer-discriminator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/onsnapshot-observer-discriminator.test.ts#L2) (error-only observer; verified failing pre-fix) | 84 |\n| onSnapshot(refOrQuery, …) | | `SnapshotListenOptions.includeMetadataChanges` — one write yields the pending-write local echo (`hasPendingWrites: true`) then, for metadata listeners, the settled ack fire: default listener 2 fires, metadata listener 3 | ✓ | Aligned via the listener delivery scheduler (`src/sandbox/firestore/local-environment.ts` + `snapshot-listeners.ts`): the write echo carries `hasPendingWrites: true` and `includeMetadataChanges` listeners receive the settled metadata-only ack, reproducing prod's recorded 2/3-fire sequences exactly. Machine-checked against [`packages/conformance/observations/firestore/firestore-include-metadata-changes.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-include-metadata-changes.json) in `oracle-conformance.test.ts` (fire counts and per-fire `hasPendingWrites` sequence asserted from the capture) | 85 |\n| onSnapshot(refOrQuery, …) | | Snapshot's `.ref` / `.docs[i].ref` are tagged so consumer code can pass them to follow-up ops | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L34) | 86 |\n| onSnapshot(refOrQuery, …) | | Sandbox-live: listener registered as alice keeps emitting alice's view after `setUser → bob` (identity frozen at subscribe) | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L35) (\"listener registered as alice keeps emitting alice's view\") | 87 |\n| onSnapshot(refOrQuery, …) | | Sandbox-live: listener registered as anonymous keeps firing after sign-in (anonymous → signed-in identity persists per listener) | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L35) (\"listener registered as anonymous on /public keeps firing after sign-in\") | 88 |\n| onSnapshot(refOrQuery, …) | | Snapshot's ref is usable in follow-up ops under the new user (the ref is live, the listener identity is frozen — distinct) | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L35) (\"snapshot ref is usable in subsequent ops under the new user\"), oracle: [`packages/conformance/observations/firestore/firestore-row-89-snapshot-ref-usable.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-89-snapshot-ref-usable.json) — captured `snap.ref` from a docRef listener's first fire and `snap.docs[0].ref` from a query listener's first fire; both refs round-trip via `getDoc` (returning the same data) and `setDoc` (writes succeed and a follow-up `getDoc` confirms the new payload). `snap.ref.path` equals the original `doc(coll, id).path`. Both snap-ref shapes are first-class refs in prod, matching sandbox's tagged-ref guarantee. | 89 |\n| onSnapshot(refOrQuery, …) | | Preview tree mounts the user's component exactly once per session load — no observer subscriptions leak across parallel `AppPreview` instances. Root cause: `PlaygroundPage` rendered both `WorkspacePanel`'s and the mobile `AppPanel`'s `AppPreview` unconditionally (the latter `md:hidden` on desktop but still mounted), producing two live preview trees subscribing in parallel. Fixed by gating `AppPanel` on `useIsMobile() && mobileTab === 'app'`. | ✓ | `playground:preview-single-mount` | 90 |\n\n## `runTransaction(db, fn)`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| runTransaction(db, fn) | | Atomic read-write — all reads in `fn` see a consistent snapshot, writes commit together | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L36), `playground:firestore-transaction`; get-missing/deleted + empty txn + nested update: [`unit:upstream-transform-txn-listener-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/upstream-transform-txn-listener-probes.test.ts#L27) | 91 |\n| runTransaction(db, fn) | | Identity is frozen at `runTransaction` start — mid-transaction `setUser` does NOT re-auth in-flight reads | ✓ | [`unit:firestore/sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L36) changes the live session from alice to bob inside the callback; the transaction still reads and commits under alice's captured identity. | 92 |\n| runTransaction(db, fn) | | Retry behavior — a read-document conflict reruns the callback against fresh data; `maxAttempts` bounds persistent contention | ✓ | Oracle: [`packages/conformance/observations/firestore/firestore-transaction-contention-retries.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-transaction-contention-retries.json) — two production Web SDK clients force one retry (`[0, 40]`, final `42`) and persistent contention with `maxAttempts: 2` runs exactly twice then throws `failed-precondition`. [`packages/pyric/test/firestore/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L603) replays both cases through the sandbox. (Structured evidence: [`sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L36)) | 93 |\n| runTransaction(db, fn) | | Throws `FirebaseError('permission-denied')` on rule denial inside the transaction (the inner write's denial — not a generic `aborted`). Sandbox throws `FirestoreCompatError` with the same `code: 'permission-denied'` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L36) (writes-reject branch), oracle: [`packages/conformance/observations/firestore/firestore-transaction-rules-denied-error.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-transaction-rules-denied-error.json) (prod throws `FirebaseError` with `code: 'permission-denied'`, NOT `aborted`; the inner callback ran once and the rules-rejected write surfaces as a regular permission-denied at commit) | 94 |\n\n## `writeBatch(db)`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| writeBatch(db) | | `batch.set` / `batch.update` / `batch.delete` queue mutations | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L37), `playground:firestore-batch` | 95 |\n| writeBatch(db) | | `batch.commit()` applies all queued writes atomically — success path commits all queued mutations together; failure path (one write violating rules) rejects the **whole** batch with no partial application | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L37), oracle: [`packages/conformance/observations/firestore/firestore-row-96-batch-commit-atomic.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-96-batch-commit-atomic.json) — success path: a batch with `set` (fresh doc), `update` (existing doc), and `delete` (existing doc) all land in a single commit (`allApplied: true`). Failure path: a batch with one write targeting a path **outside** `pyric_oracle/*` rejects with `code: 'permission-denied'` and leaves the would-have-set doc absent and the would-have-updated doc at its original value (`noPartialApply: true`) — atomicity verified end-to-end. | 96 |\n| writeBatch(db) | | Batch is tagged on construction and remains bound to the sandbox owner that created it | ✓ | [`unit:firestore/sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L37) constructs a batch from sandbox A with a reference from sandbox B and proves the commit remains isolated to A. | 97 |\n| writeBatch(db) | | Batch identity is frozen at construction (per current implementation) | ✓ | [`unit:firestore/sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L37) constructs under alice, switches the live session to bob, and proves commit still succeeds under alice's captured identity. | 98 |\n\n## Sentinels — `serverTimestamp` / `increment` / `arrayUnion` / `arrayRemove` / `deleteField` / `FieldValue` / `Timestamp`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Sentinels | | `serverTimestamp()` resolves to a `Timestamp` after the write commits | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L40), `playground:firestore-sentinels` (bundled) + `playground:firestore-row-99-servertimestamp-resolves` (one-claim), oracle: [`packages/conformance/observations/firestore/firestore-row-99-servertimestamp-resolves-to-timestamp.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-99-servertimestamp-resolves-to-timestamp.json) — `setDoc({at: serverTimestamp()})` then `getDoc` yields `at instanceof Timestamp === true`, `constructor.name === 'Timestamp'`, with both `.seconds` (number) and `.nanoseconds` (number) present. | 99 |\n| Sentinels | | `increment(n)` atomically bumps a numeric field; `null`/missing field starts from 0 | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L41), `playground:firestore-sentinels` (bundled) + [`playground:firestore-row-100-increment-bumps-numeric`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-100-increment-bumps-numeric.json) (one-claim), oracle: `packages/conformance/observations/firestore/firestore-row-100-increment-bumps-numeric.json` — `setDoc` with no `count` field then `updateDoc({count: increment(5)})` yields `count === 5` (starts from 0). Follow-up `increment(3)` → 8, then `increment(-2)` → 6 (negative deltas apply, increments accumulate). Merge-create + int↔double + batch-across-docs: [`unit:upstream-transform-txn-listener-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/upstream-transform-txn-listener-probes.test.ts#L5) | 100 |\n| Sentinels | | `arrayUnion(...values)` de-dupes against existing members **and** against duplicate args within the same call | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L42), `playground:firestore-sentinels` (bundled) + [`playground:firestore-row-101-arrayunion-dedupes`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-101-arrayunion-dedupes.json) (one-claim), oracle: `packages/conformance/observations/firestore/firestore-row-101-arrayunion-dedupes.json` — `setDoc({tags: ['a','b']})` then `updateDoc({tags: arrayUnion('b','c')})` yields `['a','b','c']` (single `b`, not double). Follow-up `updateDoc({tags: arrayUnion('d','d','a')})` yields `['a','b','c','d']` — both inline duplicate args and existing-member duplicates are de-duped. Merge-path + object members: [`unit:upstream-transform-txn-listener-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/upstream-transform-txn-listener-probes.test.ts#L4) | 101 |\n| Sentinels | | `arrayRemove(...values)` strips matching members; values not present in the array are silent no-ops | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L43), `playground:firestore-sentinels` (bundled) + [`playground:firestore-row-102-arrayremove-strips`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-102-arrayremove-strips.json) (one-claim), oracle: `packages/conformance/observations/firestore/firestore-row-102-arrayremove-strips.json` — `setDoc({tags: ['a','b','c']})` then `updateDoc({tags: arrayRemove('b','d')})` yields `['a','c']`: `'b'` removed, `'d'` (absent) was a silent no-op (no error). Merge-path: [`unit:upstream-transform-txn-listener-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/upstream-transform-txn-listener-probes.test.ts#L30) | 102 |\n| Sentinels | | `deleteField()` removes a field on update — the field is fully absent from the returned data, not merely undefined-valued. Legal at the top level or via a **dot-path** (`{'a.b': deleteField()}` removes the nested leaf — FS-B5). **Nested inside a map literal (`{a: {b: deleteField()}}`) it throws `invalid-argument` (FS-B13)** instead of destroying the sibling map. | ✓ | `playground:firestore-sentinels` (bundled) + [`playground:firestore-row-103-deletefield-removes-field`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-103-deletefield-removes-field.json) (one-claim), oracle: `packages/conformance/observations/firestore/firestore-row-103-deletefield-removes-field.json` — `setDoc({keep:1, remove:2})` then `updateDoc({remove: deleteField()})` yields a doc whose `data()` has keys `['keep']` only, `keep === 1` preserved; [`unit:admin-compat/nested-delete-field.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/admin-compat/nested-delete-field.test.ts#L2) (nested → invalid-argument; dot-path + top-level still valid; verified failing pre-fix) | 103 |\n| Sentinels | | `Timestamp` shape (`{seconds, nanoseconds}`) is identical between prod and sandbox — round-trips cleanly | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L40) | 104 |\n| Sentinels | | **`Timestamp` nanos normalization + value API (FS-B12)** — `fromMillis`/`fromDate`/`now` derive `nanoseconds` as `floor((ms - seconds*1000) * 1e6)` so it is always non-negative; `fromMillis(-500).toMillis()` round-trips to -500 (was -1500). The class ships `isEqual` / `toString` / `toJSON` / `valueOf`, mirroring `clones/.../lite-api/timestamp.ts`. | ✓ | [`unit:admin-compat/timestamp-api.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/admin-compat/timestamp-api.test.ts#L2) (negative-millis round-trip + value API; pre-fix lacked the methods and mis-normalized) | 104b |\n| Sentinels | | **Unified Timestamp storage (FS-B4)** — a `Timestamp` written directly via the modular SDK (`setDoc({createdAt: Timestamp.now()})`) is stored as the same rules-internal `Timestamp` that `serverTimestamp()`/`Date` resolve to. Pre-FS-B4 a user-written `Timestamp` was the compat class only (not a `RulesValue`), so `request.resource.data.createdAt is timestamp` returned **false** for it while a `serverTimestamp()` write passed the same rule, and the two paths stored two different classes. A write-boundary converter now normalizes both. | ✓ | [`unit:firestore/sandbox-converters/user-timestamp.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-converters/user-timestamp.test.ts#L2) (`is timestamp` passes for a user Timestamp; unified storage class; range-filter regression guard — verified failing pre-fix by removing the converter registration) | 104a |\n| Sentinels | | `FieldValue` re-exported from `pyric-admin` (alias of `ChainFieldValue`) | ✓ | type-only smoke | 105 |\n| Sentinels | | **Sentinel overwrite on type mismatch (FS-B11)** — `increment(n)` on a non-numeric (or absent) prior OVERWRITES using a base value of 0 (result `n`); `arrayUnion`/`arrayRemove` on a non-array prior coerce the base to `[]`. Pre-FS-B11 these threw and surfaced as `invalid-argument` denials. Mirrors `clones/.../model/transform_operation.ts` (`computeTransformOperationBaseValue`, `coercedFieldValuesArray`). | ✓ | [`unit:simulator/converters/fieldvalue.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/simulator/converters/fieldvalue.test.ts#L5) (FS-B11 overwrite block + flipped unit/integration/batch cases; verified failing pre-fix) | 105a |\n\n## Scalar types — `Bytes` / `GeoPoint` / `FieldPath` / `documentId`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Scalar types | | The sandbox mirror owns compatible scalar constructors — `Bytes.fromUint8Array(...)`, `new GeoPoint(lat, lng)`, `new FieldPath(...)`, and `documentId()` — without importing `firebase/firestore` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L46) (constructibility + round trips), [`package-edge:package-dependencies.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/package-dependencies.test.ts#L4) | 106 |\n| Scalar types | | `documentId()` works in `where(documentId(), …)` / `orderBy(documentId())` against the sandbox — string ids, DocumentReference operands, ranges, and id sort | ✓ | [`unit:upstream-query-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/upstream-query-probes.test.ts#L9) (`documentId() filters + orderBy`); modular `where`/`orderBy` accept `FieldPath` | 107 |\n| Scalar types | | `FieldPath` (nested) works in queries against sandbox | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L870) | 108 |\n| Scalar types | | `Bytes` round-trip through the sandbox wire encoder — `Bytes` written via `setDoc` reads back as a `Bytes` instance with the same base64 representation | ✓ | [`unit:packages/pyric/test/sandbox/firestore/wire-encoder-bytes-geopoint.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/wire-encoder-bytes-geopoint.test.ts#L2) + [`unit:packages/pyric/test/firestore/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L46) (\"Bytes + GeoPoint round-trip\"), oracle: [`packages/conformance/observations/firestore/firestore-row-109-bytes-roundtrip.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-109-bytes-roundtrip.json) — `setDoc({payload: Bytes.fromUint8Array([1,2,3,4])})` then `getDoc` yields `payload instanceof Bytes === true`, `payload.constructor.name === 'Bytes'`, `payload.toBase64() === 'AQIDBA=='`, and `payload.toUint8Array()` returns `[1,2,3,4]` against blockingfun. The sandbox converter stores the rules `Bytes` wrapper; `pyric/firestore` finalizes reads into its locally owned `Bytes` class with the same observed methods and values. | 109 |\n| Scalar types | | `GeoPoint` round-trip through the sandbox wire encoder — `GeoPoint` written via `setDoc` reads back as a `GeoPoint` instance with the same latitude / longitude | ✓ | [`unit:packages/pyric/test/sandbox/firestore/wire-encoder-bytes-geopoint.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/wire-encoder-bytes-geopoint.test.ts#L2) + [`unit:packages/pyric/test/firestore/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L47) (\"Bytes + GeoPoint round-trip\"), oracle: [`packages/conformance/observations/firestore/firestore-row-110-geopoint-roundtrip.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-110-geopoint-roundtrip.json) — `setDoc({loc: new GeoPoint(37.7749, -122.4194)})` then `getDoc` yields `loc instanceof GeoPoint === true`, `loc.constructor.name === 'GeoPoint'`, `loc.latitude === 37.7749`, `loc.longitude === -122.4194` against blockingfun. Sandbox storage uses the rules `LatLng` wrapper; `pyric/firestore` finalizes reads into its locally owned `GeoPoint` class. | 110 |\n| Scalar types | | Vector value type (`vector()` + `VectorValue`) round-trip: a vector written via `setDoc` reads back as a `VectorValue` with the same components | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L48) (\"Bytes + GeoPoint + VectorValue round-trip\", top-level + nested). The locally owned `vector()` / `VectorValue` preserve Firebase's observable value shape; the sandbox converter stores the rules `Vector` wrapper and `pyric/firestore` finalizes reads back to `VectorValue`. Oracle observation to follow (cf. #109/#110). **CLIENT surface only:** the web SDK exposes `vector()` + `VectorValue` (read/write) but has NO `findNearest` and NO `FieldValue.vector`; vector SEARCH is admin/server-only (`firebase-admin` `Query`/`CollectionReference.findNearest` + `FieldValue.vector()`), out of scope for this client matrix; the admin surface is tracked in the design rationale. | 111 |\n\n## Equality helpers — `refEqual` / `queryEqual` / `snapshotEqual`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Equality helpers | | `refEqual(a, b)` — true when paths match under the same target | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L840) | 112 |\n| Equality helpers | | `refEqual` is `true` for cross-flavor sandbox vs sandbox-live refs at the same path | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L38) (\"refEqual returns true for live and frozen refs at the same path\") | 113 |\n| Equality helpers | | `refEqual` is `false` for refs at different paths | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L38) | 114 |\n| Equality helpers | | `refEqual(sandboxRef, foreignRef)` throws `TypeError` — references not created by this sandbox mirror are unrecognized | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L840) (foreign refs throw unrecognized-reference TypeError) | 115 |\n| Equality helpers | | `queryEqual(a, b)` structurally compares collection and collection-group scope, filter/order/limit/cursor constraint structure, converter identity, and construction-time Firestore value snapshots for both equality and execution without re-observing caller objects. Snapshots cover maps/arrays, scalar objects, references (including raw and converted `addDoc()` results), and vectors; normalize Date to Timestamp; preserve the distinction between -0 and 0; compare snapshot and explicit bounds for all four cursor overloads by value without invoking snapshot converters, including across live-target rebuilds; reject undefined, bigint, nested arrays for ordinary and array-membership operands, and recursively nested cross-database references during query construction; and preserve Firebase's nested-array allowance for `in`/`not-in` candidate lists. | ✓ | Oracle-locked by [`packages/conformance/observations/firestore/firestore-queryequal-structural.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-queryequal-structural.json): collection and collection-group scope, order sequence/direction, limits, composite filters, cursor values/inclusivity, converters, and Firestore operands all distinguish equal from changed queries as production does. The capture executes mutable maps, Timestamp, Bytes, GeoPoint, DocumentReference (including raw and converted `addDoc()` results), and Vector queries; proves Bytes/Vector source-array mutation cannot change existing or new queries; proves all four snapshot cursor overloads compare with explicit bounds and execute twice without invoking a consumer converter; and locks recursive foreign-reference rejection, Date/Timestamp normalization, and -0 handling. [`firestore-query-nested-array-validation.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-query-nested-array-validation.json) separately pins the production SDK's client-side rejection of nested arrays without implying a cloud round trip. The replay forces live-target identity rebuilds for every snapshot cursor overload. `oracle:firestore#116` replays every claimed fact. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L1047), [`equality.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/equality.test.ts#L14)) | 116 |\n| Equality helpers | | `snapshotEqual(a, b)` follows production snapshot state. A query's first one-shot read differs from the next identical read, then repeated settled reads (including an independently built equivalent query) compare structurally until query state changes and settles again. Listener snapshots compare query, documents, changes, and metadata. Document snapshots compare path, existence, Firestore-typed data, converter identity, and snapshot kind, so two gets of the same document are equal while a query-child snapshot and a direct get are distinct; scalar-shaped plain maps remain distinct from Timestamp, reference, GeoPoint, and Vector values. | ✓ | Oracle-locked by [`packages/conformance/observations/firestore/firestore-snapshotequal-structural.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-snapshotequal-structural.json): sequential and equivalent-query reads prove the initial-to-settled transition; a changed result restarts that transition; listener sensitivity cases vary query, documents/change history, and metadata-only state; direct document cases vary path, existence, data, converter, and snapshot kind; and four collision probes distinguish scalar-shaped maps from their Timestamp, reference, GeoPoint, and Vector counterparts. `oracle:firestore#117` replays every reachable fact. JSON reconstruction remains raw characterization only because Pyric does not expose that separate API. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L1454), [`equality.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/equality.test.ts#L16)) | 117 |\n| Equality helpers | | Cross-flavor `refEqual` via `QuerySnapshot.docs[i].ref` works | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L38) (\"cross-flavor refEqual via QuerySnapshot doc refs\") | 118 |\n\n## `connectFirestoreEmulator`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| connectFirestoreEmulator | | No-op on sandbox-target handles (the sandbox already IS a local emulator) | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L861) | 119 |\n| connectFirestoreEmulator | | Production does not enter the mirror: inactive package resolution leaves Firebase's `connectFirestoreEmulator` implementation unchanged | ✓ | [`node-register:register-child.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/register/register-child.test.ts#L1) (inactive canonical Firestore is not rewritten) | 120 |\n| connectFirestoreEmulator | | The sandbox mirror accepts Firebase's `mockUserToken` option shape as an inert compatibility argument; production uses Firebase's untouched implementation | ✓ | type-only smoke | 121 |\n\n## Offline / persistence / network family\n\n## Offline / persistence / network family (continued)\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| enableIndexedDbPersistence | | Resolves before Firestore starts and rejects with `failed-precondition` after any operation has started the service | ✓ | Real Chromium: [`firestore-browser-lifecycle.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-browser-lifecycle.json); replayed through the public modular API by [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L653) and focused lifecycle coverage. (Structured evidence: [`persistence-network.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/persistence-network.test.ts#L7)) | 140 |\n| enableMultiTabIndexedDbPersistence | | Enables multi-client shared persistence before first use, permits two clients to opt in, and rejects initialization after a client has started with `failed-precondition` | ✓ | Real Chromium: [`firestore-browser-lifecycle.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-browser-lifecycle.json) exercises fresh, after-use, and two-client initialization; replayed through public APIs by [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L653) and focused persistence tests. (Structured evidence: [`persistence-network.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/persistence-network.test.ts#L14)) | 141 |\n| clearIndexedDbPersistence | | Maps to `Sandbox.clearPersistence()` — actually wipes the persisted blob (honest, not a no-op); already a no-op when persistence was never enabled | ✓ | [`unit:firestore/persistence-network.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/persistence-network.test.ts#L14) | 142 |\n| enableNetwork / disableNetwork | | Disabling network exposes local mutations with cache/pending-write metadata while holding write acknowledgement until network is re-enabled | ✓ | Real Chromium: [`firestore-browser-lifecycle.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-browser-lifecycle.json); replayed through the public modular API by [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L653) and focused network coverage. (Structured evidence: [`persistence-network.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/persistence-network.test.ts#L9)) | 143 |\n| waitForPendingWrites | | Waits for outstanding offline write acknowledgements and resolves when network re-enable drains them | ✓ | Real Chromium: [`firestore-browser-lifecycle.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-browser-lifecycle.json); replayed through the public modular API by [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L653) and focused pending-write coverage. (Structured evidence: [`persistence-network.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/persistence-network.test.ts#L11)) | 144 |\n| terminate | | Terminates only the selected Firestore service instance: held refs reject with `failed-precondition`, owned listeners stop, and sibling Firestore or Auth services remain usable | ✓ | Real Chromium: [`packages/conformance/observations/firestore/firestore-browser-lifecycle.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-browser-lifecycle.json) records `failed-precondition` on the terminated instance while Auth and a sibling Firestore instance continue. Replayed by [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L653); focused ownership coverage in [`terminate.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/terminate.test.ts#L2). | 152 |\n\n## Tier-1 cache-init + get-from-* family\n\n## Tier-1 cache-init + get-from-* family (continued)\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| initializeFirestore | | Delegates to sandbox `getFirestore(app)` and returns the same handle. Accepts the `settings` argument (so the explicit-init pattern doesn't crash at import) but no-ops the cache/network settings — persistence is always on | ⚠ settings accepted but cache/network settings are no-ops | [`unit:firestore/tier1-cache-init-align.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/tier1-cache-init-align.test.ts#L7) | 145 |\n| persistentLocalCache / memoryLocalCache / persistentSingleTabManager / persistentMultipleTabManager / memoryEagerGarbageCollector / memoryLruGarbageCollector | | Config token accepted, inert — each returns a small tagged object so identity/usage doesn't crash. Persistence is the sandbox default; there is no cache tier left to configure | ⚠ inert config tokens; no cache tier to configure | [`unit:firestore/tier1-cache-init-align.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/tier1-cache-init-align.test.ts#L8) | 146 |\n| getDocFromServer / getDocsFromServer | | Delegates to `getDoc` / `getDocs` in the sandbox mirror — the sandbox store IS the authoritative source, so there is no separate server round-trip to force and no observable divergence from the default read | ✓ | [`unit:firestore/tier1-cache-init-align.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/tier1-cache-init-align.test.ts#L12) | 147 |\n| getDocFromCache / getDocsFromCache | | Explicit cache reads distinguish cold and warm state: cold document reads reject with `unavailable`, cold queries return empty, and server/default reads populate subsequent cache reads | ✓ | Real Chromium: [`firestore-browser-lifecycle.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-browser-lifecycle.json) captures cold and warm document and query cache behavior; [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L653) replays it through public APIs. (Structured evidence: [`tier1-cache-init-align.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/tier1-cache-init-align.test.ts#L13)) | 148 |\n| setLogLevel | | Accepted no-op — the sandbox has no modular-SDK-style logger to wire a level into; it uses host-level `console` logging directly, gated by `pyric dev`'s own flags, not this call | ⚠ accepted no-op; no sandbox logger wired | [`unit:firestore/tier1-cache-init-align.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/tier1-cache-init-align.test.ts#L13) | 149 |\n| onSnapshotsInSync | | Emits an initial in-sync signal, batches another signal after snapshot listener delivery, and stops after unsubscribe | ✓ | Real Chromium: [`firestore-browser-lifecycle.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-browser-lifecycle.json) records initial sync, write snapshot, then sync; replayed by [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L653) and focused listener tests. (Structured evidence: [`tier1-cache-init-align.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/tier1-cache-init-align.test.ts#L14)) | 150 |\n| Runtime ES Classes / Constructor Tokens | | Exports ES classes and constructor tokens matching structural type handles for instanceof checks and prototype inheritance | ✓ | `unit:firestore/tier1-cache-init-align.test.ts` (Structured evidence: [`runtime-classes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/runtime-classes.test.ts#L5)) | 151 |\n| PersistentCacheIndexManager / Index auto-creation | | Config tokens and index manager controller accepted as honest inert runtime tokens without failing execution | ✓ | `unit:firestore/tier1-cache-init-align.test.ts` (Structured evidence: [`persistence-tokens.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/persistence-tokens.test.ts#L5)) | 157 |\n| loadBundle / namedQuery | | Decodes string/ArrayBuffer bundle payloads into local sandbox store and registers named queries | ✓ | `unit:firestore/tier1-cache-init-align.test.ts` (Structured evidence: [`bundles.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/bundles.test.ts#L5)) | 153 |\n| documentSnapshotFromJSON / querySnapshotFromJSON / onSnapshotResume | | Deserializes JSON snapshot objects into active Pyric snapshot wrappers and attaches resume listener | ✓ | `unit:firestore/tier1-cache-init-align.test.ts` (Structured evidence: [`ssr-snapshots.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/ssr-snapshots.test.ts#L5)) | 154 |\n| aggregateFieldEqual / aggregateQuerySnapshotEqual | | Compares aggregate descriptors and snapshot data for value equality | ✓ | `unit:firestore/tier1-cache-init-align.test.ts` (Structured evidence: [`runtime-classes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/runtime-classes.test.ts#L50)) | 155 |\n| ensureFirestoreConfigured / executeWrite | | Validates sandbox initialization and executes write callback within mutation evaluation pipeline | ✓ | `unit:firestore/tier1-cache-init-align.test.ts` (Structured evidence: [`writes-plumbing.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/writes-plumbing.test.ts#L5)) | 156 |\n\n## Rules engine (via `setRules` from `pyric/sandbox/firestore`)\n\nRules-engine behavior is technically `pyric-admin`'s `LocalEnvironment`,\nbut it's the most-tested surface for divergence — `request.auth`,\ncross-doc reads via `get()`, data validation. These rows pin the\nshape consumer code depends on.\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | `request.auth.uid` reads through to `sandbox.currentUser?.uid` on sandbox-live | ✓ | `playground:auth-anonymous`, `playground:rules-cross-doc-get` | 127 |\n| | | `request.auth == null` when sandbox.currentUser is null (anonymous path) | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L23) (\"anonymous fallback\") | 128 |\n| | | Cross-doc `get(/databases/$(database)/documents/...)` in rules works under sandbox; `get()` of a **missing** doc ERRORS (guard with `exists()`), and `get(p).id` / `get(p).__name__` expose the doc identity (RULES-B8) | ✓ | `playground:rules-cross-doc-get`, [`unit:rules/simulator/evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/evaluator.test.ts#L140) (RULES-B8 block) | 129 |\n| | | `request.resource.data.<field>` field validation in rules works under sandbox; an **undefined** field read ERRORS (deny), it does NOT read as null (RULES-B2) — guard with `'f' in data` / `data.get('f', d)` | ✓ | `playground:rules-data-validation`, [`unit:rules/simulator/evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/evaluator.test.ts#L140) (RULES-B2 block) | 130 |\n| | | `resource.data.<field>` (existing doc on writes) works under sandbox; undefined-field reads ERROR (RULES-B2) | ✓ | `playground:rules-resource-data-field`, [`unit:rules/simulator/evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/evaluator.test.ts#L140) (RULES-B2 block) | 131 |\n| | | Custom claims in `request.auth.token.<claim>` | ✓ | `playground:rules-custom-claims` | 132 |\n| | | Tri-state error semantics: DOTTED field access of a missing key (`resource.data.typo`), access on null/undefined, undefined variables, and `get()`-of-missing ERROR → deny; `&&`/`\\|\\|` absorb operand errors **commutatively** (CEL: `error \\|\\| true` → true, `error && false` → false). NOTE: DYNAMIC index access `data[expr]` stays null-on-miss (the documented may-be-absent-lookup idiom; only dotted access is doc-confirmed to error). (RULES-B2/B3/B8) | ✓ | [`unit:rules/simulator/evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/evaluator.test.ts#L140) (RULES-B2 / RULES-B3 / RULES-B8 blocks) | 133 |\n| | | `matches()` is a **full-string** anchored RE2 test; `replace()`/`split()` take regexes (`replace` = all occurrences) (RULES-B4) | ✓ | [`unit:rules/simulator/evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/evaluator.test.ts#L140) (RULES-B4 block) | 134 |\n| | | No JS prototype-chain leakage: `'toString' in data` → false, `data.constructor` errors; `in`/`hasAll`/`get` use own keys only (RULES-B7) | ✓ | [`unit:rules/simulator/evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/evaluator.test.ts#L140) (RULES-B7 block) | 135 |\n| | | Type-strict operators: `+` requires matching operand types (`'a' + 1` errors; `[1]+[2]` concatenates); ordered compares (`< > <= >=`) error across types; list membership uses value equality; `is map` excludes MapDiff/Set (RULES-B6 partial / B9 / B12 partial) | ✓ | [`unit:rules/simulator/evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/evaluator.test.ts#L140) (RULES-B6 / B9 / B12 blocks) | 136 |\n| | | `FirestoreSet` VALUE equality: `diff.addedKeys() == [uid].toSet()` compares set contents (order-insensitive); `set == list` is false, not an error (RULES-B13). Pre-fix, ANY two sets compared EQUAL (generic-object deep-equals saw no enumerable keys) — a false-PERMISSIVE divergence found by joining validation | ✓ | [`unit:rules/simulator/set-equality.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/set-equality.test.ts#L6); live validation: 10/10 both engines | 136b |\n| | | `update` exposes `request.resource.data` / `getAfter()` as the existing doc **merged** with the payload via the `writeMode: { kind: 'update' }` path (the agent-facing `simulate()` opt-in); a sparse no-writeMode payload that drops a field now ERRORS on that field (RULES-B2) rather than silently reading null (RULES-B10) | ✓ | [`unit:rules/simulator/handler.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/handler.test.ts#L2) (RULES-B10 block) | 137 |\n| | | Int/float distinction (`1.5 is int`→false, `1 is float`→false, `1.0 is float`→true) + integer division (`10 / 4 == 2`) + int div/mod-by-zero ERRORS (RULES-B5); strict `int('12abc')`/`float('abc')`/`bool('false')`/`bool('yes')` parsing (RULES-B6 rest); `string(1.0)`→\"1.0\" (RULES-B12 rest) | ✓ | [`unit:rules/simulator/evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/evaluator.test.ts#L140) (RULES-B5 + \"RULES-B6 remainder\" blocks); [`unit:rules/simulator/handler.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/handler.test.ts#L1089) (\"RULES-B5 end-to-end\" block) | 138 |\n| | | Strict boolean control flow matches production: non-booleans in `&&`, `\\|\\|`, or a ternary condition error and deny. Firestore double payloads retain float identity in the simulator (fractional JSON numbers are revived directly; `{ __type:'float', value }` preserves an explicitly tagged double). On create, `resource == null` denies while `request.resource` exposes the incoming document. | ✓ | Production behavior is recorded by Firestore Rules rows `firestore-rules#188`, `firestore-rules#167`, and `firestore-rules#166`. Direct evaluator/handler tests guard strict operands, fractional-number revival, the explicit float tag, and create-time resource verdicts on the SDK-facing simulator path. (Structured evidence: [`evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/evaluator.test.ts#L140), [`handler.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/handler.test.ts#L1089)) | 138a |\n| | | Query-proof EVALUATION — the rules-side decision (\"rules are not filters\"): given a `list` rule + query constraints, decide provable-or-reject (a doc-dependent rule like `resource.data.visibility == 'public'` is provable ONLY with a matching `where('visibility','==','public')`; otherwise the whole query is rejected) (RULES-B11 rules-side) | ✓ | [`unit:rules/simulator/query-proof.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/query-proof.test.ts#L9) | 139 |\n| | | Query-proof ENFORCEMENT wiring — `silentReadCollection` + `readQueryCandidates` call `evaluateQueryProof` (via `sandbox/firestore/list-query-proof.ts`) instead of the per-doc silent-omission filter; structured `where`/`limit`/`orderBy` constraints are threaded from `QueryImpl.structuredConstraints()` through both the one-shot (`getDocs`/aggregate) and listener (`SnapshotTarget` applier `.structured`) paths, and `request.query.{limit,offset,orderBy}` is populated on list test cases (RULES-B11 cross-file) | ✓ | [`unit:firestore/query-proof-enforcement.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/query-proof-enforcement.test.ts#L29) (both paths; verified failing pre-fix); prover scope caveat: row 24c | 139a |\n\n## Current gaps\n\n### Documented divergences\n\nKnown differences between Pyric and production Firebase. Each remains tracked as a non-conforming row.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">getDocs(query)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Query-proof **prover scope is conservative, with full accounting and a fail-closed classifier** — user functions are inlined (nested helper-calling-helper and multi-parameter helpers included, matching production's rules-function inlining), and a <code>list</code> rule is provable only when its **entire** doc-dependence reduces to top-level AND-conjunct <code>resource.data.<field> == <literal></code> equalities (with <code>request.auth.uid</code> pinned to the caller) each discharged by a <code>where(field, '==', value)</code> filter. Doc-dependence is decided by a fail-closed classifier: an expression counts as doc-independent only when every node is positively recognized as such — any touch of the <code>resource</code> root in any syntactic form (<code>resource.data</code>, <code>resource['data']</code>, slices, <code>resource.id</code>, path-literal segments feeding <code>exists</code>/<code>get</code> lookups) and any unrecognized node shape classifies doc-dependent. Any doc-dependent conjunct that is not a discharged equality — disjunctions over doc data, inequality/range predicates (<code>resource.data.score > 10</code> + <code>where('score','>',10)</code>), <code>in</code> / negated-<code>in</code> membership checks, <code>get(key, default)</code>, <code>keys().hasOnly(...)</code>, <code>is</code> type checks, nested-path predicates, data-keyed document lookups — makes the whole query conservatively DENY, even when every equality conjunct is discharged; production's prover may allow some of these. Divergence is deny-only: mixed equality + non-equality shapes are rejected up front rather than trusted to a residual evaluation that absent-tolerant predicates could pass vacuously.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/query-proof.test.ts#L7\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:rules/simulator/query-proof.test.ts</code></a> (function-inlining provable cases; full-accounting rejections for mixed equality + <code>in</code>/<code>get</code>/<code>hasOnly</code>/range/<code>is</code> shapes in inline and helper form; fail-closed classifier probes: bracket-access <code>resource['data']</code>, <code>exists()</code> keyed by <code>resource.data</code> through path-literal segments, slice access, <code>resource.id</code>, and an unrecognized node shape), <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/query-proof-enforcement.test.ts#L19\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:firestore/query-proof-enforcement.test.ts</code></a> (seeded end-to-end denials of the demonstrated attacks: forbidden-field absence check in member and bracket form, banned-owner <code>exists()</code> lookup, slice conjunct — each returning zero documents)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">Query construction</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Index validation against <code>firestore.indexes.json</code> — sandbox uses <code>LocalEnvironment</code>'s lint pass; prod has its own server-side validation</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence: sandbox can mis-pass a query that prod would reject at the server with <code>failed-precondition</code> if no index exists</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">Aggregates</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Aggregates count documents server-side without paying read cost per doc in prod; sandbox computes locally (no cost model)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence: cost behavior differs, observable shape identical. Oracle-locked: <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-count-aggregate-shape.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>packages/conformance/observations/firestore/firestore-count-aggregate-shape.json</code></a> — <code>getCountFromServer().data()</code> returns <code>{ count: <number> }</code> (single key, no other fields). Empty query returns <code>count: 0</code> (not <code>null</code>/<code>undefined</code>); seeded 3 docs returns <code>count: 3</code>; filtered query honors the <code>where</code> constraint (<code>count: 2</code>).</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">initializeFirestore</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Delegates to sandbox <code>getFirestore(app)</code> and returns the same handle. Accepts the <code>settings</code> argument (so the explicit-init pattern doesn't crash at import) but no-ops the cache/network settings — persistence is always on</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/tier1-cache-init-align.test.ts#L7\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:firestore/tier1-cache-init-align.test.ts</code></a></div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">persistentLocalCache / memoryLocalCache / persistentSingleTabManager / persistentMultipleTabManager / memoryEagerGarbageCollector / memoryLruGarbageCollector</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Config token accepted, inert — each returns a small tagged object so identity/usage doesn't crash. Persistence is the sandbox default; there is no cache tier left to configure</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/tier1-cache-init-align.test.ts#L8\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:firestore/tier1-cache-init-align.test.ts</code></a></div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">setLogLevel</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Accepted no-op — the sandbox has no modular-SDK-style logger to wire a level into; it uses host-level <code>console</code> logging directly, gated by <code>pyric dev</code>'s own flags, not this call</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/tier1-cache-init-align.test.ts#L13\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:firestore/tier1-cache-init-align.test.ts</code></a></div></div></details>\n</div>\n" }, { "slug": "database-compat", "label": "Realtime Database", "title": "pyric/database compatibility matrix", "markdown": "<!-- Generated from the conformance model (registry rows + surface contracts). Do not edit by hand; run bun run compat:generate. -->\n\n# `pyric/database` compatibility matrix\n\n<div class=\"compat-stat\">\n<p class=\"compat-stat-figure\">\n<span class=\"compat-stat-pct\">100%</span>\n<span class=\"compat-stat-label\">of the public API supported</span>\n</p>\n<div class=\"compat-meters\">\n<span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 100%\"></span></span>\n</div>\n<p class=\"compat-stat-denom\">59 of 59 public API</p>\n</div>\n\n\n## Status legend\n\n| Status | Meaning |\n|---|---|\n| ✓ | **Conforming** — observable behavior matches Firebase, locked by a passing probe |\n| ⚠ | **Diverged (documented)** — intentional difference with a written reason |\n\n## Public API\n\n`pyric/database` mirrors the public `firebase/database` API in the Pyric sandbox. The rows below compare its observable behaviour with Firebase.\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | `getDatabase(ctx)` builds a sandbox-target `Database`; frozen `ctx.auth` baked in | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L11) (\"getDatabase(ctx) returns a tagged Database handle\") | M1 |\n| | | `getDatabase(sandbox)` builds a sandbox-live target; reads `sandbox.currentUser` per op | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L11) (\"reads sandbox.currentUser at op time, not at getDatabase time\") | M2 |\n| | | `ref(db, path?)` returns a path-tagged `DatabaseReference`; default is root | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L12) (\"ref(db) returns a root ref\" + \"ref(db, ...) returns a path ref\") | M4 |\n| | | `child(ref, 'sub/path')` composes paths; result inherits the parent's target | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L12) (\"child(ref, 'sub') composes paths\") | M5 |\n| | | `ref.parent` returns the parent ref; `root.parent === null` | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L12) (\"ref.parent returns the parent ref; root.parent is null\") | M6 |\n| | | `ref.root` returns the root ref of the same target | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L12) (\"ref.root returns the root ref\") | M7 |\n| | | `get(ref)` returns a `DataSnapshot`-shaped object with `val()`, `exists()`, `key`, `child()`, `hasChildren()`, `size`, and `toJSON()`; the modular Web surface does not expose `numChildren()`. | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L2) (snapshot shape tests) | M8 |\n| | | `get` on an absent path resolves to `{ val: null, exists: false }` (matches `DataSnapshot.val()` contract) | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L2) (\"reads return null for an absent path\") | M9 |\n| | | `set(ref, value)` replaces the value at the path | ✓ | Sandbox aligned: [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L119) (\"round-trips a primitive value\" + \"round-trips nested objects\"); matches oracle observation [`packages/conformance/observations/rtdb/rtdb-set-then-get-roundtrip.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-set-then-get-roundtrip.json) (prod observation blocked on rules; sandbox locks the contract directly) | M10 |\n| | | `set(ref, null)` deletes the subtree at the path | ✓ | Sandbox aligned: [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L154) (\"set(ref, null) deletes the path\"); matches oracle observation [`packages/conformance/observations/rtdb/rtdb-remove-vs-set-null.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-remove-vs-set-null.json) | M11 |\n| | | `remove(ref)` is equivalent to `set(ref, null)` (same end state) | ✓ | Sandbox aligned: [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L154) (\"remove and set(null) produce identical end-state\"); matches oracle observation [`packages/conformance/observations/rtdb/rtdb-remove-vs-set-null.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-remove-vs-set-null.json) | M12 |\n| | | `update(ref, patch)` shallow-merges top-level keys at the ref's path | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L12) (\"shallow-merges top-level keys\") | M13 |\n| | | `null` value in a shallow update deletes that key | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L16) (\"null values in a shallow update delete the key\") | M14 |\n| | | `update(rootRef, { '/a/x': v1, '/b/y': v2 })` is a multi-path atomic write — all paths land or none do | ✓ | [`unit:upstream-rtdb-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/upstream-rtdb-probes.test.ts#L10) (\"one update nulls, mutates, and displaces within a limitToFirst window\") + [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L16) (atomic multipath + rules denial); matches the matrix #23 prod contract | M15 |\n| | | Overlapping multi-path updates (one path is a descendant of another) reject before any path is written | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L16) (\"rejects overlapping paths\") | M16 |\n| | | `push(ref)` mints a 20-char auto-id key starting with `-`, lexicographically sortable | ✓ | Sandbox aligned: [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L340) (\"mints 20-char keys starting with \\\"-\\\"\" + \"sequential push keys are lex-sortable\"); matches oracle observation [`packages/conformance/observations/rtdb/rtdb-push-autoid-format.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-push-autoid-format.json) | M17 |\n| | | `push(ref, value)` writes `value` at the new child path | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L12) (\"push(ref, value) writes the value at the new child path\") | M18 |\n| | | `pushKey()` mints a fresh push-shaped key without writing — used by callers building multi-path updates that need the key first | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L19) (\"pushKey() mints a fresh key without writing\") | M19 |\n| | | `serverTimestamp()` returns the `{ \".sv\": \"timestamp\" }` sentinel marker the wire encoder recognises | ✓ | Sandbox aligned: [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L21) (\"serverTimestamp() returns the documented shape\"); matches the prod wire contract | M20 |\n| | | `serverTimestamp()` resolves to a number (epoch ms) on read-back | ✓ | Sandbox aligned: [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L376) (\"resolves to a number on read-back\"); matches oracle observation [`packages/conformance/observations/rtdb/rtdb-servertimestamp-resolves.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-servertimestamp-resolves.json) (prod observation blocked on rules; sandbox locks the contract directly) | M21 |\n| | | `serverTimestamp()` sentinels resolve when nested inside multi-path update payloads | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L16) (\"resolves sentinels nested deep inside an update payload\") | M22 |\n| | | Rules-denied write throws a plain `Error` (NOT a `FirebaseError`) with `.code === 'PERMISSION_DENIED'` (uppercase snake-case) and `.message === 'PERMISSION_DENIED: Permission denied'` | ✓ | Sandbox aligned: [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L407) (\"rules-denied set throws a plain Error with PERMISSION_DENIED code\"); matches oracle observation [`packages/conformance/observations/rtdb/rtdb-rules-denied-error-code.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-rules-denied-error-code.json) (against blockingfun, fb-js-sdk 12.13.0) | M23 |\n| | | Rules-denied read throws the same plain-`Error` `PERMISSION_DENIED` shape as a denied write | ✓ | Sandbox aligned: [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L407) (\"rules-denied get throws the same plain Error shape\"); matches oracle observation [`packages/conformance/observations/rtdb/rtdb-rules-denied-error-code.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-rules-denied-error-code.json) | M24 |\n| | | Rules-denied remove throws the same plain-`Error` `PERMISSION_DENIED` shape | ✓ | Sandbox aligned: [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L18) (\"rules-denied remove throws the same plain Error shape\") | M25 |\n| | | `onValue(ref, cb)` fires immediately on subscribe with the current value at the path | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L12) (\"fires on subscribe with the current value\") | M26 |\n| | | `onValue` fires again after every write that CHANGES the value at the watched path; a write that leaves the watched subtree byte-identical (a no-change re-write, or an ancestor/descendant write that doesn't alter this path) is suppressed (DB-B8) | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L22) (\"fires after every write that touches the watched path\") + [`unit:modular/no-change-suppression.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/no-change-suppression.test.ts#L17) (\"re-writing the same value does NOT re-fire\" + \"ancestor write leaving the subtree unchanged does NOT fire\") | M27 |\n| | | `onValue` fires after a descendant write (the listener sees subtree changes) | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L22) (\"fires on a descendant write\") | M28 |\n| | | `onValue` initial-fire for an absent path delivers `val=null, exists=false` (matches matrix expectation locked by oracle for sentinel/listener shape) | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L22) (\"absent path: initial fire delivers val=null, exists=false\") | M29 |\n| | | The `onValue` return value is an unsubscribe function; calling it stops further fires | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L22) (\"fires after every write that touches the watched path\" — checks unsubscribed listener doesn't fire on subsequent write) | M30 |\n| | | `onChildAdded` / `onChildChanged` / `onChildRemoved` / `onChildMoved` — plain-ref subscription surface | ✓ | [`unit:modular/sandbox-child-events.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-child-events.test.ts#L14) exercises all four plain-reference child listener registrars; M41–M48 own their per-event oracle claims. | M31 |\n| | | `onChildAdded` replays each existing direct child of the parent ref on subscribe (one fire per existing key) | ✓ | Sandbox aligned: [`unit:modular/sandbox-child-events.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-child-events.test.ts#L40) (\"replays existing direct children on subscribe — one fire per key\"); matches oracle observation [`packages/conformance/observations/rtdb-modular/rtdb-modular-onchildadded-initial-replay.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onchildadded-initial-replay.json) (seeded `{k1,k2,k3}`, observed `firedKeys: ['k1','k2','k3']`). | M41 |\n| | | After subscribe, `onChildAdded` fires exactly once per new direct child write; snapshot carries `{key, val}` of the new child | ✓ | Sandbox aligned: [`unit:modular/sandbox-child-events.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-child-events.test.ts#L66) (\"fires exactly once per NEW direct child after subscribe\"); matches oracle observation [`packages/conformance/observations/rtdb-modular/rtdb-modular-onchildadded-post-subscribe.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onchildadded-post-subscribe.json) (`postSubscribeFires: 1`, `lastFire: {key:'k3', val:{v:3}}`). | M42 |\n| | | `onChildChanged` has NO initial replay; fires once when an existing direct child's value transitions; snapshot carries the NEW value | ✓ | Sandbox aligned: [`unit:modular/sandbox-child-events.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-child-events.test.ts#L99) (\"does NOT fire on subscribe (no initial replay)\" + \"fires once when an existing child transitions to a new value; snapshot carries NEW val\"); matches oracle observation [`packages/conformance/observations/rtdb-modular/rtdb-modular-onchildchanged-fires-on-update.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onchildchanged-fires-on-update.json) (`firedOnInitial: 0, firedOnUpdate: 1, lastFire: {key:'k1', val:{v:2}}`). | M43 |\n| | | `onChildChanged` does NOT fire for added or removed children — those go to the other event listeners | ✓ | Sandbox aligned: [`unit:modular/sandbox-child-events.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-child-events.test.ts#L19) (\"does NOT fire when a child is added\" + \"does NOT fire when a child is removed\"). | M44 |\n| | | `onChildRemoved` has NO initial replay; fires once when a direct child is deleted (via `remove(child)` or `set(child, null)`); snapshot carries the PRIOR (now-removed) value | ✓ | [`unit:upstream-rtdb-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/upstream-rtdb-probes.test.ts#L8) (parent wipe fan-out via remove(parent) / set(parent, scalar)) + [`unit:modular/sandbox-child-events.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-child-events.test.ts#L143) (single-child delete carries PRIOR val); matches oracle [`rtdb-modular-onchildremoved-fires-on-delete.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onchildremoved-fires-on-delete.json) | M45 |\n| | | A plain-reference `onChildMoved` uses Firebase's default priority index: ordinary value changes that preserve priority do not move, while each changed child's priority emits one event even if its predecessor stays the same | ✓ | Oracle [`rtdb-modular-priority-contract`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-priority-contract.json) captures the same one-event-per-priority-operation sequence on a plain ref and an explicit `orderByPriority()` query, including a priority change that retains the same predecessor; [`unit:modular/sandbox-child-events.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-child-events.test.ts#L14) separately pins no movement for an ordinary value-only change. (Structured evidence: [`priority-metadata-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/priority-metadata-cdd.test.ts#L20)) | M46 |\n| | | `off(ref)` (no event type) removes ALL listeners at that ref — value + every child event variety | ✓ | Sandbox aligned: [`unit:modular/sandbox-child-events.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-child-events.test.ts#L338) (\"off(ref) removes ALL listeners at the ref\" + \"off(ref) also removes value listeners at the same path\"); matches oracle observation [`packages/conformance/observations/rtdb-modular/rtdb-modular-off-stops-child-fires.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-off-stops-child-fires.json) (`postOffFires: 0`). | M47 |\n| | | `off(ref, eventType?, callback?)` variants: `off(ref, 'value')` / `off(ref, 'child_added')` / `off(ref, eventType, cb)` remove the targeted subset; returned-unsubscribe from `onChild*` is equivalent to `off(ref, eventType, cb)` | ✓ | Sandbox aligned: [`unit:modular/sandbox-child-events.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-child-events.test.ts#L14) (\"off(ref, \\\"child_added\\\") removes only that event variety\" + \"off(ref, \\\"value\\\") removes only value listeners\" + \"off(ref, eventType, cb) removes only the matching callback\" + \"returned-unsubscribe from onChildAdded is functionally equivalent to off()\"). | M48 |\n| | | `connectDatabaseEmulator(db, host, port)` is a no-op on sandbox targets (the sandbox IS a local emulator) | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L23) (\"is a no-op on sandbox handles\") | M32 |\n| | | `sandbox.setRules(db, rulesJson)` deploys rules to the in-process simulator; `setRules(db, null)` clears rules (default-allow) | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L35) (\"sandbox.setRules(db, null) clears rules\") | M33 |\n| | | `sandbox.setData(db, { '/path': value })` bulk-loads data, bypassing rules | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L585) (\"sandbox.setData seeds the tree (rule-bypass)\") | M34 |\n| | | `sandbox.snapshotState(db)` dumps the full tree as a plain JSON object | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L51) (\"sandbox.snapshotState dumps the full tree\") | M35 |\n| | | `query(ref, ...constraints)` + ordering/range constraints | ✓ | [`unit:upstream-rtdb-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/upstream-rtdb-probes.test.ts#L4) (\"orderByChild('a/b') + limitToFirst orders by the nested path\") + [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L15) + oracle observations under `packages/conformance/observations/rtdb-modular/`; see M49–M64 for the per-claim breakdown. | M36 |\n| | | `runTransaction(ref, fn, options?)` resolves to `{ committed: boolean, snapshot: DataSnapshot }` for the happy path — the update fn return value is written, committed is `true`, and `snapshot.val()` reflects the committed value | ✓ | Sandbox aligned: [`unit:modular/transaction.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction.test.ts#L37) (\"resolves to { committed: boolean, snapshot } with the committed value\"); matches oracle observations [`packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-success.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-success.json) + `rtdb-modular-runtransaction-returns-committed-snapshot.json` (against blockingfun, fb-js-sdk 12.13.0) (Structured evidence: [`rtdb-modular-runtransaction-warm-client-speculation`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-warm-client-speculation.json)) | M37 |\n| | | Returning `undefined` from the update fn ABORTS the transaction — resolves `{ committed: false, snapshot }`; no write performed, no listener fan-out | ✓ | Sandbox aligned: [`unit:modular/transaction.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction.test.ts#L61) (\"returning undefined aborts — committed: false, no write\" + \"aborted transaction does NOT fan out to listeners\"); matches oracle observation [`packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-abort-undefined.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-abort-undefined.json) — known divergence: prod's `result.snapshot.val()` reflects the CLIENT's pre-fetch (often `null` even when the server has a value because the speculative invocation runs before the server snap arrives); the sandbox returns the actual pre-transaction value at the path (more useful in single-client harness). The agreed-upon contract callers should rely on is `committed === false` and unchanged server-side data, NOT the snapshot's `.val()` on the abort path. | M37a |\n| | | Cold production clients may invoke the update fn first with speculative `null` and then with the current seeded value; the always-warm in-process sandbox invokes it once with the current value | ⚠ | Oracle [`rtdb-modular-runtransaction-current-value-arg`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-current-value-arg.json) captures two seeded-path production invocations (`null`, then current); [`unit:modular/oracle-conformance-transactions.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-transactions.test.ts#L52) pins the sandbox's single current-value invocation. The separate warm-client observation matches the sandbox but does not erase the cold-client contract. (Structured evidence: [`transaction.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction.test.ts#L93)) | M37b |\n| | | The update fn arg is a defensive deep clone — mutating it does NOT corrupt the stored tree (matters for code that does `current.count++; return undefined` and expects abort to preserve state) | ✓ | [`unit:modular/transaction.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction.test.ts#L8) (\"mutating the update-fn arg does NOT corrupt the stored tree\") — no separate oracle row (defensive contract; prod behavior is identical because the SDK clones on the wire boundary) | M37c |\n| | | `options.applyLocally` controls whether the in-flight optimistic value fans out to `onValue` listeners — default `true` (apply locally before commit); `false` suppresses intermediate fires so listeners see only the committed value | ✓ | Sandbox aligned: [`unit:modular/transaction.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction.test.ts#L175) (\"applyLocally: true (default) — listener sees initial + committed value\" + \"applyLocally: false — listener sees only the committed value\"); matches oracle observation [`packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-options-applylocally.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-options-applylocally.json) (single-client harness: both branches produce 2 fires (initial + commit) — divergence vs prod's documented multi-client suppression would surface under contention, which the sandbox doesn't model) | M37d |\n| | | Rules-denied transaction rejects with a plain `Error` whose `message === 'permission_denied'` (lowercase) and NO `.code` field — DIFFERENT from `set`/`get`'s `'PERMISSION_DENIED: Permission denied'` shape with uppercase `.code`. | ✓ | Sandbox aligned: [`unit:modular/transaction.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction.test.ts#L13) (\"rejects with a plain Error whose message is \\\"permission_denied\\\"\"); matches oracle observation [`packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-on-rules-denied-path.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-on-rules-denied-path.json) (against blockingfun: `message: 'permission_denied', code: null, constructorName: 'Error'`). | M37e |\n| | | Rules-denied transaction does NOT write — pre-transaction value at the path is preserved through the rejection | ✓ | [`unit:modular/transaction.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction.test.ts#L2) (\"does not write to the path when rules deny\") — locked alongside the M37e shape claim | M37f |\n| | | Committed transaction fans out to `onValue` listeners on the watched path with the new value (default applyLocally behavior) | ✓ | [`unit:modular/transaction.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction.test.ts#L24) (\"committed write fans out to onValue listeners\") | M37g |\n| | | **Divergence:** two ordinary concurrent `runTransaction` calls are serialized by the in-process backend, so their update functions are not retried with Firebase's captured contention counts. A synchronous re-entrant conflicting write does trigger a deterministic retry. | ⚠ | Oracle [`rtdb-modular-concurrent-transforms`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-concurrent-transforms.json) captures invocation counts [2, 3]; [`unit:modular/transaction-contention-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction-contention-cdd.test.ts#L12) pins the sandbox's [1, 1] ordinary-concurrency boundary and separately covers its deterministic re-entrant retry seam. | M37h |\n| | | Identity-aware sandbox-live op routing — sign-in/sign-out via `pyric/auth` is observed by the next RTDB op without re-binding | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L11) (\"reads sandbox.currentUser at op time, not at getDatabase time\") | M38 |\n| | | Backend identity is per-`Sandbox` — two `getDatabase(sandbox)` calls on the same sandbox share data, two on different sandboxes don't | ✓ | [`unit:modular/database-instances-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/database-instances-cdd.test.ts#L3) proves same-sandbox sharing and independent-sandbox isolation. | M39 |\n| | | Sandbox refs carry a stable `key` (last path segment) and `toString()` returning `sandbox://rtdb/<path>` | ✓ | [`unit:modular/oracle-conformance-reference-writes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L1) pins the stable key and local `sandbox://rtdb/<path>` string boundary alongside the production URL observation. | M40 |\n| | | `query(ref, orderByChild(p), startAt(v), endAt(w))` window is BOTH-INCLUSIVE — children whose ordered field === `v` or === `w` are included | ✓ | [`unit:upstream-rtdb-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/upstream-rtdb-probes.test.ts#L4) (deep orderByChild nested path) + [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L65) (\"returns children whose ordered child is within [startAt, endAt] inclusive\"); matches oracle [`rtdb-modular-orderbychild-window.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-orderbychild-window.json) | M49 |\n| | | `orderByKey()` orders children by RTDB `nameCompare` — integer-looking keys sort numerically FIRST (so `['1','2','10']`, not the lexicographic `['1','10','2']`), then non-integer keys lexicographically; `startAt`/`endAt` cursors + the optional `key` tie-breaker use the same order (DB-B4) | ✓ | [`unit:upstream-rtdb-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/upstream-rtdb-probes.test.ts#L22) (INT32 overflow/underflow cursors) + [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L23) + [`unit:modular/name-compare.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/name-compare.test.ts#L19); matches oracle `rtdb-modular-orderbykey-window.json` and upstream `core/util/util.ts:253-276` | M50 |\n| | | Production rejects an unindexed `orderByValue() + limitToFirst(N)` query; the sandbox does not enforce `.indexOn` and returns the N smallest primitive values | ⚠ | Oracle [`rtdb-modular-orderbyvalue-numeric`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-orderbyvalue-numeric.json) captures production's `Index not defined` rejection; [`unit:modular/oracle-conformance-queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L108) pins that rejection beside the sandbox's successful `[10,20,30]` window. (Structured evidence: [`oracle-conformance-queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-queries.test.ts#L51)) | M51 |\n| | | `orderByChild(p) + equalTo(v)` returns ALL children whose field at `p` === `v` — no uniqueness enforced | ✓ | Sandbox aligned: [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L129) (\"returns ALL children whose ordered field === the supplied value\"); matches oracle observation [`packages/conformance/observations/rtdb-modular/rtdb-modular-equalTo-filter.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-equalTo-filter.json) (both 'b'-grouped children returned). | M52 |\n| | | `equalTo` with no matches returns an empty snapshot (`exists() === false`, `size === 0`) | ✓ | [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L32) (\"returns an empty snapshot when nothing matches\") | M53 |\n| | | `limitToFirst(N)` keeps the lowest-ranked N children (post-ordering, pre-filter) | ✓ | Sandbox aligned: [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L158) (\"limitToFirst takes the lowest-ranked window\"); matches oracle observation [`packages/conformance/observations/rtdb-modular/rtdb-modular-limittofirst-vs-limittolast.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-limittofirst-vs-limittolast.json) (firstPositions `[1,2]`). | M54 |\n| | | `limitToLast(N)` keeps the highest-ranked N children | ✓ | Sandbox aligned: [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L158) (\"limitToLast takes the highest-ranked window\"); matches oracle observation [`packages/conformance/observations/rtdb-modular/rtdb-modular-limittofirst-vs-limittolast.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-limittofirst-vs-limittolast.json) (lastPositions `[4,5]`). | M55 |\n| | | `limitToFirst(N)` larger than the result returns the full window (no padding, no throw) | ✓ | [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L33) (\"limitToFirst(N) larger than the result returns the full window\") | M56 |\n| | | `startAfter(v)` and `endBefore(v)` are EXCLUSIVE — the boundary value is dropped from the result | ✓ | Sandbox aligned: [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L197) (\"startAfter + endBefore drop the boundary values\"); matches oracle observation [`packages/conformance/observations/rtdb-modular/rtdb-modular-startafter-endbefore-exclusive.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-startafter-endbefore-exclusive.json) (positions `[3,4]`, cursors 2 + 5 dropped). | M57 |\n| | | `onValue(query, cb)` only fires when the windowed result changes — writes OUTSIDE the window don't re-fire the listener; writes that displace a member DO | ✓ | [`unit:upstream-rtdb-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/upstream-rtdb-probes.test.ts#L4) (\"one update nulls, mutates, and displaces within a limitToFirst window\") + [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L221) (\"fires only when the windowed result changes\"); matches oracle [`rtdb-modular-onvalue-with-query.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onvalue-with-query.json) | M58 |\n| | | `onValue(query)` initial fire delivers an empty window (`size === 0`) when the path is absent | ✓ | [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L20) (\"initial fire on an empty path delivers an empty window\") | M59 |\n| | | `query(query(ref, c1), c2)` composes constraints — chaining folds both into one spec | ✓ | [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L15) (\"query(query(ref, c1), c2) composes constraints\") | M60 |\n| | | Snapshot from a query exposes children via `snap.forEach` in the executor-computed order — NOT necessarily the order `Object.entries(val)` would yield | ✓ | [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L21) (\"forEach visits children in ascending order of the child key\") | M61 |\n| | | `startAt(value, key)` uses `key` as the tie-breaker when multiple children share the same ordered value — children before `key` are dropped, the row at `key` is included (inclusive cursor) | ✓ | [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L28) (\"startAt with key tie-breaker drops earlier same-value children\") | M62 |\n| | | `orderByChild('p')` on children missing the field treats their value as `null` (sorts FIRST per RTDB's type ordering) | ✓ | [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L22) (\"orderByChild on a missing child path treats those children as null\") | M63 |\n| | | `query` on a path holding a primitive (or absent path) returns an empty snapshot — no rows to iterate | ✓ | [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L21) (\"query on a path with primitive value returns no rows\") | M64 |\n| | | Write-boundary normalization (`nodeFromJSON`-equivalent): a value written as an array is stored as an integer-keyed object — `child(ref, '1')` returns the element, `forEach` iterates `0,1,2…` (DB-B2) | ✓ | Sandbox aligned: [`unit:modular/normalization.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/normalization.test.ts#L20) (\"array write is addressable by integer-string child key\" + \"forEach over an array iterates its elements\"); upstream `core/snap/nodeFromJSON.ts:118-128`, `core/snap/ChildrenNode.ts:194-230` | M65 |\n| | | Read-side array coercion: a dense integer-keyed object renders back as an array on `snap.val()` (`allIntegerKeys && maxKey < 2 * numKeys`) (DB-B2) | ✓ | Sandbox aligned: [`unit:modular/normalization.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/normalization.test.ts#L19) (\"a dense integer-keyed object reads back as an array\"); upstream `core/snap/ChildrenNode.ts:196-230` | M66 |\n| | | `null` children and empty objects are pruned at the write boundary — `set(ref, {})` is equivalent to `remove(ref)`; nested `null` collapses empty ancestors (\"empty nodes don't exist\") (DB-B3) | ✓ | Sandbox aligned: [`unit:modular/normalization.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/normalization.test.ts#L20) (\"set(ref, {}) is equivalent to remove\" + \"null children are pruned\"); upstream `core/snap/nodeFromJSON.ts:78-88,122-126` | M67 |\n| | | Write validation: an `undefined` payload, a non-finite number (`NaN`/`±Infinity`), or a key containing a forbidden char (`.`, `#`, `$`, `/`, `[`, `]`, control chars) is rejected with a plain `Error` (DB-B1) | ✓ | Sandbox aligned: [`unit:modular/normalization.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/normalization.test.ts#L23) (\"rejects an undefined payload\" + \"rejects an invalid key\" + \"rejects a non-finite number\"); upstream `core/util/validation.ts:45,58,112-199` | M68 |\n| | | Conflicting query constraints throw synchronously at `query(...)` construction (NOT silent last-win): multiple `orderBy*`, a second `limitToFirst`/`limitToLast`, a second start (`startAt`/`startAfter`/`equalTo`) or end (`endAt`/`endBefore`/`equalTo`) (DB-B5) | ✓ | Sandbox aligned: [`unit:modular/constraint-conflicts.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/constraint-conflicts.test.ts#L4) (5 cases); upstream `api/Reference_impl.ts:160-165,1824-1841,1888-1905,1945-1951,2193-2206` | M69 |\n| | | `push(ref, value?)` returns a `ThenableReference` (a `DatabaseReference` with `.then`/`.catch`). The key + ref are minted CLIENT-SIDE and available synchronously even when the optional value write is rules-denied; the write is deferred onto the promise, so a denial REJECTS the awaited push rather than throwing synchronously and discarding the key (DB-B7) | ✓ | Sandbox aligned: [`unit:modular/push-thenable.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/push-thenable.test.ts#L10) (4 cases); matches oracle [`packages/conformance/observations/rtdb/rtdb-push-autoid-format.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-push-autoid-format.json) (\"available immediately even when the subsequent server write is denied by rules\") + upstream `api/Reference_impl.ts:599-630` | M70 |\n| | | `DataSnapshot` shape: `size` (getter), `priority` (`null` when absent), `exportVal()`, `key`, `ref`, `val()`, `exists()`, `child()`, `hasChild()`, `hasChildren()`, `forEach()`, `toJSON()`. It does NOT ship the legacy namespaced `numChildren()` method (DB-B10) | ✓ | Sandbox aligned: [`unit:modular/snapshot-shape.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/snapshot-shape.test.ts#L9) (\"exposes size/priority/exportVal; NOT numChildren()\"); matches oracle [`packages/conformance/observations/rtdb-modular/rtdb-modular-get-snapshot-shape.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-get-snapshot-shape.json) (`hasSize: true, hasNumChildren: false`) + upstream `api/Reference_impl.ts:288-447`. **Flipped masking tests**: `modular/queries.test.ts` + `modular/sandbox-target.test.ts` asserted `snap.numChildren()` — updated to `snap.size`. | M71 |\n| | | Object-valued children are ORDER-EQUAL — the sort/range tie is broken by key (`nameCompare`), NOT by an invented `JSON.stringify` ordering; a query re-write that only reorders object keys is \"no change\" and doesn't re-fire (DB-B11) | ✓ | Sandbox aligned: [`unit:modular/object-order-equality.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/object-order-equality.test.ts#L16); upstream `core/snap/ChildrenNode.ts:386-400` | M72 |\n| | | A primitive at the ROOT is legal (`set(ref(db), 'hello')`); a subsequent child write replaces the primitive root (\"writes win\") (DB-B13) | ✓ | Sandbox aligned: [`unit:modular/root-primitive.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/root-primitive.test.ts#L2) (2 cases) | M73 |\n| | | `onValue(ref, cb, { onlyOnce: true })` fires once then auto-unsubscribes (DB-B12) | ✓ | Sandbox aligned: [`unit:modular/onvalue-onlyonce.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/onvalue-onlyonce.test.ts#L2); upstream `api/Reference_impl.ts:975-980` | M74 |\n| | | Child-listener callbacks receive Firebase's `previousChildName` second argument, including initial replay and ordered-query movement. | ✓ | Oracle [`rtdb-modular-child-previous-name`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-child-previous-name.json) captures initial replay plus add/change/remove/move predecessor values; replayed by [`unit:modular/listener-lifecycle-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/listener-lifecycle-cdd.test.ts#L24). | M75 |\n| | | Value and child listener overloads accept a cancellation callback and deliver Firebase-shaped permission errors when a listen is denied or revoked; a callbackless listener that loses auth access is terminal and does not resurrect after sign-in. | ✓ | Oracle [`rtdb-modular-listener-cancellation`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-listener-cancellation.json) captures all five registrars returning normally, asynchronous initial-denial cancellation, one revocation callback each, and the Error/PERMISSION_DENIED/message shape; replayed in [`unit:modular/listener-lifecycle-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/listener-lifecycle-cdd.test.ts#L26). | M75a |\n| | | All child-listener functions accept `Query` inputs; add/change/remove events respect the active query window. | ✓ | [`unit:modular/listener-lifecycle-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/listener-lifecycle-cdd.test.ts#L267) and [`unit:modular/sandbox-child-events.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-child-events.test.ts#L18) cover initial windows plus entering, leaving, and changing members. | M75b |\n| | | `onChildMoved` fires when an ordered child changes position and co-fires with `child_changed` when Firebase does, preserving the captured `previousChildName` sequence. | ✓ | Oracle [`rtdb-modular-child-previous-name`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-child-previous-name.json) and the modular oracle suite replay ordered movement, co-fire behavior, and predecessor sequencing. (Structured evidence: [`rtdb-modular-childchanged-cofire-with-childmoved`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-childchanged-cofire-with-childmoved.json), [`rtdb-modular-onchildmoved-previouschildname-sequencing`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onchildmoved-previouschildname-sequencing.json), [`listener-lifecycle-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/listener-lifecycle-cdd.test.ts#L24), [`oracle-conformance-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-listeners.test.ts#L178)) | M75c |\n| | | Child listener overloads accept `ListenOptions` directly or after a cancellation callback; `onlyOnce` stops delivery after Firebase's captured initial/event batch. | ✓ | Oracle [`rtdb-modular-child-listener-only-once`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-child-listener-only-once.json) captures all four listener families, both overload shapes, the existing-child replay batch, and one-shot post-registration delivery; replayed by [`unit:modular/listener-lifecycle-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/listener-lifecycle-cdd.test.ts#L25). | M75d |\n| | | `.validate` rules are enforced on modular sandbox writes through `set`, atomic `update`, and `runTransaction`; a descendant validation failure rejects the operation without changing state. | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L15) executes all three write paths against a required-child `.validate` rule and proves each rejects without committing state. The shared `SimulateHandler` behavior is production-locked by RTDB rules corpus rows #4 and #15. | M76 |\n| | | `onDisconnect(ref)` returns the Firebase-shaped five-method handle; registration methods and `cancel()` return acknowledged Promises | ✓ | Oracle [`rtdb-modular-ondisconnect-registration`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-ondisconnect-registration.json); sandbox [`unit:database/on-disconnect.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/on-disconnect.test.ts#L40). | M77 |\n| | | An acknowledged `onDisconnect(ref).set(value)` registration does not mutate server data before the client disconnects | ✓ | Oracle [`rtdb-modular-ondisconnect-registration`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-ondisconnect-registration.json); sandbox registration/no-mutation test. (Structured evidence: [`on-disconnect.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/on-disconnect.test.ts#L40)) | M78 |\n| | | `goOffline(db)` drains that client's queue once, listeners observe the disconnect write in server order, and `goOnline(db)` does not resurrect executed operations | ✓ | Oracle [`rtdb-modular-ondisconnect-clean-set`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-ondisconnect-clean-set.json); sandbox clean lifecycle test. (Structured evidence: [`on-disconnect.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/on-disconnect.test.ts#L71)) | M79 |\n| | | Disconnect queues support `set`, `update`, `remove`, exact cancellation, parent cancellation of queued descendants, and captured parent-set/child-set/child-cancel coalescing that preserves the existing canceled child | ✓ | Oracle [`rtdb-modular-ondisconnect-operations-cancel`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-ondisconnect-operations-cancel.json); sandbox operation/cancellation test, including the captured parent-set + child-cancel merge result. (Structured evidence: [`on-disconnect.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/on-disconnect.test.ts#L96)) | M80 |\n| | | Rules are evaluated when a disconnect operation is registered and re-evaluated when it executes; an execution-time denial leaves server state unchanged | ✓ | Oracle [`rtdb-modular-ondisconnect-rules`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-ondisconnect-rules.json) with successful normal-write controls in both rule phases; sandbox rules-timing test. (Structured evidence: [`on-disconnect.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/on-disconnect.test.ts#L174)) | M81 |\n| | | **Local lifecycle contract:** disconnect queues are owned per Database client, drain on app deletion, clear without executing on sandbox reset, and are excluded from persisted RTDB snapshots | ✓ | The explicit `rtdb-modular#M82` assertion set in [`unit:database/on-disconnect.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/on-disconnect.test.ts#L252) covers independent clients, app deletion, reset clearing, and snapshot exclusion; these sandbox-owned boundaries have no production oracle equivalent. | M82 |\n| | | `OnDisconnect.setWithPriority(value, priority)` writes both the value and RTDB priority metadata when the disconnect queue drains. | ✓ | Oracle [`rtdb-modular-ondisconnect-operations-cancel`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-ondisconnect-operations-cancel.json) captures export-format priority 7; sandbox [`unit:database/on-disconnect.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/on-disconnect.test.ts#L96) replays the value and priority. | M83 |\n| | | **Divergence (pending fix):** clean `goOffline`, app deletion, playground `pagehide`, and best-effort MessagePort close drain queued operations, but unannounced total renderer/process loss is not guaranteed by the in-memory sandbox | ⚠ | Oracle [`rtdb-modular-ondisconnect-abrupt-exit`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-ondisconnect-abrupt-exit.json) proves Firebase executes an acknowledged registration after forced writer termination. The two-port worker integration exercises `goOffline`, served-app deletion, and non-persisted `pagehide`; browser MessagePort close delivery cannot prove total-process loss without durable host-owned leases. (Structured evidence: [`rtdb-integration.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/serve/worker/rtdb-integration.test.ts#L24)) | M84 |\n| | | `Database` is exported as a runtime constructor value and handles returned by `getDatabase()` satisfy Firebase's observable prototype and `instanceof` contract. | ✓ | Oracle [`rtdb-modular-runtime-class-identity`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtime-class-identity.json); sandbox row assertion `rtdb-modular#M85` replays runtime identity. (Structured evidence: [`oracle-conformance-runtime-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-runtime-identity.test.ts#L28)) | M85 |\n| | | `DataSnapshot` is exported as a runtime constructor value and snapshots returned by reads/listeners satisfy Firebase's observable prototype and `instanceof` contract. | ✓ | Oracle [`rtdb-modular-runtime-class-identity`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtime-class-identity.json); sandbox row assertion `rtdb-modular#M86` replays runtime identity. (Structured evidence: [`oracle-conformance-runtime-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-runtime-identity.test.ts#L29)) | M86 |\n| | | `QueryConstraint` is exported as a runtime constructor value and constraints returned by `orderBy*`, bound, and limit factories satisfy Firebase's observable prototype and `instanceof` contract. | ✓ | Oracle [`rtdb-modular-runtime-class-identity`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtime-class-identity.json); concrete constraint subclasses satisfy `instanceof QueryConstraint` without using the base prototype directly, replayed by row assertion `rtdb-modular#M87`. (Structured evidence: [`oracle-conformance-runtime-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-runtime-identity.test.ts#L29)) | M87 |\n| | | `TransactionResult` is exported as a runtime constructor value and transaction results satisfy Firebase's observable prototype, `instanceof`, and `toJSON()` contract. | ✓ | Oracle [`rtdb-modular-runtime-class-identity`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtime-class-identity.json); sandbox row assertion `rtdb-modular#M88` replays runtime identity and `toJSON()`. (Structured evidence: [`oracle-conformance-runtime-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-runtime-identity.test.ts#L29)) | M88 |\n| | | `setPriority` and `setWithPriority` store, replace, preserve, and clear valid RTDB priority metadata; parent `exportVal()` and `toJSON()` recursively include descendant priority metadata. | ✓ | Oracle [`rtdb-modular-priority-contract`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-priority-contract.json) captures round-trip/export shape, update/transaction preservation, plain-set clearing, and explicit clearing; replayed by [`unit:modular/priority-metadata-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/priority-metadata-cdd.test.ts#L20). | M89 |\n| | | `orderByPriority` orders by Firebase priority with key tie-breaking and composes with bounds, equality, and limits; plain snapshots and unconstrained queries use the same default priority index, and invalid priority bounds throw synchronously. | ✓ | Oracle [`rtdb-modular-priority-contract`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-priority-contract.json) captures priority order, key tie-breaking, bounds, and limits; replayed by [`unit:modular/priority-metadata-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/priority-metadata-cdd.test.ts#L20). | M90 |\n| | | Priority changes participate in plain and explicitly ordered child movement/listener sequencing, transactions, and updates without losing metadata. | ✓ | Oracle [`rtdb-modular-priority-contract`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-priority-contract.json) captures priority movement, update/transaction preservation, and descendant delivery when ancestor replacement clears metadata; replayed by [`unit:modular/priority-metadata-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/priority-metadata-cdd.test.ts#L20) and [`unit:modular/priority-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/priority-listeners.test.ts#L18). Disconnect priority execution is owned separately by M83. | M91 |\n| | | `off(query)` removes listeners only from the equivalent constrained query view, while `off(ref)` removes listeners from every query view at that path. | ✓ | Oracle [`rtdb-modular-off-duplicate-registration`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-off-duplicate-registration.json) captures exact constrained-query removal and all-view reference removal with independent live-listener controls; replayed by [`unit:modular/sandbox-child-events.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-child-events.test.ts#L22). | M92 |\n| | | **Divergence:** `DatabaseReference` and `Query` equality includes database target, path, and canonical query parameters like Firebase, but `toJSON()` serializes the local `sandbox://rtdb/...` identity instead of Firebase's HTTPS database URL. | ⚠ | Oracle [`rtdb-modular-reference-shape-url`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-reference-shape-url.json) captures conforming equality plus HTTPS JSON serialization; [`unit:modular/oracle-conformance-reference-writes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L33) pins both the production HTTPS shape and the sandbox's `sandbox://` boundary. | M93 |\n| | | Query constraint construction preserves Firebase's synchronous validation for limits, child paths, cursor keys, undefined/non-finite/object endpoint values, and index-specific endpoint compatibility, including the captured +Infinity and server-value priority acceptances | ✓ | Oracle [`rtdb-modular-query-construction-validation`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-query-construction-validation.json) repeats the complete validation matrix twice per run and matched across two clean production runs; [`unit:modular/query-validation.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/query-validation.test.ts#L23) replays every captured field exactly. | M94 |\n\n### `getDatabase(target)` — initializer\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | `getDatabase(ctx)` returns a tagged sandbox-target handle (frozen identity) | ✓ | [`unit:modular/database-instances-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/database-instances-cdd.test.ts#L14) asserts the returned Database carries a sandbox target. | 94 |\n| | | `getDatabase(sandbox)` returns a tagged sandbox-live handle (per-op identity) | ✓ | [`unit:modular/database-instances-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/database-instances-cdd.test.ts#L21) asserts the returned Database carries a sandbox-live target. | 95 |\n| | | Inactive canonical `firebase/database` imports remain the upstream package; the mirror does not create tagged production targets | ✓ | Package-resolution boundary assertion in [`unit:modular/database-instances-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/database-instances-cdd.test.ts#L45) proves a real Firebase Database remains untagged and is rejected by the mirror. | 96 |\n| | | `getDatabase()` (no argument) — wrapped in the playground preview to supply the sandbox; a raw mirror call rejects with package-resolution guidance | ✓ (wrap, fixture passing) | Phase 3 Tier 5: virtualized in the playground preview scope. Wired at `packages/playground/src/components/AppPreview.tsx` (slot install with bare-call wrap), `packages/playground/src/lib/preview/virtual-imports-plugin.ts` (alias map), and `packages/playground/src/lib/preview/preview-scope.ts` (type-level slot). Mirrors the `getAuth` / `getFirestore` wrap pattern. Demo fixture: `packages/playground/scripts/fixtures/rtdb-set-get-roundtrip.tsx` (bare `getDatabase()` + `set`/`get`/`remove` round-trip with anonymous sign-in) passes end-to-end through the `bun run debug:fixtures` Playwright suite. (Structured evidence: [`lifecycle-and-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/lifecycle-and-identity.test.ts#L58)) | 97 |\n| | | Two `getDatabase(sandbox)` calls share state (same underlying `LocalEnvironment`) | ✓ | [`unit:modular/database-instances-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/database-instances-cdd.test.ts#L28) writes through one handle and reads through another handle owned by the same sandbox. | 98 |\n| | | Handle dispatch by `TARGET_SYMBOL` brand — refs route to their owning target via a `refToTarget` WeakMap (mirror of firestore's pattern) | ✓ | [`unit:modular/database-instances-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/database-instances-cdd.test.ts#L36) proves references from independent sandbox targets route to independent backends. | 99 |\n\n### `ref(db, path)` / `child` / `parent` / `root`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | `ref(db, path)` returns a tagged `DatabaseReference` carrying `key`, `parent`, `root`, and `toString()`, and synchronously rejects paths containing `.`, `#`, `$`, `[`, or `]` with Firebase's captured error shape | ✓ | Oracle [`rtdb-modular-reference-shape-url`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-reference-shape-url.json); sandbox row assertion `rtdb-modular#100` replays navigation and every forbidden path character. (Structured evidence: [`oracle-conformance-reference-writes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L32)) | 100 |\n| | | `ref(db)` with no path returns the root ref (`key === null`, `parent === null`) | ✓ | Oracle [`rtdb-modular-reference-shape-url`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-reference-shape-url.json); sandbox row assertion `rtdb-modular#101`. (Structured evidence: [`oracle-conformance-reference-writes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L33)) | 101 |\n| | | `child(ref, 'a/b')` joins a non-empty relative path, including embedded slashes, and synchronously rejects empty or forbidden-character paths with Firebase's captured error shape | ✓ | Oracle [`rtdb-modular-reference-shape-url`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-reference-shape-url.json); sandbox row assertion `rtdb-modular#102` replays embedded-path joining plus empty/invalid validation. (Structured evidence: [`oracle-conformance-reference-writes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L33)) | 102 |\n| | | `ref.parent` is `null` at root, otherwise the parent ref | ✓ | Oracle [`rtdb-modular-reference-shape-url`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-reference-shape-url.json); sandbox row assertion `rtdb-modular#103`. (Structured evidence: [`oracle-conformance-reference-writes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L33)) | 103 |\n| | | `ref.key` is the final path segment, `null` for root | ✓ | Oracle [`rtdb-modular-reference-shape-url`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-reference-shape-url.json); sandbox row assertion `rtdb-modular#104`. (Structured evidence: [`oracle-conformance-reference-writes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L33)) | 104 |\n| | | Unknown ref (not produced by this package) → `TypeError` in shim ops | ✓ | Oracle [`rtdb-modular-reference-shape-url`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-reference-shape-url.json) captures a synchronous `TypeError`; sandbox row assertion `rtdb-modular#105` matches its timing and constructor. (Structured evidence: [`oracle-conformance-reference-writes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L33)) | 105 |\n\n### `get(ref)` — single read\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | Returns a `DataSnapshot` carrying `.val()`, `.exists()`, `.key`, `.ref`, `.size` (getter, returns child count), `.hasChildren()`, `.hasChild(path)`, `.forEach(cb)`. The legacy namespaced-SDK method `.numChildren()` is **NOT** on the modular DataSnapshot — use `.size` instead. Observed: `hasNumChildren: false`, `size: 3` for a `{a,b,c}` object, `forEachKeys: ['a','b','c']` against blockingfun, fb-js-sdk 12.13.0. | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-get-snapshot-shape.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-get-snapshot-shape.json) (Structured evidence: [`snapshot-shape.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/snapshot-shape.test.ts#L9)) | 106 |\n| | | `snap.val()` returns `null` for a missing path (NOT a thrown error — RTDB diverges from Firestore here; `getDoc` returns `exists()===false` but `get` on RTDB just returns a `null`-val snapshot) | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-get-missing-path.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-get-missing-path.json) — observed `threw: false, val: null, exists: false` on a never-written path against blockingfun. (Structured evidence: [`lifecycle-and-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/lifecycle-and-identity.test.ts#L38)) | 107 |\n| | | `snap.exists()` is `false` when `val() === null`, `true` otherwise | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-get-missing-path.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-get-missing-path.json) — observed `exists: false` for `val: null`. (Structured evidence: [`lifecycle-and-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/lifecycle-and-identity.test.ts#L38)) | 108 |\n| | | Round-trip: `set(ref, payload)` then `get(ref)` returns the payload (lock the basic write→read invariant) | ✓ | oracle: [`packages/conformance/observations/rtdb/rtdb-set-then-get-roundtrip.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-set-then-get-roundtrip.json) — the payload round-trips structurally on both sides (this row's claim holds). NOTE — adjacent divergence pinned in [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/oracle-conformance.test.ts#L95): prod returns object children in LEXICOGRAPHIC key order (the capture's `roundTripEqual: false` — a `JSON.stringify` round-trip against a non-sorted payload fails), while the sandbox preserves insertion order (stringify round-trip succeeds). Key-order-sensitive consumers behave differently. | 109 |\n| | | Rules-denied read throws a plain `Error` (NOT a `FirebaseError`) with `code: 'PERMISSION_DENIED'` (uppercase snake-case) — matches the agent-tool rows #15/#20 | ✓ | oracle: [`packages/conformance/observations/rtdb/rtdb-rules-denied-error-code.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-rules-denied-error-code.json) (Structured evidence: [`lifecycle-and-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/lifecycle-and-identity.test.ts#L38)) | 110 |\n\n### `set(ref, value)` — full write\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | Replaces the value at the path entirely; resolves to `undefined` (unlike `setDoc` which resolves to `void`, RTDB's `set` is documented as `Promise<void>`) | ✓ | Oracle [`rtdb-modular-write-return-validation`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-write-return-validation.json) captures `null` in JSON for the resolved `undefined` value; sandbox row assertion `rtdb-modular#111` matches it. (Structured evidence: [`oracle-conformance-reference-writes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L138)) | 111 |\n| | | `set(ref, null)` removes the path entirely — equivalent to `remove(ref)`, subsequent `get` returns `null`-val snapshot | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-set-null-equals-remove.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-set-null-equals-remove.json) — observed `beforeExists: true → afterExists: false, afterVal: null` after `set(ref, null)`. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L20)) | 112 |\n| | | Nested objects overwrite — `set(ref, {a: 1})` after `set(ref, {a: 1, b: 2})` leaves `{a: 1}` only, NOT a merge (RTDB `set` is replacement, not merge) | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-set-replaces-not-merges.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-set-replaces-not-merges.json) — observed `final: {a: 1}` with `b` absent after the second set. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L20)) | 113 |\n| | | Primitive round-trip — numbers, strings, booleans, arrays all survive a set→get cycle | ✓ | oracle: [`packages/conformance/observations/rtdb/rtdb-set-then-get-roundtrip.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-set-then-get-roundtrip.json) — the payload round-trips structurally on both sides (this row's claim holds). NOTE — adjacent divergence pinned in [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/oracle-conformance.test.ts#L95): prod returns object children in LEXICOGRAPHIC key order (the capture's `roundTripEqual: false` — a `JSON.stringify` round-trip against a non-sorted payload fails), while the sandbox preserves insertion order (stringify round-trip succeeds). Key-order-sensitive consumers behave differently. | 114 |\n| | | Rules-denied write throws plain `Error` with `code: 'PERMISSION_DENIED'` (same shape as #110) | ✓ | oracle: [`packages/conformance/observations/rtdb/rtdb-rules-denied-error-code.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-rules-denied-error-code.json) (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L20)) | 115 |\n\n### `update(ref, values)` — partial / multi-path update\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | `update(ref, {a: 1, b: 2})` merges top-level keys at the ref; unspecified keys preserved (in contrast to `set`'s replacement) | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-update-merges-keys.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-update-merges-keys.json) — after `set({a:1,b:2})` then `update({a:10})`, observed `final: {a:10, b:2}`. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L60)) | 116 |\n| | | Multi-path update — `update(parentRef, { 'a/x': 1, 'b/y': 2 })` lands BOTH writes atomically at distinct subtrees (RTDB's most distinctive feature; this is the \"fan-out\" pattern) | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-update-multipath-atomic.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-update-multipath-atomic.json) — observed `aX: 1, bY: 2` both readable after a single update call. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L60)) | 117 |\n| | | Multi-path update is atomic: if any path is denied by rules, the entire update rejects and no path is written | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-update-multipath-rules-denial.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-update-multipath-rules-denial.json) — observed `threw: true, code: 'PERMISSION_DENIED'` AND `okPathWrittenDespiteDenial: false` (the otherwise-permitted path also rolled back). (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L60)) | 118 |\n| | | Setting a key to `null` inside `update` removes that key — same equivalence as `set(ref, null)` | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-update-null-removes-key.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-update-null-removes-key.json) — after `set({a:1,b:2})` then `update({a:null})`, observed `final: {b:2}` with `a` absent. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L60)) | 119 |\n| | | Update path validation — overlapping paths (e.g. `'/a'` and `'/a/x'` in the same call) throws synchronously before any write | ✓ | Oracle [`rtdb-modular-write-return-validation`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-write-return-validation.json) captures a synchronous ancestor-path error and unchanged terminal state; sandbox row assertion `rtdb-modular#120` matches both. (Structured evidence: [`oracle-conformance-reference-writes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L139)) | 120 |\n\n### `remove(ref)` — delete\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | Removes the value AND all children; subsequent `get` returns `null`-val snapshot | ✓ | oracle: [`packages/conformance/observations/rtdb/rtdb-remove-vs-set-null.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-remove-vs-set-null.json) (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L66)) | 121 |\n| | | Idempotent — `remove` on a path that's already absent resolves successfully (no-throw) | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-remove-idempotent.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-remove-idempotent.json) — `remove` on a never-written path observed `threw: false, afterExists: false`. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L66)) | 122 |\n| | | `remove(ref)` and `set(ref, null)` produce the same end state — locks the documented RTDB invariant | ✓ | oracle: [`packages/conformance/observations/rtdb/rtdb-remove-vs-set-null.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-remove-vs-set-null.json) (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L66)) | 123 |\n\n### `push(ref, value?)` — auto-id append\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | `push(ref).key` is a 20-char string starting with `-`, available **synchronously** (client-side mint, no server round-trip required) | ✓ | oracle: [`packages/conformance/observations/rtdb/rtdb-push-autoid-format.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-push-autoid-format.json) (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L69)) | 124 |\n| | | Sequential `push` calls produce monotonically-sortable keys (timestamp-prefixed for chronological ordering via `orderByKey`) | ✓ | oracle: [`packages/conformance/observations/rtdb/rtdb-push-autoid-format.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-push-autoid-format.json) (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L69)) | 125 |\n| | | `push(ref, value)` writes the value AND returns the new child ref (both behaviors in one call); `push(ref)` mints the ref without writing | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-push-with-value.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-push-with-value.json) — `await push(parent, {hello:'world'})` returned a ref with a 20-char key; subsequent `get(r)` returned `{hello:'world'}`. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L69)) | 126 |\n| | | The returned ref `r = push(parent, value)` is usable in follow-up ops: `get(r)`, `set(r, …)`, `remove(r)` | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-push-with-value.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-push-with-value.json) — observed all 4 follow-up ops succeed through the returned ref (`refIsUsableForFollowupOps: true`). (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L69)) | 127 |\n\n### `onValue(ref, cb)` — value-level listener\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | Subscribing to a path with **existing data** fires the listener once with the current snapshot (the \"initial fire\") | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-onvalue-initial-with-data.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onvalue-initial-with-data.json) — observed exactly 1 initial fire within ~46ms of subscribe, snapshot.val() === the seeded payload. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L75)) | 128 |\n| | | Subscribing to a **nonexistent path** still fires the listener once — with a `null`-val snapshot AND `exists() === false`. Matches Firestore's `onSnapshot`-on-missing-doc semantics: prod RTDB does NOT silently skip the initial fire for empty paths. | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-onvalue-initial-no-data.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onvalue-initial-no-data.json) — observed 1 initial fire on a never-written path with `firstFire.val: null, firstFire.exists: false` (~55ms after subscribe). (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L28)) | 129 |\n| | | Subsequent `set(ref, …)` fires the listener with the new value | ✓ | oracle: [`packages/conformance/observations/rtdb/rtdb-onvalue-fires-on-set.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-onvalue-fires-on-set.json) — observed 1 fire per `set()` (1+1+1 = 3 total: initial-null, after-first-set, after-second-set). (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L75)) | 130 |\n| | | Unsubscribe — the returned unsubscribe function stops further fires; subsequent writes produce 0 additional fires after `unsub()` | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-onvalue-unsubscribe.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onvalue-unsubscribe.json) — observed `preUnsubFires: 2, postUnsubFires: 2` (a write performed after `unsub()` produced 0 additional fires within a 500ms settle window). (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L75)) | 131 |\n| | | The returned value from `onValue(ref, cb)` is the unsubscribe function (NOT an object); calling it removes the listener | ✓ | [`unit:modular/listener-lifecycle-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/listener-lifecycle-cdd.test.ts#L208) asserts the return is a function and that invoking it stops subsequent delivery; production behavior is adjacent to oracle-backed row #131. | 132 |\n\n### `onChildAdded` / `onChildChanged` / `onChildRemoved` / `onChildMoved`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | `onChildAdded` replays the existing children on subscribe — one fire per existing child key, in the default priority index with key tie-breaking (unlike `onValue` which fires once with the parent snapshot) | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-onchildadded-initial-replay.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onchildadded-initial-replay.json) — seeded `{k1, k2, k3}`, observed 3 initial fires with `firedKeys: ['k1', 'k2', 'k3']`; the default priority/key order is separately pinned by `rtdb-modular-priority-contract`. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L106)) | 133 |\n| | | After subscribe, adding a child via `push` or `set(child, …)` fires `onChildAdded` exactly once for that key | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-onchildadded-post-subscribe.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onchildadded-post-subscribe.json) — seeded `{k1,k2}`, observed `postSubscribeFires: 1, lastFire: {key:'k3', val:{v:3}}` after writing the new child. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L106)) | 134 |\n| | | `onChildChanged` fires when an existing child's value changes; does NOT fire for added or removed children | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-onchildchanged-fires-on-update.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onchildchanged-fires-on-update.json) — observed `firedOnInitial: 0, firedOnUpdate: 1, lastFire: {key:'k1', val:{v:2}}` (the NEW value, not the prior). (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L86)) | 135 |\n| | | `onChildRemoved` fires when a child is deleted (via `remove(child)` or `set(child, null)`); snapshot carries the PRIOR value | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-onchildremoved-fires-on-delete.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onchildremoved-fires-on-delete.json) — observed `firedOnDelete: 1, removedSnapCarriesPriorValue: true` (snapshot.val() was the pre-delete value). (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L107)) | 136 |\n| | | `onChildMoved` under an explicit ordered query fires when the ordered field changes, co-fires with `child_changed`, and supplies the captured `previousChildName`. | ✓ | Oracle observations [`rtdb-modular-onchildmoved-with-orderby`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onchildmoved-with-orderby.json), [`rtdb-modular-onchildmoved-previouschildname-sequencing`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onchildmoved-previouschildname-sequencing.json), and [`rtdb-modular-childchanged-cofire-with-childmoved`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-childchanged-cofire-with-childmoved.json) are replayed by the modular oracle suite. (Structured evidence: [`oracle-conformance-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-listeners.test.ts#L152)) | 137 |\n\n### `off(ref, eventType?, callback?)` — unsubscribe variants\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | `off(ref)` removes ALL listeners at that ref (any event type, any callback) | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-off-stops-child-fires.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-off-stops-child-fires.json) — after `off(ref)` with no eventType, a subsequent write produced `postOffFires: 0` against an `onChildAdded` registration. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L108)) | 138 |\n| | | `off(ref, 'value')` removes only `value` listeners at that ref | ✓ | Sandbox aligned (M48); oracle: [`packages/conformance/observations/rtdb/rtdb-off-eventtype-precision.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-off-eventtype-precision.json) — registered TWO `value` listeners + one `child_added` at the same ref; after `off(ref, 'value')` (no callback), `valueListenersStopped: true` (neither value cb fired on subsequent writes) AND `childListenerStillFiringAfterOffValue: true` (the child listener kept firing). `offValueClearsAllValueListeners: true` confirms the no-callback variant removes ALL value listeners at the ref. (Structured evidence: [`rtdb-modular-off-stops-child-fires`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-off-stops-child-fires.json), [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L108)) | 139 |\n| | | `off(ref, 'value', cb)` removes only the specific callback | ✓ | Sandbox aligned (M48); adjacent to #141 — the upstream `off` with the cb argument removes only the matching callback. Same probe (`rtdb-onvalue-unsub-equivalence.json` Case 2) confirms `off(ref, 'value', cb)` stops only that callback. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/oracle-conformance.test.ts#L52)) | 140 |\n| | | The returned unsubscribe function from `onValue(ref, cb)` is equivalent to `off(ref, 'value', cb)` | ✓ | Sandbox aligned (M48); oracle: [`packages/conformance/observations/rtdb/rtdb-onvalue-unsub-equivalence.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-onvalue-unsub-equivalence.json) — `unsubReturnType: 'function'`, `unsubReturnedFnStopsListener: true` (the captured return value halted fires on write), `offRefValueCbStopsListener: true` (the same effect via `off(ref, 'value', cb)`), `bothFormsEquivalent: true`. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L108)) | 141 |\n| off(ref, eventType, callback) | | When the same callback is registered more than once, each `off(ref, eventType, callback)` removes one registration without orphaning the others | ✓ | Oracle [`rtdb-modular-off-duplicate-registration`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-off-duplicate-registration.json) proves each matching `off` removes one registration; sandbox row assertion `rtdb-modular#183` replays the full sequence. (Structured evidence: [`oracle-conformance-reference-writes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L175)) | 183 |\n\n### `query(ref, ...constraints)` + ordering / bounds / limits\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | `query(ref, orderByChild('field'), limitToFirst(N))` returns a `Query` whose `get()` resolves a snapshot containing N children ordered by `field` | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-query-orderbychild-limit.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-query-orderbychild-limit.json) — seeded 4 children with positions `[3,1,4,2]`, observed `orderedKeys: [{key:'a',pos:1}, {key:'b',pos:2}]` (first 2 in ascending order). Requires `.indexOn` declared in rules. (Structured evidence: [`rtdb-modular-orderbychild-window`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-orderbychild-window.json), [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L19)) | 142 |\n| | | `orderByKey()` orders by the auto-id / numeric key | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-orderbykey-window.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-orderbykey-window.json) — seeded `{a,b,c,d,e}` in shuffled insertion order, observed `matchedKeys: ['b','c','d']` for `orderByKey() + startAt('b') + endAt('d')` (in key order). (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L122)) | 143 |\n| | | Production enforces `.indexOn: '.value'` for `orderByValue()` while the sandbox executes the value ordering without index enforcement | ⚠ | Oracle [`rtdb-modular-orderbyvalue-numeric`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-orderbyvalue-numeric.json) rejects the unindexed production query; [`unit:modular/oracle-conformance-queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-queries.test.ts#L51) pins the sandbox's successful ordered result instead of treating that contradiction as conformance. | 144 |\n| | | `equalTo(v)` filters children whose ordered field === v (returns 0, 1, or multiple matches — RTDB does NOT enforce uniqueness) | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-query-equalto.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-query-equalto.json) — seeded `{red, blue, blue, green}`, observed `matchedKeys: ['k2', 'k3']` for `equalTo('blue')` (both blue children, none of the others). Additional probe: [`packages/conformance/observations/rtdb-modular/rtdb-modular-equalTo-filter.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-equalTo-filter.json) (a..b..c groups) confirms `equalTo('b')` returns the two `b` children. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L124)) | 145 |\n| | | `startAt(v)` is **inclusive** (the child whose ordered value === v is included) | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-query-startat-inclusive.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-query-startat-inclusive.json) — seeded positions `[1,2,3,4]`, observed `matched: [2,3,4]` for `startAt(2)` (cursor doc included). (Structured evidence: [`rtdb-modular-orderbychild-window`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-orderbychild-window.json), [`rtdb-modular-orderbykey-window`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-orderbykey-window.json), [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L125)) | 146 |\n| | | `endAt(v)` is **inclusive** | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-orderbychild-window.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-orderbychild-window.json) — `startAt(2) + endAt(4)` matched positions `[2,3,4]` (endAt(4) included its boundary value). (Structured evidence: [`rtdb-modular-orderbykey-window`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-orderbykey-window.json), [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L126)) | 147 |\n| | | `startAfter(v)` is **exclusive** | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-startafter-endbefore-exclusive.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-startafter-endbefore-exclusive.json) — `startAfter(2) + endBefore(5)` matched positions `[3,4]` (cursor `2` dropped). (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L127)) | 148 |\n| | | `endBefore(v)` is **exclusive** | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-startafter-endbefore-exclusive.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-startafter-endbefore-exclusive.json) — same probe; cursor `5` dropped. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L128)) | 149 |\n| | | `limitToFirst(N)` caps the result count from the start of the ordered range | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-query-orderbychild-limit.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-query-orderbychild-limit.json) plus [`packages/conformance/observations/rtdb-modular/rtdb-modular-limittofirst-vs-limittolast.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-limittofirst-vs-limittolast.json) (firstPositions `[1,2]`). (Structured evidence: [`rtdb-modular-orderbyvalue-numeric`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-orderbyvalue-numeric.json), [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L26)) | 150 |\n| | | `limitToLast(N)` caps from the end | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-limittofirst-vs-limittolast.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-limittofirst-vs-limittolast.json) — observed `lastKeys: ['d','e'], lastPositions: [4,5]` for `limitToLast(2)` on a 5-child collection ordered by `pos`. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L130)) | 151 |\n| | | Listeners on a `Query` (`onValue(q, …)`) emit only the windowed snapshot — NOT the parent ref's full data | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-onvalue-with-query.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onvalue-with-query.json) — seeded 3 children, watched first 2 by `pos`; observed 3 fires total: (1) initial `[a,b]`, (2) OUTSIDE-window write to `c/extra` did NOT fire, (3) INSIDE-window mutation of `a` re-fired, (4) new child `z` displaced `b` and re-fired. Outside-window writes are silent. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L19)) | 152 |\n\n### Sentinels — `serverTimestamp()` / `increment(n)`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | `serverTimestamp()` resolves server-side to a **number** (epoch milliseconds) — diverges from Firestore's `Timestamp` instance | ✓ | oracle: [`packages/conformance/observations/rtdb/rtdb-servertimestamp-resolves.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-servertimestamp-resolves.json) — observed `createdAtType: 'number', createdAt: 1779075391118` (i.e. a plain JS number, NOT a `Timestamp` object). (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L1)) | 153 |\n| | | `serverTimestamp()` as a field value in `set` or `update` writes the `{\".sv\": \"timestamp\"}` sentinel; the read-back value is the resolved number | ✓ | oracle: [`packages/conformance/observations/rtdb/rtdb-servertimestamp-resolves.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-servertimestamp-resolves.json) — read-back showed `createdAtSentinelShape: false` (sentinel resolved server-side; client sees the number, not the `.sv` placeholder). (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L1)) | 154 |\n| | | `increment(n)` against a **missing** field starts at 0 (so `increment(5)` lands as `5`) | ✓ | Sandbox aligned (modular `increment` export now present): [`unit:modular/increment.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/increment.test.ts#L7) (\"increment against a missing field starts from 0\"); matches oracle [`packages/conformance/observations/rtdb-modular/rtdb-modular-increment-from-missing.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-increment-from-missing.json) — observed `afterFirst: 5` from `increment(5)` against an absent `count` field. | 155 |\n| | | `increment(n)` against an existing numeric field adds atomically; negative deltas subtract | ✓ | Sandbox aligned: [`unit:modular/increment.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/increment.test.ts#L7) (\"subsequent increments accumulate (positive then negative)\" + \"nested inside an update patch resolves per-field\"); matches oracle [`packages/conformance/observations/rtdb-modular/rtdb-modular-increment-from-missing.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-increment-from-missing.json) — observed `afterSecond: 8` (5+3) then `afterNegative: 6` (8-2). | 156 |\n| | | **Divergence:** production interleaves concurrent `increment` writes from independent clients and preserves both deltas; the synchronous in-process backend applies each call before returning its promise, so a `Promise.all` call site is observably serialized even though its terminal value also reaches 5 | ⚠ | Oracle [`rtdb-modular-concurrent-transforms`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-concurrent-transforms.json) uses two independent clients and captures terminal 5 after concurrent +2/+3 increments; [`unit:modular/transaction-contention-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction-contention-cdd.test.ts#L12) pins the sandbox's synchronous first-write visibility and matching terminal value without claiming that terminal equality proves contention semantics. | 157 |\n\n### `runTransaction(ref, transactionUpdate, options?)` — optimistic concurrency\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | Basic success — `runTransaction(ref, current => (current ?? 0) + 1)` resolves `{ committed: true, snapshot }` where `snapshot.val()` is the new value | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-success.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-success.json) — observed `committed: true, snapVal: 1` after running `current => (current ?? 0) + 1` against an empty ref. (Structured evidence: [`rtdb-modular-runtransaction-on-rules-denied-path`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-on-rules-denied-path.json), [`rtdb-modular-runtransaction-options-applylocally`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-options-applylocally.json), [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L140)) | 158 |\n| | | Returning `undefined` from the update fn **aborts** the transaction — resolves `{ committed: false }`, no write performed (RTDB-specific; distinct from Firestore where the only abort path is throwing) | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-abort-undefined.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-abort-undefined.json) — seeded `100` then transaction returned `undefined`; observed `committed: false, snapVal: null, afterValOnServer: 100` (existing value preserved). (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L29)) | 159 |\n| | | A cold production client can call the update fn with speculative `null` before the current seeded value; the always-warm sandbox calls it once with the current value | ⚠ | Oracle [`rtdb-modular-runtransaction-current-value-arg`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-current-value-arg.json) captures the cold two-call sequence and `rtdb-modular-runtransaction-warm-client-speculation` captures the matching warm one-call case; [`unit:modular/oracle-conformance-transactions.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-transactions.test.ts#L20) pins both sides. (Structured evidence: [`rtdb-modular-runtransaction-options-applylocally`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-options-applylocally.json), [`rtdb-modular-runtransaction-success`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-success.json)) | 160 |\n| | | Concurrent contention — if another client writes between the read and write, the update fn is retried with the new current value (typically up to 25 retries by default) | ⚠ | Oracle [`rtdb-modular-concurrent-transforms`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-concurrent-transforms.json) captures callback counts [2, 3] across two clients; the synchronous in-process backend serializes the same ordinary calls with [1, 1], pinned by [`unit:modular/transaction-contention-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction-contention-cdd.test.ts#L12). | 161 |\n| | | Result snapshot's `.val()` reflects the committed value (or the existing value if aborted) | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-success.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-success.json) — observed `snapVal: 1` matching the committed value. (Structured evidence: [`rtdb-modular-runtransaction-returns-committed-snapshot`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-returns-committed-snapshot.json), [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L30)) | 162 |\n\n### `goOnline` / `goOffline` — connection control\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | `goOffline(db)` drains that client's onDisconnect queue once, but does not make the in-memory data plane unreachable; ordinary writes, listeners, and `get()` remain available | ⚠ logical disconnect lifecycle is modeled, but the in-memory data plane itself remains available | [`unit:database/on-disconnect.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/on-disconnect.test.ts#L10) + [`unit:modular/fruit-aliases.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/fruit-aliases.test.ts#L17) | 163 |\n| | | `goOnline(db)` reconnects the logical lifecycle without resurrecting drained operations; it is otherwise a no-op because the in-memory data plane never became unreachable | ⚠ logical disconnect lifecycle is modeled, but the in-memory data plane itself remains available | [`unit:database/on-disconnect.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/on-disconnect.test.ts#L11) + [`unit:modular/fruit-aliases.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/fruit-aliases.test.ts#L18) | 164 |\n\n### Transport, logging, and URL-reference exports\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | `forceLongPolling()` — accepted no-op: transport selection is not applicable to the in-process/worker sandbox (it never opens a real socket). Accepted so init code that calls it compiles + runs | ⚠ transport selection not applicable to the in-process/worker sandbox | [`unit:modular/fruit-aliases.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/fruit-aliases.test.ts#L19) | 171 |\n| | | `forceWebSockets()` — accepted no-op: transport selection is not applicable to the in-process/worker sandbox (see `forceLongPolling`) | ⚠ transport selection not applicable to the in-process/worker sandbox | [`unit:modular/fruit-aliases.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/fruit-aliases.test.ts#L20) | 172 |\n| | | `enableLogging(logger?, persistent?)` — accepted no-op: the sandbox has no modular-SDK-style logger to wire a level/sink into (it uses host-level `console` logging directly, matching `pyric/firestore`'s `setLogLevel`). Accepted so init code that calls it compiles + runs | ⚠ accepted no-op; no sandbox logger to wire into | [`unit:modular/fruit-aliases.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/fruit-aliases.test.ts#L21) | 173 |\n| | | **Divergence:** `refFromURL(db, url)` matches Firebase's path parsing, query ignoring, FTP-to-database normalization, and fragment rejection, but the single-database sandbox does not validate the URL host/namespace against the handle; Firebase rejects a mismatched host while the sandbox uses its path. | ⚠ path and validation behavior conform except URL host/namespace matching (single-database sandbox) | Oracle [`rtdb-modular-reference-shape-url`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-reference-shape-url.json) captures accepted FTP/query URLs and rejected fragments, malformed URLs, and host mismatches; [`unit:modular/oracle-conformance-reference-writes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L33) pins the conforming validations plus both sides of the host divergence. | 174 |\n\n## Current gaps\n\n### Documented divergences\n\nKnown differences between Pyric and production Firebase. Each remains tracked as a non-conforming row.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\">Cold production clients may invoke the update fn first with speculative <code>null</code> and then with the current seeded value; the always-warm in-process sandbox invokes it once with the current value</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Oracle <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-current-value-arg.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rtdb-modular-runtransaction-current-value-arg</code></a> captures two seeded-path production invocations (<code>null</code>, then current); <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-transactions.test.ts#L52\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/oracle-conformance-transactions.test.ts</code></a> pins the sandbox's single current-value invocation. The separate warm-client observation matches the sandbox but does not erase the cold-client contract. (Structured evidence: <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction.test.ts#L93\" target=\"_blank\" rel=\"noopener noreferrer\"><code>transaction.test.ts</code></a>)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\">**Divergence:** two ordinary concurrent <code>runTransaction</code> calls are serialized by the in-process backend, so their update functions are not retried with Firebase's captured contention counts. A synchronous re-entrant conflicting write does trigger a deterministic retry.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Oracle <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-concurrent-transforms.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rtdb-modular-concurrent-transforms</code></a> captures invocation counts [2, 3]; <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction-contention-cdd.test.ts#L12\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/transaction-contention-cdd.test.ts</code></a> pins the sandbox's [1, 1] ordinary-concurrency boundary and separately covers its deterministic re-entrant retry seam.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\">Production rejects an unindexed <code>orderByValue() + limitToFirst(N)</code> query; the sandbox does not enforce <code>.indexOn</code> and returns the N smallest primitive values</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Oracle <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-orderbyvalue-numeric.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rtdb-modular-orderbyvalue-numeric</code></a> captures production's <code>Index not defined</code> rejection; <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L108\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/oracle-conformance-queries.test.ts</code></a> pins that rejection beside the sandbox's successful <code>[10,20,30]</code> window. (Structured evidence: <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-queries.test.ts#L51\" target=\"_blank\" rel=\"noopener noreferrer\"><code>oracle-conformance-queries.test.ts</code></a>)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\">**Divergence (pending fix):** clean <code>goOffline</code>, app deletion, playground <code>pagehide</code>, and best-effort MessagePort close drain queued operations, but unannounced total renderer/process loss is not guaranteed by the in-memory sandbox</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Oracle <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-ondisconnect-abrupt-exit.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rtdb-modular-ondisconnect-abrupt-exit</code></a> proves Firebase executes an acknowledged registration after forced writer termination. The two-port worker integration exercises <code>goOffline</code>, served-app deletion, and non-persisted <code>pagehide</code>; browser MessagePort close delivery cannot prove total-process loss without durable host-owned leases. (Structured evidence: <a href=\"https://github.com/davideast/pyric/blob/main/packages/cli/test/serve/worker/rtdb-integration.test.ts#L24\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rtdb-integration.test.ts</code></a>)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\">**Divergence:** <code>DatabaseReference</code> and <code>Query</code> equality includes database target, path, and canonical query parameters like Firebase, but <code>toJSON()</code> serializes the local <code>sandbox://rtdb/...</code> identity instead of Firebase's HTTPS database URL.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Oracle <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-reference-shape-url.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rtdb-modular-reference-shape-url</code></a> captures conforming equality plus HTTPS JSON serialization; <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L33\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/oracle-conformance-reference-writes.test.ts</code></a> pins both the production HTTPS shape and the sandbox's <code>sandbox://</code> boundary.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\">Production enforces <code>.indexOn: '.value'</code> for <code>orderByValue()</code> while the sandbox executes the value ordering without index enforcement</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Oracle <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-orderbyvalue-numeric.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rtdb-modular-orderbyvalue-numeric</code></a> rejects the unindexed production query; <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-queries.test.ts#L51\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/oracle-conformance-queries.test.ts</code></a> pins the sandbox's successful ordered result instead of treating that contradiction as conformance.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\">**Divergence:** production interleaves concurrent <code>increment</code> writes from independent clients and preserves both deltas; the synchronous in-process backend applies each call before returning its promise, so a <code>Promise.all</code> call site is observably serialized even though its terminal value also reaches 5</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Oracle <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-concurrent-transforms.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rtdb-modular-concurrent-transforms</code></a> uses two independent clients and captures terminal 5 after concurrent +2/+3 increments; <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction-contention-cdd.test.ts#L12\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/transaction-contention-cdd.test.ts</code></a> pins the sandbox's synchronous first-write visibility and matching terminal value without claiming that terminal equality proves contention semantics.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\">A cold production client can call the update fn with speculative <code>null</code> before the current seeded value; the always-warm sandbox calls it once with the current value</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Oracle <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-current-value-arg.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rtdb-modular-runtransaction-current-value-arg</code></a> captures the cold two-call sequence and <code>rtdb-modular-runtransaction-warm-client-speculation</code> captures the matching warm one-call case; <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-transactions.test.ts#L20\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/oracle-conformance-transactions.test.ts</code></a> pins both sides. (Structured evidence: <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-options-applylocally.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rtdb-modular-runtransaction-options-applylocally</code></a>, <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-success.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rtdb-modular-runtransaction-success</code></a>)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\">Concurrent contention — if another client writes between the read and write, the update fn is retried with the new current value (typically up to 25 retries by default)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Oracle <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-concurrent-transforms.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rtdb-modular-concurrent-transforms</code></a> captures callback counts [2, 3] across two clients; the synchronous in-process backend serializes the same ordinary calls with [1, 1], pinned by <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction-contention-cdd.test.ts#L12\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/transaction-contention-cdd.test.ts</code></a>.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>goOffline(db)</code> drains that client's onDisconnect queue once, but does not make the in-memory data plane unreachable; ordinary writes, listeners, and <code>get()</code> remain available</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/on-disconnect.test.ts#L10\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:database/on-disconnect.test.ts</code></a> + <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/fruit-aliases.test.ts#L17\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/fruit-aliases.test.ts</code></a></div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>goOnline(db)</code> reconnects the logical lifecycle without resurrecting drained operations; it is otherwise a no-op because the in-memory data plane never became unreachable</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/on-disconnect.test.ts#L11\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:database/on-disconnect.test.ts</code></a> + <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/fruit-aliases.test.ts#L18\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/fruit-aliases.test.ts</code></a></div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>forceLongPolling()</code> — accepted no-op: transport selection is not applicable to the in-process/worker sandbox (it never opens a real socket). Accepted so init code that calls it compiles + runs</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/fruit-aliases.test.ts#L19\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/fruit-aliases.test.ts</code></a></div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>forceWebSockets()</code> — accepted no-op: transport selection is not applicable to the in-process/worker sandbox (see <code>forceLongPolling</code>)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/fruit-aliases.test.ts#L20\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/fruit-aliases.test.ts</code></a></div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>enableLogging(logger?, persistent?)</code> — accepted no-op: the sandbox has no modular-SDK-style logger to wire a level/sink into (it uses host-level <code>console</code> logging directly, matching <code>pyric/firestore</code>'s <code>setLogLevel</code>). Accepted so init code that calls it compiles + runs</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/fruit-aliases.test.ts#L21\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/fruit-aliases.test.ts</code></a></div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\">**Divergence:** <code>refFromURL(db, url)</code> matches Firebase's path parsing, query ignoring, FTP-to-database normalization, and fragment rejection, but the single-database sandbox does not validate the URL host/namespace against the handle; Firebase rejects a mismatched host while the sandbox uses its path.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Oracle <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-reference-shape-url.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rtdb-modular-reference-shape-url</code></a> captures accepted FTP/query URLs and rejected fragments, malformed URLs, and host mismatches; <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L33\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/oracle-conformance-reference-writes.test.ts</code></a> pins the conforming validations plus both sides of the host divergence.</div></div></details>\n</div>\n" }, { "slug": "storage-compat", "label": "Storage", "title": "pyric/storage compatibility matrix", "markdown": "<!-- Generated from the conformance model (registry rows + surface contracts). Do not edit by hand; run bun run compat:generate. -->\n\n# `pyric/storage` compatibility matrix\n\n<div class=\"compat-stat\">\n<p class=\"compat-stat-figure\">\n<span class=\"compat-stat-pct\">77.1%</span>\n<span class=\"compat-stat-label\">of the public API supported</span>\n</p>\n<div class=\"compat-meters\">\n<span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 77.1%\"></span></span>\n</div>\n<p class=\"compat-stat-denom\">27 of 35 public API</p>\n</div>\n\n\n## Status legend\n\n| Status | Meaning |\n|---|---|\n| ✓ | **Conforming** — sandbox matches prod, locked by a passing probe |\n| ⚠ | **Diverged (documented)** — intentional difference with a written reason |\n| — | **Not implemented yet** — deliberately or pending |\n\n## `getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?)` — initializer\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?) | | `getStorageSandbox(ctx)` returns a tagged sandbox-target handle (frozen identity) | ✓ | [`unit:service.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/service.test.ts#L15) | 1 |\n| getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?) | | `getStorageSandbox(sandbox)` wraps a bare Sandbox with an anonymous context (`auth: null`) | ✓ | [`unit:service.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/service.test.ts#L15) | 2 |\n| getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?) | | `getStorage(app)` returns the sandbox handle selected by package resolution | ✓ | `entry-path:storage` runs the canonical `initializeApp` → `getStorage(app)` → `ref` → `uploadBytes` flow (Structured evidence: [`storage.ts`](https://github.com/davideast/pyric/blob/main/packages/conformance/entry-path/storage.ts#L6)) | 3 |\n| getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?) | | Two `getStorageSandbox(ctx)` calls on the same context return the SAME wrapper (identity-stable) | ✓ | [`unit:service.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/service.test.ts#L15) (\"returns the same handle for repeated calls on the same context\") | 4 |\n| getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?) | | Two `getStorageSandbox(sandbox)` calls on a bare `Sandbox` return the SAME wrapper (identity-stable) | ✓ | ST-B3 fixed: `withAuth(null)` mints a fresh context per call, so the per-context cache missed and bare-Sandbox calls returned different handles. A `Sandbox`-keyed cache makes the convenience path stable, matching the docstring. Probe: [`unit:service.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/service.test.ts#L15) (\"ST-B3: returns the same handle for repeated bare-Sandbox calls\"). | 4a |\n| getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?) | | Two different `SandboxContext`s on the same `Sandbox` get DIFFERENT handles but share the underlying `StorageService` (IDB) | ✓ | [`unit:service.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/service.test.ts#L4) (\"shares the underlying StorageService across contexts on the same sandbox\") | 5 |\n| getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?) | | `options.bucket` round-trips on metadata records; v1 has a single implicit bucket but the field is preserved | ✓ | [`unit:service.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/service.test.ts#L4) (\"records the bucket value on the handle\") | 6 |\n| getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?) | | `options.dbName` honored on the FIRST call per `Sandbox`; second-call overrides ignored | ✓ | [`unit:service.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/service.test.ts#L4) (\"dbName only takes effect on the sandbox's first getStorage call\") | 7 |\n| getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?) | | `options.rules` parsed eagerly — malformed rules throw `SyntaxError` at config time | ✓ | [`unit:storage/sandbox/rules.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/sandbox/rules.test.ts#L1) (parse errors propagate from `parseStorageRules`) | 8 |\n| getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?) | | The `TARGET_SYMBOL` brand keeps each handle bound to its owning sandbox service and identity | ✓ | [`unit:service.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/service.test.ts#L4) (distinct contexts share one service while retaining distinct handles) | 9 |\n| getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?) | | Unrecognized handle (not produced by a factory) → `TypeError` \"not a FirebaseStorage handle\" | ✓ | [`unit:service.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/service.test.ts#L4) (\"rejects an object that was not produced by a factory\") | 10 |\n| getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?) | | `getStorage(app, bucketUrl?)` accepts Firebase's bucket argument; the sandbox remains bound to its configured single bucket | ⚠ | Package resolution owns production selection. The sandbox accepts the canonical argument but does not model production multi-bucket routing; [`unit:service.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/service.test.ts#L4) pins the configured `pyric-default` bucket. | 11 |\n| getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?) | | The served `firebase/storage` entry accepts bare `getStorage()` and returns the page's shared sandbox handle | ✓ | The canonical served entry supplies the page sandbox when the app argument is omitted; the entry-path and bundler suites execute the public package shape. (Structured evidence: [`storage.ts`](https://github.com/davideast/pyric/blob/main/packages/conformance/entry-path/storage.ts#L6), [`bundler.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/serve/bundler.test.ts#L448)) | 12 |\n\n## `ref(storage[, path])` / `ref(parent, path)` — reference constructor\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| ref(storage[, path])` / `ref(parent, path) | | `ref(storage)` returns the root ref — `fullPath === ''`, `name === ''`, `parent === null`, `root === self` | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L2) (\"root reference has empty fullPath, null parent, and equal root\") | 13 |\n| ref(storage[, path])` / `ref(parent, path) | | `ref(storage, 'sessions/s1.json')` populates `fullPath`, `name` (last segment), `parent` (path without last segment) | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L2) (\"ref(storage, path) populates fullPath and name from the last segment\") | 14 |\n| ref(storage[, path])` / `ref(parent, path) | | Path normalization: leading slashes stripped (`/sessions/s1` → `sessions/s1`) | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L2) (\"normalizes leading/trailing/double slashes\") | 15 |\n| ref(storage[, path])` / `ref(parent, path) | | Path normalization: trailing slashes stripped | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L2) | 16 |\n| ref(storage[, path])` / `ref(parent, path) | | Path normalization: repeated internal slashes collapsed (`a//b` → `a/b`) | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L2) | 17 |\n| ref(storage[, path])` / `ref(parent, path) | | `ref(parent, child)` joins relative to parent's `fullPath` | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L2) (\"ref(parent, child) joins relative to the parent\") | 18 |\n| ref(storage[, path])` / `ref(parent, path) | | `parent` chain walks back to root (each `.parent` strips one segment until empty, then `null`) | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L2) (\"parent traversal walks back to root\") | 19 |\n| ref(storage[, path])` / `ref(parent, path) | | `root` accessor returns the bucket-root ref regardless of starting depth | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L2) | 20 |\n| ref(storage[, path])` / `ref(parent, path) | | `toString()` returns `gs://<bucket>/<fullPath>` | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L2) (\"toString returns gs://bucket/path\") | 21 |\n| ref(storage[, path])` / `ref(parent, path) | | Reference identity: two `ref(s, 'a/b')` calls are equal-by-`toString` but NOT `===` (value objects, not interned) | ✓ | (implicit in [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L2) parent-chain test — each `.parent` returns a fresh object) | 22 |\n| ref(storage[, path])` / `ref(parent, path) | | References are mirror-owned value objects; `parent` and `root` preserve the same storage handle and path semantics | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L2) pins parent traversal, root identity, bucket, and path behavior through the public reference interface. | 23 |\n\n## `uploadBytes(ref, data, metadata?)` — write blob\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| uploadBytes(ref, data, metadata?) | | Accepts `Blob` payload; returns `UploadResult` with populated `metadata` | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L9) (\"accepts a Blob and round-trips through getBlob\") | 24 |\n| uploadBytes(ref, data, metadata?) | | Accepts `Uint8Array` payload | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L9) (\"accepts a Uint8Array\") | 25 |\n| uploadBytes(ref, data, metadata?) | | Accepts `ArrayBuffer` payload | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L9) (\"accepts an ArrayBuffer\") | 26 |\n| uploadBytes(ref, data, metadata?) | | ContentType precedence: caller's `metadata.contentType` > `Blob.type` > `application/octet-stream` | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L9) (\"metadata.contentType overrides the Blob's intrinsic type\" + \"falls back to application/octet-stream when no type is supplied\") | 27 |\n| uploadBytes(ref, data, metadata?) | | `Blob.type === ''` (no intrinsic type) falls through to `application/octet-stream`, NOT to `''` | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L9) (\"falls back to application/octet-stream when no type is supplied\") | 28 |\n| uploadBytes(ref, data, metadata?) | | `customMetadata` round-trips through the upload pipeline | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L9) (\"round-trips customMetadata\") + [`unit:metadata.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/metadata.test.ts#L5) | 29 |\n| uploadBytes(ref, data, metadata?) | | Empty `Blob.type` rewrap: when caller hint differs from `Blob.type`, the blob is re-wrapped with the caller's type (same bytes) | ✓ | implicit in [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L9) (\"metadata.contentType overrides the Blob's intrinsic type\") | 30 |\n| uploadBytes(ref, data, metadata?) | | Throws `storage/invalid-root-operation` when called on the root reference | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L9) (\"throws on root reference\") | 31 |\n| uploadBytes(ref, data, metadata?) | | Returned `metadata.fullPath` matches the ref's `fullPath` | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L9) | 32 |\n| uploadBytes(ref, data, metadata?) | | Returned `metadata.size` matches the input blob's byte length | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L9) | 33 |\n| uploadBytes(ref, data, metadata?) | | Returned `metadata.bucket` matches the storage handle's bucket | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L9) | 34 |\n| uploadBytes(ref, data, metadata?) | | Replaces any existing object at the path (overwrite, not append) | ✓ | [`unit:upstream-storage-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/upstream-storage-probes.test.ts#L19) (\"second uploadBytes at the same path replaces bytes and metadata\") | 35 |\n| uploadBytes(ref, data, metadata?) | | Prod: round-trips uploaded bytes through `getDownloadURL` + fetch (byte-for-byte equality) | ✓ (prod-only) | oracle: [`packages/conformance/observations/storage/storage-upload-bytes-roundtrip.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-upload-bytes-roundtrip.json) (against blockingfun, fb-js-sdk 12.13.0: 6-byte payload → uploadBytes → getDownloadURL → HTTPS fetch → `bytesMatch: true`, `urlIsHttps: true`, `bodyLen === payloadLen === 6`). This row records the production answer key; row #51 compares the sandbox's page-local URL behavior against it. | 36 |\n| uploadBytes(ref, data, metadata?) | | Returned `metadata.contentType` matches what the caller hinted (when set) | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L9) + oracle: [`packages/conformance/observations/storage/storage-upload-then-getmetadata.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-upload-then-getmetadata.json) (`contentType: 'application/octet-stream'` round-trip against blockingfun, fb-js-sdk 12.13.0; `contentTypeMatches: true`) | 37 |\n| uploadBytes(ref, data, metadata?) | | Returned `metadata.generation` / `metageneration` are stringified counters (`'1'` after fresh upload) | ✓ | [`unit:metadata.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/metadata.test.ts#L5) | 38 |\n\n## `uploadString(ref, value, format?, metadata?)` — write string-form\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| uploadString(ref, value, format?, metadata?) | | `format='raw'` (default): UTF-8 encodes the string; `contentType` defaults to `text/plain;charset=utf-8` | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L11) (\"raw format encodes UTF-8 and defaults contentType to text/plain\") | 39 |\n| uploadString(ref, value, format?, metadata?) | | `format='base64'`: decodes payload bytes from standard base64 | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L11) (\"base64 format decodes payload bytes\") | 40 |\n| uploadString(ref, value, format?, metadata?) | | Sandbox: `format='base64url'` (or any unknown format) rejected with `storage/invalid-format` naming the bad format. Prod: `base64url` is ACCEPTED (upload succeeds); a genuinely-unrecognized format throws `storage/unknown` | ⚠ | divergence, both halves oracle-locked by [`packages/conformance/observations/storage/storage-uploadstring-unknown-format.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-uploadstring-unknown-format.json): prod accepts `base64url` (`base64urlOk: true`) and throws `storage/unknown` for an unrecognized format — not `storage/invalid-format`. The v1 sandbox ships only `raw`/`base64`/`data_url` (matches `StringFormat`) and throws `storage/invalid-format` for anything else (ST-B3 replaced the old mis-parse-as-data_url behavior). Both sides pinned in [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/oracle-conformance.test.ts#L129); sandbox code path documented in `upload.ts`'s `decodeString`. Implementing base64url decoding is still one line in `decodeString`. (Structured evidence: [`error-codes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/error-codes.test.ts#L25)) | 41 |\n| uploadString(ref, value, format?, metadata?) | | `format='data_url'`: parses `data:<mime>;base64,<payload>`, infers `contentType` from prefix | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L11) (\"data_url format infers contentType from the prefix\") | 42 |\n| uploadString(ref, value, format?, metadata?) | | `format='data_url'` with non-base64 payload: percent-decodes the body | ✓ | [`unit:upstream-storage-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/upstream-storage-probes.test.ts#L20) (\"non-base64 data_url percent-decodes the body\"; malformed `%%0` → `storage/invalid-format`) | 43 |\n| uploadString(ref, value, format?, metadata?) | | Caller's `metadata.contentType` beats data_url inference | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L11) (\"caller metadata.contentType beats data_url inference\") | 44 |\n| uploadString(ref, value, format?, metadata?) | | Malformed `data_url` (no comma / doesn't start with `data:`) throws `TypeError` with \"data_url format\" message | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L11) (\"throws on malformed data_url\") | 45 |\n| uploadString(ref, value, format?, metadata?) | | `uploadString(ref, value, 'base64')` round-trips via `getDownloadURL` + fetch across in-page sandbox and SharedWorker mode | ✓ | oracle: [`packages/conformance/observations/storage/storage-uploadstring-base64-roundtrip.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-uploadstring-base64-roundtrip.json) (`'aGVsbG8='` → `'hello'` against blockingfun, fb-js-sdk 12.13.0; `textMatches: true`). Sandbox oracle replay matches production behavior in-page, and client↔host integration proves SharedWorker mode decodes and transfers string payloads faithfully over the worker port. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/oracle-conformance.test.ts#L192), [`integration.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/serve/worker/integration.test.ts#L252)) | 46 |\n\n## `uploadBytesResumable(ref, data, metadata?)` — resumable upload + task observers\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| uploadBytesResumable(ref, data, metadata?) | | Exported by `firebase/storage`; returns an `UploadTask` with `pause()` / `resume()` / `cancel()` | ⚠ | [`unit:resumable-upload.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/resumable-upload.test.ts#L14) confirms `uploadBytesResumable(ref, data)` returns an `UploadTask` supporting `pause()`, `resume()`, and `cancel()` over synthetic microtask steps. | 47 |\n| uploadBytesResumable(ref, data, metadata?) | | `task.on('state_changed', next, error, complete)` fires `next` with `{bytesTransferred, totalBytes, state}` snapshots | ⚠ | [`unit:resumable-upload.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/resumable-upload.test.ts#L14) confirms synthetic progress snapshots are emitted during task execution without requiring network transfer. | 48 |\n| uploadBytesResumable(ref, data, metadata?) | | `task.pause()` flips `state` to `'paused'`; `task.resume()` continues | ⚠ | [`unit:resumable-upload.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/resumable-upload.test.ts#L14) confirms calling `task.pause()` and `task.resume()` transitions through `'paused'` and `'running'` states deterministically. | 49 |\n| uploadBytesResumable(ref, data, metadata?) | | `task.cancel()` rejects the upload with `storage/canceled` | ⚠ | [`unit:resumable-upload.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/resumable-upload.test.ts#L14) confirms cancellation rejects with `storage/canceled` and leaves no falsely completed object in storage. | 50 |\n\n## `getDownloadURL(ref)` — read URL\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getDownloadURL(ref) | | Exported by `firebase/storage`; returns a token-signed HTTPS URL that fetches the blob | ⚠ | Implemented with a two-sided pin. Production observation [`storage-upload-bytes-roundtrip`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-upload-bytes-roundtrip.json) records `urlIsHttps: true` and a byte-identical fetch. The sandbox oracle replay now calls the same public `getDownloadURL` + `fetch` path and proves byte-identical content, while explicitly asserting its URL starts with [`blob:`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/oracle-conformance.test.ts#L170). The client↔host integration proves SharedWorker mode creates that URL in the calling page after the rules-checked Blob crosses the port. The remaining divergence is URL identity and lifetime: the sandbox URL is a page-local snapshot, not token-signed HTTPS and not shareable outside that page. (Structured evidence: [`integration.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/serve/worker/integration.test.ts#L237)) | 51 |\n| getDownloadURL(ref) | | Throws `storage/object-not-found` for missing objects | ✓ | Production observation [`storage-delete-then-get-throws`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-delete-then-get-throws.json) records `getDownloadURL` throwing `storage/object-not-found` after deletion. The sandbox oracle replay now invokes `getDownloadURL` itself and matches that code; the public error-code suite also pins the never-existing-object case. (Structured evidence: [`error-codes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/error-codes.test.ts#L28), [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/oracle-conformance.test.ts#L110)) | 52 |\n\n## `getBytes(ref, maxDownloadSize?)` — read as ArrayBuffer\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getBytes(ref, maxDownloadSize?) | | Returns the blob's contents as an `ArrayBuffer` | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L12) (\"accepts a Uint8Array\" round-trip via `getBytes`) | 53 |\n| getBytes(ref, maxDownloadSize?) | | Throws `storage/object-not-found` when no object exists at the path | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L12) (\"throws storage/object-not-found for missing paths\") + oracle: [`packages/conformance/observations/storage/storage-delete-then-get-throws.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-delete-then-get-throws.json) (against blockingfun, fb-js-sdk 12.13.0: upload → delete → `getDownloadURL` on the deleted ref throws `FirebaseError` with `code: 'storage/object-not-found'`) | 54 |\n| getBytes(ref, maxDownloadSize?) | | When the object exceeds `maxDownloadSize`, returns a truncated prefix of that byte length (does not throw) | ✓ | [`unit:upstream-storage-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/upstream-storage-probes.test.ts#L15) (\"getBytes / getBlob return a truncated prefix when the object exceeds the cap\"). Matches upstream `getBytesInternal` / `getBlobInternal` post-fetch slice (GCS may ignore Range on small files). Prior COMPAT claim that the cap throws was wrong. | 55 |\n| getBytes(ref, maxDownloadSize?) | | Just-under-cap reads succeed and return the full byte length | ✓ | [`unit:upstream-storage-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/upstream-storage-probes.test.ts#L15) (\"just-under-cap reads return the full object\") + [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L12) (\"honors maxDownloadSizeBytes when the blob is too large\") | 56 |\n| getBytes(ref, maxDownloadSize?) | | Throws `storage/invalid-root-operation` when called on the root reference | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L12) (\"throws invalid-root-operation on root reads\") | 57 |\n\n## `getBlob(ref, maxDownloadSize?)` — read as Blob\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getBlob(ref, maxDownloadSize?) | | Returns the stored bytes wrapped as a `Blob` (with `.type` from metadata) | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L12) (\"accepts a Blob and round-trips through getBlob\") | 58 |\n| getBlob(ref, maxDownloadSize?) | | Throws `storage/object-not-found` for missing paths | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L12) (\"throws storage/object-not-found for missing paths\") | 59 |\n| getBlob(ref, maxDownloadSize?) | | Honors `maxDownloadSize` same as `getBytes` | ✓ | [`unit:upstream-storage-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/upstream-storage-probes.test.ts#L14) (\"getBytes / getBlob return a truncated prefix when the object exceeds the cap\"; shared `fetchBlob` helper in `download.ts`) | 60 |\n| getBlob(ref, maxDownloadSize?) | | Root-ref read throws `storage/invalid-root-operation` | ✓ | shared via `guardNonRoot` in `download.ts` | 61 |\n\n## `getStream(ref, maxDownloadSize?)` — Node-specific\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getStream(ref, maxDownloadSize?) | | Exported by `firebase/storage` (Node entry only); returns a Node `Readable` | — | No `pyric/storage` implementation or behavioral probe exists; availability is classified by the owning surface contract. | 62 |\n\n## `deleteObject(ref)` — delete\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| deleteObject(ref) | | Removes both the blob AND the metadata atomically (post-delete `getBlob` throws `object-not-found`) | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L14) (\"removes both blob and metadata\") | 63 |\n| deleteObject(ref) | | Sandbox: no-op on missing path (does NOT throw) | ⚠ | divergence: sandbox is no-op via `persistence.ts`'s `delete`. Prod's `deleteObject` on a missing path throws `storage/object-not-found`. Oracle-locked: [`packages/conformance/observations/storage/storage-delete-missing-throws.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-delete-missing-throws.json) (`code: 'storage/object-not-found'`, `name: 'FirebaseError'` against blockingfun, fb-js-sdk 12.13.0). Both sides pinned in [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/oracle-conformance.test.ts#L91); documented in `download.ts`. | 64 |\n| deleteObject(ref) | | Throws `storage/invalid-root-operation` on the root reference | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L14) (\"throws invalid-root-operation on root\") | 65 |\n| deleteObject(ref) | | Prod: a successful `deleteObject` followed by `getDownloadURL` on the same ref throws `storage/object-not-found` | ✓ (prod-only) | oracle: [`packages/conformance/observations/storage/storage-delete-then-get-throws.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-delete-then-get-throws.json) (against blockingfun, fb-js-sdk 12.13.0: upload + delete succeed, then `getDownloadURL` throws `code: 'storage/object-not-found'`, message `\"Firebase Storage: Object '…' does not exist.\"`, `isFirebaseError: true`) | 66 |\n| deleteObject(ref) | | Sandbox: writes-then-delete leaves no metadata (post-delete `getMetadata` throws `object-not-found`) | ✓ | follows from #63 + `getMetadata` | 67 |\n\n## `listAll(ref)` — list all children under a ref\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| listAll(ref) | | Returns `ListResult` with `items` (direct child files) + `prefixes` (sub-folder refs) + `nextPageToken: undefined` | ✓ | [`unit:list.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/list.test.ts#L2) | 68 |\n| listAll(ref) | | Empty bucket → both arrays empty, `nextPageToken: undefined` | ✓ | [`unit:list.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/list.test.ts#L2) (\"returns empty arrays on an empty bucket\") | 69 |\n| listAll(ref) | | Direct children only — does NOT recurse into grandchildren as items | ✓ | [`unit:list.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/list.test.ts#L2) (\"does not recurse into grandchildren as items\") | 70 |\n| listAll(ref) | | Sub-folders surface as `prefixes` and are deduplicated (many files under one folder → ONE prefix entry) | ✓ | [`unit:list.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/list.test.ts#L2) (\"promotes sub-folders into prefixes (deduplicated)\") | 71 |\n| listAll(ref) | | `items` sorted by path (IDB key order, lexicographic) | ✓ | [`unit:list.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/list.test.ts#L2) (\"lists direct children of a folder\") | 72 |\n| listAll(ref) | | `prefixes` sorted lexicographically by `fullPath` (for determinism) | ✓ | [`unit:list.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/list.test.ts#L2) (root-scan example asserts `configs` < `sessions`) | 73 |\n| listAll(ref) | | The scanned ref itself is NEVER included in `items` (even when an object exists at the exact prefix path) | ✓ | [`unit:list.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/list.test.ts#L2) (\"does not include the scanned ref itself\") | 74 |\n| listAll(ref) | | `listAll(ref(storage))` (root) scans the entire bucket | ✓ | [`unit:list.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/list.test.ts#L2) (\"listAll on the root scans the entire bucket\") | 75 |\n| listAll(ref) | | Items expose the full `StorageReference` shape (storage, bucket, name, parent) | ✓ | [`unit:list.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/list.test.ts#L2) (\"items expose the StorageReference shape\") | 76 |\n| listAll(ref) | | Prod: items + prefixes shape matches sandbox after `N` uploads under a directory | ✓ | oracle: [`packages/conformance/observations/storage/storage-listall-shape.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-listall-shape.json) (against blockingfun, fb-js-sdk 12.13.0: 3 direct children + 1 grandchild → `items` has all 3 direct children sorted, `prefixes` has the single sub-folder, `itemCount: 3`, `prefixCount: 1`, `threeDirectChildren: true`, `oneSubPrefix: true`) | 77 |\n| listAll(ref) | | `listAll` enforces rules: `read` permission on the scanned prefix path governs list (Firebase: `read` covers download AND list), denied prefix → `storage/unauthorized` | ✓ | ST-B2 fixed: `list.ts` now calls `enforceRules` with `method: 'read'` on the listed prefix (was a silent bypass — a denied tree was still fully enumerable). With no rules configured the check is a no-op. Probe: [`unit:list-rules.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/list-rules.test.ts#L2) (\"denies an anonymous listAll of a tree the rules protect\" / \"allows an authed listAll\"). Note: a `read` rule scoped to `match /sessions/{id}` does NOT grant list on `/sessions` — the folder needs its own read rule, matching prod; the session-archive demo ruleset adds `match /sessions { allow read }`. | 77a |\n\n## `list(ref, options?)` — paginated list\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| list(ref, options?) | | Exported by `firebase/storage`; accepts `{ maxResults, pageToken }`, returns a `ListResult` with `nextPageToken` set when more pages remain | — | No paginated `list` implementation or behavioral probe exists; availability is classified by the owning surface contract. `ListResult.nextPageToken` remains optional for forward-compatible consumer code. | 78 |\n\n## `getMetadata(ref)` / `updateMetadata(ref, metadata)` — metadata ops\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | `getMetadata(ref)` returns the same `FullMetadata` shape `uploadBytes` produced | ✓ | [`unit:metadata.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/metadata.test.ts#L2) (\"returns the FullMetadata uploadBytes wrote\") | 79 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | `getMetadata(ref)` throws `storage/object-not-found` for missing paths | ✓ | [`unit:metadata.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/metadata.test.ts#L2) (\"throws object-not-found for missing paths\") | 80 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | `getMetadata(ref)` throws `storage/invalid-root-operation` on the root | ✓ | [`unit:metadata.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/metadata.test.ts#L2) (\"throws invalid-root-operation on the root reference\") | 81 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | `updateMetadata(ref, patch)` replaces the listed client-settable fields wholesale (per Firebase semantics) | ✓ | [`unit:metadata.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/metadata.test.ts#L2) (\"replaces settable fields, bumps metageneration, refreshes updated\") | 82 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | `updateMetadata` bumps `metageneration` by 1 on each call | ✓ | [`unit:metadata.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/metadata.test.ts#L2) | 83 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | `updateMetadata` refreshes `updated` to the call moment; `timeCreated` and `generation` stay pinned | ✓ | [`unit:metadata.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/metadata.test.ts#L2) | 84 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | `updateMetadata` preserves the blob bytes (only metadata changes) | ✓ | [`unit:metadata.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/metadata.test.ts#L2) (\"leaves the blob content untouched\") | 85 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | `updateMetadata` with `undefined` field values preserves the prior value (does NOT clear it) | ⚠ | divergence: prod accepts `null` to explicitly clear a field. Sandbox doesn't model `null`-clear (per `metadata.ts` doc comment). Documented; not probe-locked. | 86 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | `updateMetadata` throws `storage/object-not-found` for missing paths | ✓ | [`unit:metadata.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/metadata.test.ts#L2) (\"throws object-not-found when the path is missing\") | 87 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | `updateMetadata` throws `storage/invalid-root-operation` on the root | ✓ | [`unit:metadata.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/metadata.test.ts#L2) (\"throws invalid-root-operation on the root reference\") | 88 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | Prod: `getMetadata` after `uploadBytes` returns `contentType` and `size` matching what was uploaded | ✓ (prod-only) | oracle: [`packages/conformance/observations/storage/storage-upload-then-getmetadata.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-upload-then-getmetadata.json) (against blockingfun, fb-js-sdk 12.13.0: upload 128-byte payload with `contentType: 'application/octet-stream'`, getMetadata returns `metadataSize: 128`, `metadataContentType: 'application/octet-stream'`, `metadataBucket: 'blockingfun.firebasestorage.app'`, `metadataMetageneration: '1'`, `fullPathMatches: true`) | 89 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | Prod: `updateMetadata({customMetadata: {...}})` round-trips through a follow-up `getMetadata` | ✓ (prod-only) | oracle: [`packages/conformance/observations/storage/storage-update-metadata-roundtrip.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-update-metadata-roundtrip.json) (against blockingfun, fb-js-sdk 12.13.0: post-update `getMetadata` returns the exact `customMetadata` object, `metageneration` bumps `'1'` → `'2'`, `customSurvived: true`, `metagenerationBumped: true`) | 90 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | `FullMetadata.md5Hash` populated on uploads | ⚠ | divergence: sandbox does NOT compute `md5Hash`. Oracle-locked: [`packages/conformance/observations/storage/storage-upload-then-getmetadata.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-upload-then-getmetadata.json) confirms prod sets `md5Hash` (`hasMd5Hash: true` after a vanilla `uploadBytes`). Both sides pinned in [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/oracle-conformance.test.ts#L207). Aligning the sandbox is a one-spot fix in `upload.ts`'s `buildStoredMetadata`. | 91 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | `FullMetadata.ref` lazy population (prod populates lazily) | — | not modeled in `pyric/storage` — `metadata.ts` explicitly omits `ref` from `FullMetadata` | 92 |\n\n## `connectStorageEmulator(storage, host, port)` — emulator hook\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| connectStorageEmulator(storage, host, port) | | Exported by `firebase/storage`; reroutes a `FirebaseStorage` handle to a local emulator | — | not implemented in `pyric/storage` — the sandbox IS the local-target alternative; emulator parity is out of scope per `index.ts` | 93 |\n\n## Op-level rules enforcement — a denied op throws `storage/unauthorized`\n\nThese are Storage SDK behaviors: how an upload / read / metadata / delete op\nsurfaces a rules deny verdict. The rules-engine fidelity rows\n(`parseStorageRules` / `evaluateStorageRules` vs the production Rules Test\nAPI) moved to the native `storage-rules` surface (`docs/rules/COMPAT.md`).\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Rules enforcement | | Op-level enforcement: `uploadBytes` against a denied path throws `storage/unauthorized` on sandbox / `storage/unauthorized` on prod, `.code` exposed on both | ✓ | ST-B1 fixed: sandbox now throws a `StorageError` (see `src/storage/errors.ts`) whose `.code === 'storage/unauthorized'` — matching prod's `FirebaseError.code`. Probe: [`unit:error-codes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/error-codes.test.ts#L15) (\"unauthorized when rules deny the operation\"). Residual divergence (documented, not a `.code` gap): the sandbox `StorageError.name` is `'StorageError'` (plain `Error` subclass, same shape as Firestore's `SandboxError`) where prod reports `name: 'FirebaseError'` / `isFirebaseError: true`, and the message wording differs (sandbox embeds the matched-rule reason chain). Oracle-locked: [`packages/conformance/observations/storage/storage-rules-denied-error-code.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-rules-denied-error-code.json) (against blockingfun, fb-js-sdk 12.13.0: `code: 'storage/unauthorized'`, message `\"Firebase Storage: User does not have permission to access '<path>'.\"`, `name: 'FirebaseError'`, `isFirebaseError: true`). | 105 |\n| Rules enforcement | | `getMetadata` against a denied path throws `storage/unauthorized` | ✓ | [`unit:storage/enforce.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/enforce.test.ts#L4) (operation-integration section) | 106 |\n| Rules enforcement | | `updateMetadata` against a denied path throws `storage/unauthorized` | ✓ | [`unit:storage/enforce.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/enforce.test.ts#L4) | 107 |\n| Rules enforcement | | `deleteObject` against a denied path throws `storage/unauthorized` | ✓ | [`unit:storage/enforce.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/enforce.test.ts#L4) | 108 |\n| Rules enforcement | | Ordinary SDK object paths are evaluated under Firebase Storage's canonical `/b/{bucket}/o/{object}` rules namespace while metadata preserves the ordinary object path | ✓ | [`unit:storage/enforce.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/enforce.test.ts#L4) (\"maps ordinary SDK object paths into the canonical bucket rules namespace\"). Production's canonical `/b/{bucket}/o/...` namespace is independently captured on the `storage-rules` surface; this adapter mapping seam is unit-backed rather than presented as a production observation. | 109 |\n\n## Current gaps\n\n### Documented divergences\n\nKnown differences between Pyric and production Firebase. Each remains tracked as a non-conforming row.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?)</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>getStorage(app, bucketUrl?)</code> accepts Firebase's bucket argument; the sandbox remains bound to its configured single bucket</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Package resolution owns production selection. The sandbox accepts the canonical argument but does not model production multi-bucket routing; <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/service.test.ts#L4\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:service.test.ts</code></a> pins the configured <code>pyric-default</code> bucket.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">uploadString(ref, value, format?, metadata?)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Sandbox: <code>format='base64url'</code> (or any unknown format) rejected with <code>storage/invalid-format</code> naming the bad format. Prod: <code>base64url</code> is ACCEPTED (upload succeeds); a genuinely-unrecognized format throws <code>storage/unknown</code></span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence, both halves oracle-locked by <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-uploadstring-unknown-format.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>packages/conformance/observations/storage/storage-uploadstring-unknown-format.json</code></a>: prod accepts <code>base64url</code> (<code>base64urlOk: true</code>) and throws <code>storage/unknown</code> for an unrecognized format — not <code>storage/invalid-format</code>. The v1 sandbox ships only <code>raw</code>/<code>base64</code>/<code>data_url</code> (matches <code>StringFormat</code>) and throws <code>storage/invalid-format</code> for anything else (ST-B3 replaced the old mis-parse-as-data_url behavior). Both sides pinned in <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/oracle-conformance.test.ts#L129\" target=\"_blank\" rel=\"noopener noreferrer\"><code>oracle-conformance.test.ts</code></a>; sandbox code path documented in <code>upload.ts</code>'s <code>decodeString</code>. Implementing base64url decoding is still one line in <code>decodeString</code>. (Structured evidence: <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/error-codes.test.ts#L25\" target=\"_blank\" rel=\"noopener noreferrer\"><code>error-codes.test.ts</code></a>)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">uploadBytesResumable(ref, data, metadata?)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Exported by <code>firebase/storage</code>; returns an <code>UploadTask</code> with <code>pause()</code> / <code>resume()</code> / <code>cancel()</code></span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/resumable-upload.test.ts#L14\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:resumable-upload.test.ts</code></a> confirms <code>uploadBytesResumable(ref, data)</code> returns an <code>UploadTask</code> supporting <code>pause()</code>, <code>resume()</code>, and <code>cancel()</code> over synthetic microtask steps.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">uploadBytesResumable(ref, data, metadata?)</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>task.on('state_changed', next, error, complete)</code> fires <code>next</code> with <code>{bytesTransferred, totalBytes, state}</code> snapshots</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/resumable-upload.test.ts#L14\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:resumable-upload.test.ts</code></a> confirms synthetic progress snapshots are emitted during task execution without requiring network transfer.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">uploadBytesResumable(ref, data, metadata?)</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>task.pause()</code> flips <code>state</code> to <code>'paused'</code>; <code>task.resume()</code> continues</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/resumable-upload.test.ts#L14\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:resumable-upload.test.ts</code></a> confirms calling <code>task.pause()</code> and <code>task.resume()</code> transitions through <code>'paused'</code> and <code>'running'</code> states deterministically.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">uploadBytesResumable(ref, data, metadata?)</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>task.cancel()</code> rejects the upload with <code>storage/canceled</code></span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/resumable-upload.test.ts#L14\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:resumable-upload.test.ts</code></a> confirms cancellation rejects with <code>storage/canceled</code> and leaves no falsely completed object in storage.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">getDownloadURL(ref)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Exported by <code>firebase/storage</code>; returns a token-signed HTTPS URL that fetches the blob</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Implemented with a two-sided pin. Production observation <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-upload-bytes-roundtrip.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>storage-upload-bytes-roundtrip</code></a> records <code>urlIsHttps: true</code> and a byte-identical fetch. The sandbox oracle replay now calls the same public <code>getDownloadURL</code> + <code>fetch</code> path and proves byte-identical content, while explicitly asserting its URL starts with <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/oracle-conformance.test.ts#L170\" target=\"_blank\" rel=\"noopener noreferrer\"><code>blob:</code></a>. The client↔host integration proves SharedWorker mode creates that URL in the calling page after the rules-checked Blob crosses the port. The remaining divergence is URL identity and lifetime: the sandbox URL is a page-local snapshot, not token-signed HTTPS and not shareable outside that page. (Structured evidence: <a href=\"https://github.com/davideast/pyric/blob/main/packages/cli/test/serve/worker/integration.test.ts#L237\" target=\"_blank\" rel=\"noopener noreferrer\"><code>integration.test.ts</code></a>)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">deleteObject(ref)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Sandbox: no-op on missing path (does NOT throw)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence: sandbox is no-op via <code>persistence.ts</code>'s <code>delete</code>. Prod's <code>deleteObject</code> on a missing path throws <code>storage/object-not-found</code>. Oracle-locked: <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-delete-missing-throws.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>packages/conformance/observations/storage/storage-delete-missing-throws.json</code></a> (<code>code: 'storage/object-not-found'</code>, <code>name: 'FirebaseError'</code> against blockingfun, fb-js-sdk 12.13.0). Both sides pinned in <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/oracle-conformance.test.ts#L91\" target=\"_blank\" rel=\"noopener noreferrer\"><code>oracle-conformance.test.ts</code></a>; documented in <code>download.ts</code>.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">getMetadata(ref)` / `updateMetadata(ref, metadata)</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>updateMetadata</code> with <code>undefined</code> field values preserves the prior value (does NOT clear it)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence: prod accepts <code>null</code> to explicitly clear a field. Sandbox doesn't model <code>null</code>-clear (per <code>metadata.ts</code> doc comment). Documented; not probe-locked.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">getMetadata(ref)` / `updateMetadata(ref, metadata)</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>FullMetadata.md5Hash</code> populated on uploads</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence: sandbox does NOT compute <code>md5Hash</code>. Oracle-locked: <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-upload-then-getmetadata.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>packages/conformance/observations/storage/storage-upload-then-getmetadata.json</code></a> confirms prod sets <code>md5Hash</code> (<code>hasMd5Hash: true</code> after a vanilla <code>uploadBytes</code>). Both sides pinned in <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/oracle-conformance.test.ts#L207\" target=\"_blank\" rel=\"noopener noreferrer\"><code>oracle-conformance.test.ts</code></a>. Aligning the sandbox is a one-spot fix in <code>upload.ts</code>'s <code>buildStoredMetadata</code>.</div></div></details>\n</div>\n\n### Not implemented yet\n\nTracked behavior that is not implemented in the current contract.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"unsupported\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">getStream(ref, maxDownloadSize?)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Exported by <code>firebase/storage</code> (Node entry only); returns a Node <code>Readable</code></span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">No <code>pyric/storage</code> implementation or behavioral probe exists; availability is classified by the owning surface contract.</div></div></details>\n<details class=\"compat-row\" data-status=\"unsupported\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">list(ref, options?)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Exported by <code>firebase/storage</code>; accepts <code>{ maxResults, pageToken }</code>, returns a <code>ListResult</code> with <code>nextPageToken</code> set when more pages remain</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">No paginated <code>list</code> implementation or behavioral probe exists; availability is classified by the owning surface contract. <code>ListResult.nextPageToken</code> remains optional for forward-compatible consumer code.</div></div></details>\n<details class=\"compat-row\" data-status=\"unsupported\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">getMetadata(ref)` / `updateMetadata(ref, metadata)</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>FullMetadata.ref</code> lazy population (prod populates lazily)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">not modeled in <code>pyric/storage</code> — <code>metadata.ts</code> explicitly omits <code>ref</code> from <code>FullMetadata</code></div></div></details>\n<details class=\"compat-row\" data-status=\"unsupported\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">connectStorageEmulator(storage, host, port)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Exported by <code>firebase/storage</code>; reroutes a <code>FirebaseStorage</code> handle to a local emulator</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">not implemented in <code>pyric/storage</code> — the sandbox IS the local-target alternative; emulator parity is out of scope per <code>index.ts</code></div></div></details>\n</div>\n\n## Reviewed public-runtime gaps\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">storage.runtime-enum-values</code><span class=\"compat-sub\"><span class=\"compat-behavior\">StorageErrorCode and StringFormat are mirrored as TypeScript types but not as Firebase-compatible runtime enum objects, so value imports remain unavailable.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>StorageErrorCode</code> <code>StringFormat</code></div>\n<div class=\"compat-note\">upstream:firebase/storage</div></div>\n</details>\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">storage.node-stream</code><span class=\"compat-sub\"><span class=\"compat-behavior\">The Node-stream variant is deferred — not part of the browser-shaped v1 scope yet, but not genuinely un-modelable.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>getStream</code></div>\n<div class=\"compat-note\">registry:storage#62</div></div>\n</details>\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">storage.paginated-listing</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Paginated listing is deferred — listAll covers the v1 scope; pagination needs a stable pageToken shape, which is unbuilt design work rather than a sandbox limitation.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>list</code></div>\n<div class=\"compat-note\">registry:storage#78</div></div>\n</details>\n</div>\n" }, { "slug": "messaging-compat", "label": "Messaging", "title": "pyric messaging compatibility matrix", "markdown": "<!-- Generated from the conformance model (registry rows + surface contracts). Do not edit by hand; run bun run compat:generate. -->\n\n# `pyric` messaging compatibility matrix\n\n<div class=\"compat-stat\">\n<p class=\"compat-stat-figure\">\n<span class=\"compat-stat-pct\">100%</span>\n<span class=\"compat-stat-label\">of the public API supported</span>\n</p>\n<div class=\"compat-meters\">\n<span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 100%\"></span></span>\n</div>\n<p class=\"compat-stat-denom\">13 of 13 public API</p>\n</div>\n\n\n## Status legend\n\n| Status | Meaning |\n|---|---|\n| ✓ | **Conforming** — sandbox matches prod, locked by a passing probe |\n| — | **Not implemented yet** — deliberately or pending |\n| ? | **Unverified** — a target with a derived failing test, not a guarantee |\n\n## `firebase/messaging` (client)\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getMessaging(app?): Messaging | | Returns the FCM `Messaging` instance associated with the given (or default) `FirebaseApp`. Bound to the client component registered under the name `messaging`. | ✓ | Upstream typings/JSDoc (firebase 12.13.0, `@firebase/messaging` 0.12.26); in-process mirror suite plus canonical-import SharedWorker replay [`messaging-app-boundary.pw.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/e2e/messaging-app-boundary.pw.ts#L3). (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L63)) | 1 |\n| getToken(messaging, options?): Promise<string> | | Subscribes the instance to push and resolves with an FCM registration token; requests notification permission if not already granted and rejects if denied. Production tokens are colon-separated, URL-safe, ~142 chars, with the suffix after the colon beginning `APA91b`, and are stable across repeated `getToken` calls on the same service-worker registration (no per-call rotation). | ✓ | oracle: [`messaging-web-token-shape.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging/messaging-web-token-shape.json) (minted, length 142, colon-separated, suffix starts `APA91b`, URL-safe) + [`messaging-web-token-stability.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging/messaging-web-token-stability.json) (second `getToken` on the same registration returns the same token). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L125)) | 2 |\n| deleteToken(messaging): Promise<boolean> | | Deletes the registration token and unsubscribes the instance from its push subscription; resolves truthy. After deletion no message reaches the client on either route, and a server send to the now-dead token eventually surfaces the UNREGISTERED / 404-class error on the send plane (propagation is asynchronous — the first send after delete may still be accepted while delivery has already stopped). | ✓ | oracle: [`messaging-web-deletetoken-unregistered.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging/messaging-web-deletetoken-unregistered.json) (deleteToken resolved truthy; no delivery to client; send plane eventually UNREGISTERED). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L145)) | 3 |\n| onMessage(messaging, nextOrObserver): Unsubscribe | | Dispatched with the push payload when a message arrives while a window client is visible; the returned function stops listening. Routing keys on page VISIBILITY, not focus: a `visibilityState: \"visible\"` page receives `onMessage` even when unfocused, and when no window client is visible the message routes to the service-worker `onBackgroundMessage` instead. | ✓ | oracle: [`messaging-web-onmessage-foreground.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging/messaging-web-onmessage-foreground.json) (focused page → onMessage) + [`messaging-web-visibility-routing.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging/messaging-web-visibility-routing.json) (visible → onMessage, no visible client → onBackgroundMessage). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L192)) | 4 |\n| isSupported(): Promise<boolean> | | Resolves whether every API required by FCM exists in the current browser window context (bound to `isWindowSupported`). | ✓ | Upstream typings/JSDoc (`@firebase/messaging` 0.12.26); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L231)) | 5 |\n| interface Messaging { app } | | Public interface of the FCM client SDK; exposes the bound `FirebaseApp` as `app`. | ✓ | Upstream typings (`@firebase/messaging` 0.12.26 `public-types`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L238)) | 6 |\n| interface GetTokenOptions { vapidKey?; serviceWorkerRegistration? } | | Options for `getToken`: an optional `vapidKey` (Web Push certificate public key) and an optional `serviceWorkerRegistration`. | ✓ | Upstream typings (`@firebase/messaging` 0.12.26 `public-types`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L245)) | 7 |\n| interface MessagePayload { notification?; data?; fcmOptions?; from; collapseKey; messageId } | | Received message envelope delivered to `onMessage` / `onBackgroundMessage`. Production deliveries carry top-level keys `data`, `from`, `messageId`, and `notification`; `from` equals the project messaging sender id and `messageId` is present. (`from`, `collapseKey`, `messageId` are typed as required.) | ✓ | oracle: [`messaging-web-onmessage-foreground.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging/messaging-web-onmessage-foreground.json) + [`messaging-web-onbackgroundmessage.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging/messaging-web-onbackgroundmessage.json) (top-level keys data/from/messageId/notification; from = sender id; messageId present). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L197)) | 8 |\n| interface NotificationPayload { title?; body?; image?; icon? } | | Display-notification block inside a `MessagePayload`. Production foreground deliveries carry a `notification` object whose keys include `title` and `body`. | ✓ | oracle: [`messaging-web-onmessage-foreground.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging/messaging-web-onmessage-foreground.json) (notificationKeys body, title). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L197)) | 9 |\n| interface FcmOptions { link?; analyticsLabel? } | | WebpushFcmOptions-style options carried on a client `MessagePayload` (`link`, `analyticsLabel`). | ✓ | Upstream typings (`@firebase/messaging` 0.12.26 `public-types`); no observation yet — type-only shape closed by the assignability census (resolved decision #5). | 10 |\n| NextFn, Observer, Unsubscribe (re-exported from @firebase/util) | | The callback, observer, and teardown types consumed by `onMessage` / `onBackgroundMessage` are re-exported from `@firebase/util`. | ✓ | Upstream typings (`@firebase/messaging` 0.12.26 re-exports); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L340)) | 11 |\n| const enum ErrorCode (client) | | Client failures surface as thrown `FirebaseError`s carrying one of the 18 documented `ErrorCode` values (`missing-app-config-values`, `only-available-in-window`, `only-available-in-sw`, `permission-default`, `permission-blocked`, `unsupported-browser`, `indexed-db-unsupported`, `failed-service-worker-registration`, `token-subscribe-failed`, `token-subscribe-no-token`, `token-unsubscribe-failed`, `token-update-failed`, `token-update-no-token`, `invalid-bg-handler`, `use-sw-after-get-token`, `invalid-sw-registration`, `use-vapid-key-after-get-token`, `invalid-vapid-key`). The enum itself is `@internal`, not a public export. | ✓ | Upstream typings (`@firebase/messaging` 0.12.26 `src/util/errors`); no client-error observation among the committed set. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L372)) | 12 |\n\n## `firebase/messaging/sw` (service worker)\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getMessaging(app?): Messaging (sw) | | Returns the FCM instance within a service-worker context (bound to `getMessagingInSw`); registers under the component name `messaging-sw`. | ✓ | Upstream typings (`@firebase/messaging` 0.12.26 `sw/index-public`) plus real module-ServiceWorker served-entry replay [`messaging-app-boundary.pw.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/e2e/messaging-app-boundary.pw.ts#L3). (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L380)) | 13 |\n| onBackgroundMessage(messaging, nextOrObserver): Unsubscribe | | Called when a message arrives while the app has no visible window client. Production routes background deliveries here rather than to `onMessage`; the delivered payload carries `data` / `from` / `messageId` and, for notification messages, a `notification` block. A DATA-ONLY message still fires `onBackgroundMessage` with no `notification` key, and a registered handler suppresses the SDK auto-display. | ✓ | oracle: [`messaging-web-onbackgroundmessage.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging/messaging-web-onbackgroundmessage.json) (no visible client → onBackgroundMessage) + [`messaging-web-visibility-routing.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging/messaging-web-visibility-routing.json) + [`messaging-web-data-only-background.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging/messaging-web-data-only-background.json) (data-only fires, no notification key). Replayed by the conformance suite and by a real module Service Worker connected to the canonical SharedWorker broker in [`messaging-app-boundary.pw.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/e2e/messaging-app-boundary.pw.ts#L73). (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L198)) | 14 |\n| experimentalSetDeliveryMetricsExportedToBigQueryEnabled(messaging, enable): void | | Enables or disables delivery-metrics export to BigQuery at runtime; default off. | ✓ | Upstream typings (`@firebase/messaging` 0.12.26 `sw/index-public`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L416)) | 15 |\n| isSupported(): Promise<boolean> (sw) | | Resolves whether every API required by FCM exists within the service-worker context (bound to `isSwSupported`). | ✓ | Upstream typings (`@firebase/messaging` 0.12.26 `sw/index-public`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L422)) | 16 |\n| firebase/messaging/sw module boundary + shared type parity | | The sw entry exports `onBackgroundMessage`, `getMessaging`, `experimentalSetDeliveryMetricsExportedToBigQueryEnabled`, and `isSupported`, but NOT `getToken` / `deleteToken` / `onMessage`; the client entry exports the latter but NOT `onBackgroundMessage` / the metrics toggle. The two modules register under different component names (`messaging` vs `messaging-sw`) and re-export identical `Messaging` / `GetTokenOptions` / `MessagePayload` / `NotificationPayload` / `FcmOptions` type declarations. | ✓ | Upstream typings (`@firebase/messaging` 0.12.26 `index.d.ts` / `index.sw.d.ts`) plus Window and real module-ServiceWorker boundary replay [`messaging-app-boundary.pw.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/e2e/messaging-app-boundary.pw.ts#L3). (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L429)) | 17 |\n\n## Behavior classes over time & across clients (client receive plane)\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Multi-tab delivery routing (several window clients, one shared service worker) | | When several window clients (browser tabs) of one origin share a single service-worker registration, a foreground delivery reaches every visible tab through `onMessage`, and when no tab is visible the one shared service worker receives the message once through `onBackgroundMessage` (the worker is shared across tabs, not instantiated per tab). A notification click focuses a single existing client rather than opening a duplicate. Stated from the FCM web SDK and the Service Worker client model; no committed observation replays the multi-tab fan-out yet. | ? | Upstream documentation (`@firebase/messaging` 0.12.26 + W3C Service Worker `Clients` model); no observation yet. Candidate probe: open two tabs on one registration and deliver with one tab visible, then with both hidden — assert `onMessage` fires once per visible tab and `onBackgroundMessage` fires exactly once on the shared worker. | 18 |\n| Multi-device delivery (one app instance holding several tokens; per-token fan-out) | | A single user or app can hold several registration tokens at once — one per browser, profile, or device. A token-targeted send reaches exactly one token; a topic, condition, or multicast send fans out independently to every subscribed token, with no server-side de-duplication across a user’s devices, so each device receives its own copy. Stated from the FCM targeting model; no committed observation replays cross-device fan-out yet. | ? | Upstream documentation (FCM targeting model); no observation yet. Candidate probe: mint two distinct tokens, subscribe both to one topic, send once, and assert each token receives the message independently. | 19 |\n| Registration-token rotation and refresh over the app lifecycle | | A registration token is stable within a session (row messaging#2 pins that repeated `getToken` calls on one service-worker registration return the same token) but is not permanent across the app lifecycle: the SDK rotates it on events such as app reinstall, SDK upgrade, a restored backup, or a notification-permission change, and Google may retire a token server-side. The modular web SDK has no token-refresh callback, so an app observes a new token only by calling `getToken` again; a rotated-away old token eventually stops delivering and the send plane reports the dead token (the UNREGISTERED path pinned by messaging#3). This lifecycle rotation is deliberately outside the within-session stability observation. Stated from FCM token-management documentation; no committed observation replays rotation across lifecycle events yet. | ? | Upstream documentation (FCM token-management guidance); the committed `messaging-web-token-stability` observation covers only within-session stability, not lifecycle rotation. Candidate probe (long-horizon): force a service-worker unregister/re-register or an app-instance reset, re-mint via `getToken`, and record whether the token changed and whether the prior token then reports UNREGISTERED on send. | 20 |\n| Registration-token inactivity expiry (server-side staleness window) | | FCM treats a registration token that has gone unused for an extended period (Google documents roughly 270 days of app-instance inactivity) as stale and drops it from delivery targeting, independent of any explicit `deleteToken`. The expiry clock is Google server-side inactivity accounting, and the recommended mitigation is periodic token refresh. Stated from FCM best-practices documentation; the sandbox has no server-side inactivity clock to model. | — | Upstream documentation (FCM registration-token best practices); no observation. This time-based server-side behavior is out of sandbox scope (see the row disposition); the explicit-deletion path to a dead token is already tracked by messaging#3. | 21 |\n\n## `firebase-admin/messaging` — entry + `Messaging` class\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getMessaging(app?): Messaging | | Returns the `Messaging` service for the default or given admin `App`. | ✓ | Upstream typings/JSDoc (firebase-admin 13.10.0 `lib/messaging/index`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L63)) | 1 |\n| messaging(app?): messaging.Messaging | | Namespaced / legacy accessor equivalent of `getMessaging`, exposed by the compat entry alongside the `namespace messaging` type aliases. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-namespace`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L181)) | 2 |\n| Messaging.get app(): App | | The admin `App` this `Messaging` instance is bound to. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L156)) | 3 |\n| Messaging.send(message, dryRun?): Promise<string> | | Sends one message via FCM v1 and resolves with the resource name `projects/<projectId>/messages/<numeric id>`. `dryRun=true` returns the SAME shape (fake id), so callers cannot distinguish validation from acceptance by shape. Topic, condition, token, notification-only, data-only, and webpush-config sends are all accepted. Malformed sends fail server-side validation with HTTP 4xx `google.rpc` error envelopes carrying both a `google.rpc.BadRequest` (fieldViolations) and a `google.firebase.fcm.v1.FcmError` (errorCode); detail ordering is not contractual. The documented data-payload cap is 4096 bytes. | ✓ | oracle: 10 send-plane observations — accept paths [`messaging-send-topic-accepted`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-topic-accepted.json), [`messaging-send-condition-accepted`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-condition-accepted.json), [`messaging-send-notification-only-vs-data-only-accepted`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-notification-only-vs-data-only-accepted.json), [`messaging-send-webpush-config-accepted`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-webpush-config-accepted.json); error envelopes [`messaging-send-no-target-error-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-no-target-error-envelope.json), [`messaging-send-invalid-token-error-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-invalid-token-error-envelope.json), [`messaging-send-invalid-condition-error-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-invalid-condition-error-envelope.json), [`messaging-send-invalid-topic-name-error-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-invalid-topic-name-error-envelope.json), [`messaging-send-oversized-payload-error-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-oversized-payload-error-envelope.json), [`messaging-send-webpush-invalid-ttl-error-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-webpush-invalid-ttl-error-envelope.json). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L192)) | 4 |\n| Messaging.sendEach(messages, dryRun?): Promise<BatchResponse> | | Sends an array of up to 500 messages, one RPC per message; resolves a `BatchResponse` whose `responses` are ordered to match the input. Total failure is signalled by a throw or an all-failure `BatchResponse`. | ✓ | Upstream typings/JSDoc (firebase-admin 13.10.0 `messaging`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L237)) | 5 |\n| Messaging.sendEachForMulticast(message, dryRun?): Promise<BatchResponse> | | Fans a `MulticastMessage` (up to 500 tokens) out through `sendEach`. | ✓ | Upstream typings/JSDoc (firebase-admin 13.10.0 `messaging`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L241)) | 6 |\n| Messaging.subscribeToTopic(tokenOrTokens, topic): Promise<MessagingTopicManagementResponse> | | Subscribes one or many registration tokens to a topic; resolves a `MessagingTopicManagementResponse`. | ✓ | Upstream typings/JSDoc (firebase-admin 13.10.0 `messaging`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L245)) | 7 |\n| Messaging.unsubscribeFromTopic(tokenOrTokens, topic): Promise<MessagingTopicManagementResponse> | | Unsubscribes one or many registration tokens from a topic; resolves a `MessagingTopicManagementResponse`. | ✓ | Upstream typings/JSDoc (firebase-admin 13.10.0 `messaging`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L249)) | 8 |\n| Messaging.enableLegacyHttpTransport(): void | | Forces HTTP/1.1 transport for `sendEach` / `sendEachForMulticast`; deprecated. | ✓ | Upstream typings/JSDoc (firebase-admin 13.10.0 `messaging`, `@deprecated`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L253)) | 9 |\n\n## `Message` union + targets\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| type Message = TokenMessage \\| TopicMessage \\| ConditionMessage | | A send payload carrying exactly one of token, topic, or condition. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L63)) | 10 |\n| interface BaseMessage { data?; notification?; android?; webpush?; apns?; fcmOptions? } | | Common message fields shared by every target variant. Production accepts a message carrying ONLY a `notification` block and, separately, ONLY a `data` block — neither is individually required. | ✓ | oracle: [`messaging-send-notification-only-vs-data-only-accepted.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-notification-only-vs-data-only-accepted.json) (both accepted). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L214)) | 11 |\n| interface TokenMessage extends BaseMessage { token: string } | | A device-token target. A syntactically invalid token is rejected with HTTP 400 INVALID_ARGUMENT whose fieldViolations name `message.token`. | ✓ | oracle: [`messaging-send-invalid-token-error-envelope.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-invalid-token-error-envelope.json) (fieldViolations names message.token). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L225)) | 12 |\n| interface TopicMessage extends BaseMessage { topic: string } | | A topic target. A well-formed topic send is accepted and returns the standard resource name (no subscribers required); a topic name containing characters outside the documented `[a-zA-Z0-9-_.~%]` set is rejected with an INVALID_ARGUMENT error envelope. | ✓ | oracle: [`messaging-send-topic-accepted.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-topic-accepted.json) (accepted) + [`messaging-send-invalid-topic-name-error-envelope.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-invalid-topic-name-error-envelope.json) (bad name rejected). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L208)) | 13 |\n| interface ConditionMessage extends BaseMessage { condition: string } | | A condition target. A well-formed condition of the form `\"'a' in topics && 'b' in topics\"` is accepted with the standard resource-name shape (no subscribers required); a malformed condition (dangling operator) is rejected with an error envelope. | ✓ | oracle: [`messaging-send-condition-accepted.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-condition-accepted.json) (accepted) + [`messaging-send-invalid-condition-error-envelope.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-invalid-condition-error-envelope.json) (malformed rejected). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L209)) | 14 |\n| interface MulticastMessage extends BaseMessage { tokens: string[] } | | A multicast target of up to 500 tokens, fanned out by `sendEachForMulticast`. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L298)) | 15 |\n\n## Payload / config option shapes\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| interface Notification { title?; body?; imageUrl? } | | Top-level, platform-independent notification block. Production accepts a notification-only message (no data block). | ✓ | oracle: [`messaging-send-notification-only-vs-data-only-accepted.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-notification-only-vs-data-only-accepted.json). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L214)) | 16 |\n| interface FcmOptions { analyticsLabel? } | | Platform-independent FCM options (`analyticsLabel`). | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L309)) | 17 |\n| interface WebpushConfig { headers?; data?; notification?; fcmOptions? } | | Webpush overrides. Production accepts a webpush config carrying `headers.TTL` and `fcmOptions.link`; a non-numeric `headers.TTL` is rejected with an error envelope. | ✓ | oracle: [`messaging-send-webpush-config-accepted.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-webpush-config-accepted.json) (accepted) + [`messaging-send-webpush-invalid-ttl-error-envelope.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-webpush-invalid-ttl-error-envelope.json) (bad TTL rejected). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L210)) | 18 |\n| interface WebpushFcmOptions { link? } | | Webpush FCM options (`link`, HTTPS required). Production accepts `fcmOptions.link` on a webpush send. | ✓ | oracle: [`messaging-send-webpush-config-accepted.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-webpush-config-accepted.json) (link accepted). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L210)) | 19 |\n| interface WebpushNotification { title?; actions?; badge?; body?; dir?; icon?; image?; renotify?; requireInteraction?; silent?; tag?; vibrate?; [key] } | | Web Notification API-shaped options, including an open-ended index signature. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L333)) | 20 |\n| interface ApnsConfig { liveActivityToken?; headers?; payload?; fcmOptions? } | | APNs overrides. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L334)) | 21 |\n| interface ApnsPayload { aps; [customData] } | | APNs payload wrapper carrying the required `aps` dictionary plus arbitrary custom keys. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L335)) | 22 |\n| interface Aps { alert?; badge?; sound?; contentAvailable?; mutableContent?; category?; threadId?; [customData] } | | APNs `aps` dictionary; `alert` is a string or an `ApsAlert`, `sound` a string or a `CriticalSound`. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L336)) | 23 |\n| interface ApsAlert { title?; subtitle?; body?; locKey?; locArgs?; ...; launchImage? } | | APNs alert object with title/subtitle/body and localization keys. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L337)) | 24 |\n| interface CriticalSound { critical?; name; volume? } | | APNs critical sound — `name` required; `volume` in the range 0.0–1.0. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L338)) | 25 |\n| interface ApnsFcmOptions { analyticsLabel?; imageUrl? } | | APNs FCM options (`analyticsLabel`, `imageUrl`). | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L339)) | 26 |\n| interface AndroidConfig { collapseKey?; priority?; ttl?; restrictedPackageName?; data?; notification?; fcmOptions?; ... } | | Android overrides; `ttl` is in milliseconds and `priority` is `high` \\| `normal`. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L340)) | 27 |\n| interface AndroidNotification { title?; body?; icon?; color?; sound?; tag?; imageUrl?; channelId?; priority?; visibility?; lightSettings?; ... } | | Android notification options, including localization keys, LED light settings, and delivery-proxy controls. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L341)) | 28 |\n| interface LightSettings { color; lightOnDurationMillis; lightOffDurationMillis } | | Android LED light settings — all three fields required. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L342)) | 29 |\n| interface AndroidFcmOptions { analyticsLabel? } | | Android FCM options (`analyticsLabel`). | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L343)) | 30 |\n\n## Legacy payload shapes\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| interface DataMessagePayload { [key]: string } | | Legacy data payload — up to 4KB; the keys `from` and `google.*` are reserved. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L346)) | 31 |\n| interface NotificationMessagePayload { tag?; body?; icon?; badge?; color?; sound?; title?; ...; [key] } | | Legacy notification payload with localization keys, a `clickAction`, and arbitrary string keys. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L347)) | 32 |\n| interface MessagingPayload { data?; notification? } | | Legacy combined payload — one or both of `data` / `notification` required. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L348)) | 33 |\n| interface MessagingOptions { dryRun?; priority?; timeToLive?; collapseKey?; mutableContent?; contentAvailable?; restrictedPackageName?; [key] } | | Legacy send options; documented defaults are dryRun false, ttl 2419200s (4 weeks), priority high for notifications / normal for data. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L349)) | 34 |\n\n## Response shapes\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| interface MessagingTopicManagementResponse { failureCount; successCount; errors } | | Topic subscribe / unsubscribe result carrying per-index errors as `FirebaseArrayIndexError[]`. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L352)) | 35 |\n| interface BatchResponse { responses; successCount; failureCount } | | Batch send result; `responses` is a `SendResponse[]`. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L353)) | 36 |\n| interface SendResponse { success; messageId?; error? } | | Per-message batch entry — on success `messageId` is set, on failure `error` is set. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L354)) | 37 |\n\n## Errors\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| class FirebaseMessagingError extends PrefixedFirebaseError | | The exported admin messaging error type. | ✓ | Upstream typings (firebase-admin 13.10.0 `lib/messaging/index`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L357)) | 38 |\n| class MessagingClientErrorCode | | Exported static `{ code, message }` members (`INVALID_ARGUMENT`, `INVALID_RECIPIENT`, `INVALID_PAYLOAD`, … `UNKNOWN_ERROR`). The wire-level `INVALID_ARGUMENT` FcmError returned by malformed sends maps to `MessagingClientErrorCode.INVALID_ARGUMENT`. | ✓ | oracle: [`messaging-send-no-target-error-envelope.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-no-target-error-envelope.json) + [`messaging-send-invalid-token-error-envelope.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-invalid-token-error-envelope.json) (both carry the INVALID_ARGUMENT FcmError). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L156)) | 39 |\n\n## Behavior classes — quota, retry, offline delivery (send plane)\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| FCM send quota and rate-limit throttling | | FCM enforces project-level send quotas and per-target rate limits (per-device and per-topic message rates) accounted on Google servers over rolling windows; exceeding them yields quota / rate-limit rejections. The admin error taxonomy names these as `MessagingClientErrorCode.MESSAGE_RATE_EXCEEDED`, `DEVICE_MESSAGE_RATE_EXCEEDED`, and `TOPICS_MESSAGE_RATE_EXCEEDED` (wire `QUOTA_EXCEEDED` / `RESOURCE_EXHAUSTED`, HTTP 429). Stated from FCM quota documentation; the sandbox has no global quota ledger to model. | — | Upstream documentation (FCM quotas and `MessagingClientErrorCode` rate-limit members, firebase-admin 13.10.0); no observation. This behavior is out of sandbox scope (see the row disposition). | 40 |\n| Send retry and backoff on transient transport failures | | The admin SDK retries transient send-transport failures — connection errors and HTTP 5xx / `SERVER_UNAVAILABLE` / `INTERNAL` responses — with backoff before surfacing the error to the caller, so a caller sees a resolved send or a terminal error rather than every intermediate failure. `enableLegacyHttpTransport` switches `sendEach` / `sendEachForMulticast` from HTTP/2 to HTTP/1.1, changing multiplexing and the retry/transport path. Stated from the admin SDK and google-auth transport documentation; no committed observation replays the retry count or backoff schedule yet. | ? | Upstream documentation (firebase-admin 13.10.0 send transport + google-auth retry defaults); no observation yet. Candidate probe: point the send transport at a fault-injecting stub that returns 503 then 200 and record the retry count and backoff intervals before the resolved send. | 41 |\n| Offline store-and-forward delivery | TTL and collapse-key semantics | When a target device is offline FCM stores the message and delivers it on reconnect, up to its time-to-live (`android.ttl`, APNs expiration, webpush `headers.TTL`; the legacy default is 2419200 seconds / four weeks). A collapse key (`android.collapseKey`, `collapseKey`) marks a message as collapsible so a newer collapsible message replaces an older still-undelivered one, and only the last is delivered on reconnect; a message whose TTL elapses before reconnect is dropped rather than delivered. Stated from FCM message-lifecycle documentation; no committed observation replays store-and-forward, collapse, or TTL expiry yet. | ? | Upstream documentation (FCM message lifecycle: TTL, collapse keys, store-and-forward); no observation yet. Candidate probe: send several collapsible messages to a token whose web client is offline, bring it online within TTL, and assert only the last collapsible message is delivered; separately, let a short-TTL message expire before reconnect and assert it is dropped. | 42 |\n\n## Current gaps\n\n### Not implemented yet\n\nTracked behavior that is not implemented in the current contract.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"unsupported\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">Registration-token inactivity expiry (server-side staleness window)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">FCM treats a registration token that has gone unused for an extended period (Google documents roughly 270 days of app-instance inactivity) as stale and drops it from delivery targeting, independent of any explicit <code>deleteToken</code>. The expiry clock is Google server-side inactivity accounting, and the recommended mitigation is periodic token refresh. Stated from FCM best-practices documentation; the sandbox has no server-side inactivity clock to model.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Upstream documentation (FCM registration-token best practices); no observation. This time-based server-side behavior is out of sandbox scope (see the row disposition); the explicit-deletion path to a dead token is already tracked by messaging#3.</div></div></details>\n<details class=\"compat-row\" data-status=\"unsupported\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">FCM send quota and rate-limit throttling</code><span class=\"compat-sub\"><span class=\"compat-behavior\">FCM enforces project-level send quotas and per-target rate limits (per-device and per-topic message rates) accounted on Google servers over rolling windows; exceeding them yields quota / rate-limit rejections. The admin error taxonomy names these as <code>MessagingClientErrorCode.MESSAGE_RATE_EXCEEDED</code>, <code>DEVICE_MESSAGE_RATE_EXCEEDED</code>, and <code>TOPICS_MESSAGE_RATE_EXCEEDED</code> (wire <code>QUOTA_EXCEEDED</code> / <code>RESOURCE_EXHAUSTED</code>, HTTP 429). Stated from FCM quota documentation; the sandbox has no global quota ledger to model.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Upstream documentation (FCM quotas and <code>MessagingClientErrorCode</code> rate-limit members, firebase-admin 13.10.0); no observation. This behavior is out of sandbox scope (see the row disposition).</div></div></details>\n</div>\n\n### Unverified\n\nTracked behavior whose available evidence does not yet establish the production result.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"unverified\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unverified\" role=\"img\" aria-label=\"Unverified\" title=\"Unverified\"></span><span class=\"compat-main\"><code class=\"compat-api\">Multi-tab delivery routing (several window clients, one shared service worker)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">When several window clients (browser tabs) of one origin share a single service-worker registration, a foreground delivery reaches every visible tab through <code>onMessage</code>, and when no tab is visible the one shared service worker receives the message once through <code>onBackgroundMessage</code> (the worker is shared across tabs, not instantiated per tab). A notification click focuses a single existing client rather than opening a duplicate. Stated from the FCM web SDK and the Service Worker client model; no committed observation replays the multi-tab fan-out yet.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Upstream documentation (<code>@firebase/messaging</code> 0.12.26 + W3C Service Worker <code>Clients</code> model); no observation yet. Candidate probe: open two tabs on one registration and deliver with one tab visible, then with both hidden — assert <code>onMessage</code> fires once per visible tab and <code>onBackgroundMessage</code> fires exactly once on the shared worker.</div></div></details>\n<details class=\"compat-row\" data-status=\"unverified\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unverified\" role=\"img\" aria-label=\"Unverified\" title=\"Unverified\"></span><span class=\"compat-main\"><code class=\"compat-api\">Multi-device delivery (one app instance holding several tokens; per-token fan-out)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">A single user or app can hold several registration tokens at once — one per browser, profile, or device. A token-targeted send reaches exactly one token; a topic, condition, or multicast send fans out independently to every subscribed token, with no server-side de-duplication across a user’s devices, so each device receives its own copy. Stated from the FCM targeting model; no committed observation replays cross-device fan-out yet.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Upstream documentation (FCM targeting model); no observation yet. Candidate probe: mint two distinct tokens, subscribe both to one topic, send once, and assert each token receives the message independently.</div></div></details>\n<details class=\"compat-row\" data-status=\"unverified\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unverified\" role=\"img\" aria-label=\"Unverified\" title=\"Unverified\"></span><span class=\"compat-main\"><code class=\"compat-api\">Registration-token rotation and refresh over the app lifecycle</code><span class=\"compat-sub\"><span class=\"compat-behavior\">A registration token is stable within a session (row messaging#2 pins that repeated <code>getToken</code> calls on one service-worker registration return the same token) but is not permanent across the app lifecycle: the SDK rotates it on events such as app reinstall, SDK upgrade, a restored backup, or a notification-permission change, and Google may retire a token server-side. The modular web SDK has no token-refresh callback, so an app observes a new token only by calling <code>getToken</code> again; a rotated-away old token eventually stops delivering and the send plane reports the dead token (the UNREGISTERED path pinned by messaging#3). This lifecycle rotation is deliberately outside the within-session stability observation. Stated from FCM token-management documentation; no committed observation replays rotation across lifecycle events yet.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Upstream documentation (FCM token-management guidance); the committed <code>messaging-web-token-stability</code> observation covers only within-session stability, not lifecycle rotation. Candidate probe (long-horizon): force a service-worker unregister/re-register or an app-instance reset, re-mint via <code>getToken</code>, and record whether the token changed and whether the prior token then reports UNREGISTERED on send.</div></div></details>\n<details class=\"compat-row\" data-status=\"unverified\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unverified\" role=\"img\" aria-label=\"Unverified\" title=\"Unverified\"></span><span class=\"compat-main\"><code class=\"compat-api\">Send retry and backoff on transient transport failures</code><span class=\"compat-sub\"><span class=\"compat-behavior\">The admin SDK retries transient send-transport failures — connection errors and HTTP 5xx / <code>SERVER_UNAVAILABLE</code> / <code>INTERNAL</code> responses — with backoff before surfacing the error to the caller, so a caller sees a resolved send or a terminal error rather than every intermediate failure. <code>enableLegacyHttpTransport</code> switches <code>sendEach</code> / <code>sendEachForMulticast</code> from HTTP/2 to HTTP/1.1, changing multiplexing and the retry/transport path. Stated from the admin SDK and google-auth transport documentation; no committed observation replays the retry count or backoff schedule yet.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Upstream documentation (firebase-admin 13.10.0 send transport + google-auth retry defaults); no observation yet. Candidate probe: point the send transport at a fault-injecting stub that returns 503 then 200 and record the retry count and backoff intervals before the resolved send.</div></div></details>\n<details class=\"compat-row\" data-status=\"unverified\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unverified\" role=\"img\" aria-label=\"Unverified\" title=\"Unverified\"></span><span class=\"compat-main\"><code class=\"compat-api\">Offline store-and-forward delivery</code><span class=\"compat-sub\"><span class=\"compat-behavior\">When a target device is offline FCM stores the message and delivers it on reconnect, up to its time-to-live (<code>android.ttl</code>, APNs expiration, webpush <code>headers.TTL</code>; the legacy default is 2419200 seconds / four weeks). A collapse key (<code>android.collapseKey</code>, <code>collapseKey</code>) marks a message as collapsible so a newer collapsible message replaces an older still-undelivered one, and only the last is delivered on reconnect; a message whose TTL elapses before reconnect is dropped rather than delivered. Stated from FCM message-lifecycle documentation; no committed observation replays store-and-forward, collapse, or TTL expiry yet.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Upstream documentation (FCM message lifecycle: TTL, collapse keys, store-and-forward); no observation yet. Candidate probe: send several collapsible messages to a token whose web client is offline, bring it online within TTL, and assert only the last collapsible message is delivered; separately, let a short-TTL message expire before reconnect and assert it is dropped.</div></div></details>\n</div>\n" }, { "slug": "functions-rtdb-compat", "label": "Functions · RTDB", "title": "Firebase Functions RTDB integration compatibility", "markdown": "<!-- Generated from the conformance model (registry rows + surface contracts). Do not edit by hand; run bun run compat:generate. -->\n\n# Firebase Functions RTDB integration compatibility\n\n<div class=\"compat-stat\">\n<p class=\"compat-stat-surface\">This page tracks an integration contract: unchanged Firebase code run through a Pyric runtime seam, so there is no separate Firebase public API to measure. The rows below are its signed behavior inventory.</p>\n</div>\n\n\n## Status legend\n\n| Status | Meaning |\n|---|---|\n| ✓ | **Conforming** — unchanged source matches production under replay |\n| ? | **Unverified** — production target or local replay is incomplete |\n\n## `onValueCreated` delivery and event contract\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| onValueCreated(ref, handler) | | A write that changes an exact matched RTDB location from absent to present invokes the handler once with a create CloudEvent and a DataSnapshot containing the created value. | ✓ | oracle: [`functions-rtdb-onvaluecreated-exact-create.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-exact-create.json) (firebase-functions 7.2.5); production exact-create behavior. Local replay: [`packages/cli/test/functions-rtdb/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/functions-rtdb/oracle-conformance.test.ts#L322) assertion set `functions-rtdb#1`. | 1 |\n| onValueCreated(ref, handler) | | After the initial create delivery, changing or deleting that same matched value does not invoke an onValueCreated handler. | ✓ | oracle: [`functions-rtdb-onvaluecreated-exact-create.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-exact-create.json); update/delete produced no additional delivery in the bounded production scenario. Local replay: [`packages/cli/test/functions-rtdb/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/functions-rtdb/oracle-conformance.test.ts#L322) assertion set `functions-rtdb#2`. | 2 |\n| onValueCreated(ref, handler) | | A value that already exists when the trigger is deployed does not produce a historical create delivery. | ✓ | oracle: [`functions-rtdb-onvaluecreated-startup-existing.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-startup-existing.json); pre-seeded value, zero delivery in the observation window. Local replay: [`packages/cli/test/functions-rtdb/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/functions-rtdb/oracle-conformance.test.ts#L323) assertion set `functions-rtdb#3`. | 3 |\n| onValueCreated(ref, handler) | | A named single-segment wildcard matches a created child and exposes the matched segment through event.params. | ✓ | oracle: [`functions-rtdb-onvaluecreated-wildcard-batches.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-wildcard-batches.json); production populated caseId and itemId. Local replay: [`packages/cli/test/functions-rtdb/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/functions-rtdb/oracle-conformance.test.ts#L324) assertion set `functions-rtdb#4`. | 4 |\n| onValueCreated(ref, handler) | | Creating an ancestor object invokes the wildcard handler once for each newly-present matching descendant. | ✓ | oracle: [`functions-rtdb-onvaluecreated-wildcard-batches.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-wildcard-batches.json); one ancestor set delivered alpha and beta. Local replay: [`packages/cli/test/functions-rtdb/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/functions-rtdb/oracle-conformance.test.ts#L324) assertion set `functions-rtdb#5`. | 5 |\n| onValueCreated(ref, handler) | | When an ancestor write creates an exact matched descendant, the event snapshot is projected to that descendant rather than the ancestor object. | ✓ | oracle: [`functions-rtdb-onvaluecreated-descendant-projection.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-descendant-projection.json); leaf snapshot excluded its sibling. Local replay: [`packages/cli/test/functions-rtdb/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/functions-rtdb/oracle-conformance.test.ts#L325) assertion set `functions-rtdb#6`. | 6 |\n| onValueCreated(ref, handler) | | One multi-location update that creates multiple wildcard-matched children produces one create delivery for each child. | ✓ | oracle: [`functions-rtdb-onvaluecreated-wildcard-batches.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-wildcard-batches.json); one update delivered delta and gamma. Local replay: [`packages/cli/test/functions-rtdb/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/functions-rtdb/oracle-conformance.test.ts#L324) assertion set `functions-rtdb#7`. | 7 |\n| DatabaseEvent.data | | The delivered DataSnapshot exposes the created value, key, existence, JSON projection, child lookup, child count, and child enumeration. | ✓ | oracle: [`functions-rtdb-onvaluecreated-exact-create.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-exact-create.json); frozen val/key/exists/toJSON/child enumeration shape. Local replay: [`packages/cli/test/functions-rtdb/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/functions-rtdb/oracle-conformance.test.ts#L322) assertion set `functions-rtdb#8`. | 8 |\n| DatabaseEvent.data.ref | | The snapshot ref is an Admin DatabaseReference rooted at the matched path and can perform an awaited write from the handler. | ✓ | oracle: [`functions-rtdb-onvaluecreated-exact-create.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-exact-create.json); matched Admin ref completed the awaited sibling write. Local replay: [`packages/cli/test/functions-rtdb/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/functions-rtdb/oracle-conformance.test.ts#L322) assertion set `functions-rtdb#9`. | 9 |\n| DatabaseEvent.authType / authId | | For the production Admin SDK write, the event exposes authType `unknown` and authId `null`. | ✓ | oracle: [`functions-rtdb-onvaluecreated-exact-create.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-exact-create.json). Local replay: [`packages/cli/test/functions-rtdb/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/functions-rtdb/oracle-conformance.test.ts#L322) assertion set `functions-rtdb#10`. | 10 |\n| onValueCreated(ref, async handler) | | A Promise returned by the handler keeps the execution open through delayed asynchronous work and its awaited Admin write. | ✓ | oracle: [`functions-rtdb-onvaluecreated-exact-create.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-exact-create.json); capture logged only after the delayed Admin write completed. Local replay: [`packages/cli/test/functions-rtdb/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/functions-rtdb/oracle-conformance.test.ts#L322) assertion set `functions-rtdb#11`. | 11 |\n| onValueCreated(ref, handler) | | A handler that throws or returns a rejected Promise is reported by the managed runtime; with retry disabled, the Eventarc request can still be acknowledged with HTTP 200. | ? | oracle: [`functions-rtdb-onvaluecreated-failed-execution.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-failed-execution.json); Pyric observes the rejected handler and marker, but has no Eventarc HTTP request seam with which to replay the captured 200 acknowledgement. | 12 |\n| onValueCreated(ref, handler) | | Sequential creates are all delivered; their observed arrival order is evidence, not an ordering guarantee. | ✓ | oracle: [`functions-rtdb-onvaluecreated-wildcard-batches.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-wildcard-batches.json); all three arrived in observed order 2, 1, 3. Local replay: [`packages/cli/test/functions-rtdb/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/functions-rtdb/oracle-conformance.test.ts#L324) assertion set `functions-rtdb#13`. | 13 |\n\n## Current gaps\n\n### Unverified\n\nTracked behavior whose available evidence does not yet establish the production result.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"unverified\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unverified\" role=\"img\" aria-label=\"Unverified\" title=\"Unverified\"></span><span class=\"compat-main\"><code class=\"compat-api\">onValueCreated(ref, handler)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">A handler that throws or returns a rejected Promise is reported by the managed runtime; with retry disabled, the Eventarc request can still be acknowledged with HTTP 200.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">oracle: <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-failed-execution.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>functions-rtdb-onvaluecreated-failed-execution.json</code></a>; Pyric observes the rejected handler and marker, but has no Eventarc HTTP request seam with which to replay the captured 200 acknowledgement.</div></div></details>\n</div>\n" }, { "slug": "rules-compat", "label": "Rules", "title": "pyric/rules compatibility matrix", "markdown": "<!-- Generated from the conformance model (registry rows + surface contracts). Do not edit by hand; run bun run compat:generate. -->\n\n# `pyric/rules` compatibility matrix\n\n<div class=\"compat-stat\">\n<p class=\"compat-stat-surface\">This is a Pyric-native API with no Firebase counterpart, so coverage is measured against its own public exports rather than a Firebase surface.</p>\n</div>\n\n\n## Status legend\n\n| Status | Meaning |\n|---|---|\n| ✓ | **Conforming** — the simulator matches the production verdict, locked by a replayed observation |\n| ⚠ | **Diverged (documented)** — a known simulator divergence from production with a written reason |\n\n## Firestore rules engine — production simulator conformance (rules-firestore corpus)\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Rules simulator | | CEL builtins `math.*`/`timestamp.*`/`duration.*` (FM3) — arithmetic, date, and duration comparisons in rules | ✓ | [`oracle:rules-firestore-builtins-time-and-math`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-builtins-time-and-math.json) — production Firestore Rules Test API verdicts for corpus scenario \"builtins-time-and-math\", replayed verdict-for-verdict against the local rules simulator by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L55); all cases match production. | 160 |\n| Rules simulator | | `Bytes`, `String.toUtf8()`, and `hashing.{md5,sha256,crc32,crc32c}()` (Item 5.3) in rules | ✓ | [`oracle:rules-firestore-bytes-toutf8-and-hashing`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-bytes-toutf8-and-hashing.json) — a 2026-07-21 production Firestore Rules Test API recapture supplies 20 verdicts, including paired negative and positive witnesses for padded base64url, the URL-safe alphabet, uppercase hexadecimal MD5/SHA-256, and production's little-endian CRC32/CRC32C byte serialization. [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7) replays all verdicts against the local simulator; `unit:rules/simulator/wrappers/bytes.test.ts` pins the exact representations and reference digests. | 161 |\n| Rules simulator | | Typed cross-type operator overloads for `Timestamp`/`Duration` (Item 2) in rules — no silent numeric coercion / type-identity loss | ✓ | [`oracle:rules-firestore-cross-type-operator-overloads`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-cross-type-operator-overloads.json) — production Firestore Rules Test API verdicts for corpus scenario \"cross-type-operator-overloads\", replayed verdict-for-verdict against the local rules simulator by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7); all cases match production. | 162 |\n| Rules simulator | | CEL tri-state error absorption in `\\|\\|`/`&&` (RULES-B3) — `error \\|\\| true` → ALLOW, `error && false` → DENY, commutative absorption (not JS left-to-right short-circuit) | ✓ | [`oracle:rules-firestore-error-absorption-and-or`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-error-absorption-and-or.json) — production Firestore Rules Test API verdicts for corpus scenario \"error-absorption-and-or\", replayed verdict-for-verdict against the local rules simulator by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7); all cases match production. | 163 |\n| Rules simulator | | `getAfter()`/`existsAfter()` expose the projected post-write target and cross-document atomic batch state; create exists, delete does not, and an unrelated unchanged document falls back to its committed existence | ✓ | [`oracle:rules-firestore-get-after-and-exists-after`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-get-after-and-exists-after.json) records authenticated Web SDK verdicts against temporarily deployed production rules, including target create/delete and unrelated-path semantics. Its lifecycle proves exact rules release restoration; retained diagnostics document why the hosted Rules Test API was not sufficient. Replayed verdict-for-verdict by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L28); [`get-after.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/simulator/get-after.test.ts#L2) and [`get-after-batch.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/simulator/get-after-batch.test.ts#L4) cover target, sibling-write, solo-write denial, delete, and atomicity locally. | 164 |\n| Rules simulator | | `get()` of a missing document (RULES-B8) in rules — resource identity (`id`/`__name__`) exposure on a mocked/missing get() result | ✓ | [`oracle:rules-firestore-get-missing-doc`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-get-missing-doc.json) — production Firestore Rules Test API verdicts for corpus scenario \"get-missing-doc\", replayed verdict-for-verdict against the local rules simulator by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7). Serializable `functionMocks` now retain their production data-only shape, so mocked `get()` results leave `id`/`__name__` absent and both identity reads error to DENY; real DocStore lookups retain their separate identity-bearing path. All 8 cases match production. | 165 |\n| Rules simulator | | `request.path`/`request.query`/`resource.id`/`resource.__name__` globals (Item 6) in rules | ✓ | [`oracle:rules-firestore-globals-request-path-and-resource-id`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-globals-request-path-and-resource-id.json) — production Firestore Rules Test API verdicts for corpus scenario \"globals-request-path-and-resource-id\", replayed verdict-for-verdict against the local rules simulator by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7). `request.query` is now absent on non-list operations, so reading it on create errors to DENY instead of false-ALLOWing through an invented empty map. `request.path` and the create-time absent-resource controls remain unchanged; all 6 cases match production. Resource identity on an EXISTING document is scoped to firestore-rules#183. | 166 |\n| Rules simulator | | `int`/`float` division and type distinction (RULES-B5) in rules — truncating int÷int, float division stays float, div-by-zero denies, `is int`/`is float` distinct | ✓ | [`oracle:rules-firestore-int-float-and-division`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-int-float-and-division.json) — production Firestore Rules Test API verdicts for all ten corpus cases, replayed verdict-for-verdict by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7). The simulator now revives non-integral JSON wire numbers as RulesFloat values, preserving production's float payload type while retaining bare integral numbers as ints; `unit:rules/simulator/handler.test.ts` pins both literal and payload distinctions. | 167 |\n| Rules simulator | | `List.concat()`/`removeAll()`/`toSet()` (Item 5.2) in rules | ✓ | [`oracle:rules-firestore-list-methods-concat-removeall-toset`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-list-methods-concat-removeall-toset.json) — production Firestore Rules Test API verdicts for all nine cases, replayed verdict-for-verdict by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7). The seven positive List witnesses and the concat DENY witness match; the historical `toSet().difference(list)` case remains DENY because production requires a Set argument. | 168 |\n| Rules simulator | | `Map.get(key, default)`, including list-form nested-path traversal (Item 3), in rules | ✓ | [`oracle:rules-firestore-map-get-string-and-list-form`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-map-get-string-and-list-form.json) — production Firestore Rules Test API verdicts for corpus scenario \"map-get-string-and-list-form\", replayed verdict-for-verdict against the local rules simulator by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7); all cases match production. | 169 |\n| Rules simulator | | `matches()` as an anchored full-string RE2 match (RULES-B4) in rules — a pattern matching only a substring is `false` | ✓ | [`oracle:rules-firestore-matches-full-string-regex`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-matches-full-string-regex.json) — production Firestore Rules Test API verdicts for corpus scenario \"matches-full-string-regex\", replayed verdict-for-verdict against the local rules simulator by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7); all cases match production. | 170 |\n| Rules simulator | | `Path` wrapper, `path()` constructor, and `Path.bind()` (Item 5.4) in rules | ✓ | [`oracle:rules-firestore-path-constructor-and-bind`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-path-constructor-and-bind.json) — production Firestore Rules Test API verdicts for all nine corpus cases in \"path-constructor-and-bind\", replayed verdict-for-verdict against the local rules simulator by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7), including production's rejection of a Path argument to `path()`. | 171 |\n| Rules simulator | | Own-keys-only map membership and constructor-access denial (RULES-B7) in rules — `'toString' in map` is `false`, `.constructor` access errors (no JS prototype-chain leakage) | ✓ | [`oracle:rules-firestore-prototype-chain-keys`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-prototype-chain-keys.json) — production Firestore Rules Test API verdicts for corpus scenario \"prototype-chain-keys\", replayed verdict-for-verdict against the local rules simulator by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7); all cases match production. | 172 |\n| Rules simulator | | Range-slice `[i:j]` syntax for `List` and `String` (Item 4) in rules | ✓ | [`oracle:rules-firestore-range-slice-list-and-string`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-range-slice-list-and-string.json) — production Firestore Rules Test API verdicts for all ten corpus cases in \"range-slice-list-and-string\", replayed verdict-for-verdict against the local rules simulator by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7), including out-of-bounds slice-end rejection for both lists and strings. | 173 |\n| Rules simulator | | `Set.difference()`/`union()`/`intersection()` (Item 5.1) in rules | ✓ | [`oracle:rules-firestore-set-algebra-difference-union-intersection`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-set-algebra-difference-union-intersection.json) captures the receiver and argument boundary in twelve production cases: Map.keys() is a List and rejects Set-only algebra, explicit toSet() receivers ALLOW difference/union/intersection with Set arguments, and a wrong-result control DENIES. The local simulator replays every verdict and the per-construct acceptance probes use the same positive toSet() shapes. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7)) | 174 |\n| Rules simulator | | String-literal escape handling feeding `matches()` (Class B) in rules — `\\\\.` is unescaped before RE2 compilation, not forwarded raw to a JS `RegExp` | ✓ | [`oracle:rules-firestore-string-literals-and-regex`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-string-literals-and-regex.json) — production Firestore Rules Test API verdicts for corpus scenario \"string-literals-and-regex\", replayed verdict-for-verdict against the local rules simulator by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7); all cases match production. | 175 |\n| Rules simulator | | Missing-field access as a runtime error (RULES-B2) in rules — `typo == null` denies (missing access is not null); `!(key in map)` is the real absence check | ✓ | [`oracle:rules-firestore-undefined-field-access`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-undefined-field-access.json) — production Firestore Rules Test API verdicts for corpus scenario \"undefined-field-access\", replayed verdict-for-verdict against the local rules simulator by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7); all cases match production. | 176 |\n| Rules simulator | | Explicit `UNSUPPORTED` reporting for unimplemented built-ins (Item 0.A) in rules — an unimplemented built-in abstains rather than silently DENYing | ✓ | [`oracle:rules-firestore-unsupported-feature-witness`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-unsupported-feature-witness.json) — production Firestore Rules Test API verdicts for corpus scenario \"unsupported-feature-witness\", replayed verdict-for-verdict against the local rules simulator by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7); all cases match production. | 177 |\n| Rules simulator | | Required-fields validation idiom in rules — `request.resource.data.keys().hasAll()/hasOnly()` and the `diff().addedKeys()/removedKeys()/changedKeys()/affectedKeys()/unchangedKeys()` MapDiff family gating field-level mutations | ✓ | NEW ROW, 2026-07-12: production capture proves the simulator matches the production Firestore Rules Test API verdict-for-verdict on all 7 cases (exact required-field set on create, field-scoped update via MapDiff). [`oracle:rules-firestore-required-fields-and-mapdiff`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-required-fields-and-mapdiff.json) — replayed by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7). | 178 |\n| Rules simulator | | `String.split()/lower()/upper()/trim()/replace()/size()`, `List.hasAll()/hasAny()/hasOnly()/join()`, and `Map.values()` in rules | ✓ | NEW ROW, 2026-07-12: production capture proves the simulator matches the production Firestore Rules Test API verdict-for-verdict on all 4 cases (CSV-split membership, slug/title/code normalization). [`oracle:rules-firestore-list-and-string-methods`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-list-and-string-methods.json) — replayed by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7). | 179 |\n| Rules simulator | | `request.time` Timestamp accessors (year/month/day/hours/minutes/seconds/nanos/dayOfWeek/dayOfYear/toMillis/date/time), `math.floor/round/sqrt/pow/isNaN`, `int()/float()/string()` casts, `request.method`, and `>=` / `*` operators in rules | ✓ | NEW ROW, 2026-07-12: production capture proves the simulator matches the production Firestore Rules Test API verdict-for-verdict on all 3 cases (request.time pinned via requestTime for determinism). [`oracle:rules-firestore-time-math-and-casts`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-time-math-and-casts.json) — replayed by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L24). | 180 |\n| Rules simulator | | `latlng.value().latitude()/longitude()/distance()` and `duration.value()/time()/abs()` in rules | ✓ | Production captures from 2026-07-12 and the 2026-07-21 recapture prove the simulator matches the Firestore Rules Test API verdict-for-verdict on all 3 cases: coordinate and duration bounds plus exact `duration.value(1500, 'ms').seconds()` / `.nanos()` components. [`oracle:rules-firestore-duration-and-latlng`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-duration-and-latlng.json) — replayed by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7). | 181 |\n| Rules simulator | | User-defined `function`/`let`, granular `allow get/list/update/write` verbs, the ternary operator, and a recursive `{document=**}` match in rules | ✓ | NEW ROW, 2026-07-12: production capture proves the simulator matches the production Firestore Rules Test API verdict-for-verdict on all 7 cases (role-gated docs + public recursive subtree). [`oracle:rules-firestore-functions-verbs-and-recursive`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-functions-verbs-and-recursive.json) — replayed by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L33). | 182 |\n| Rules simulator | | Resource identity (`resource.id`/`resource.__name__`/`request.resource.id`) on an EXISTING document (RULES-B12) — production leaves the identity keys ABSENT, so reading one errors → DENY, the error survives negation, and only a determining `\\|\\|` operand absorbs it | ✓ | NEW ROW, 2026-07-12: production capture proves the simulator matches the production Firestore Rules Test API verdict-for-verdict on all 13 cases. Production builds `resource` (and `request.resource`) from the supplied document alone and derives NO identity from the request path, so `resource.id`/`resource.__name__`/`request.resource.id` read as absent properties — verbatim `Property id is undefined on object.` / `Property __name__ is undefined on object.` — which absorb to DENY. The simulator previously SYNTHESIZED `id`/`__name__` from the request path on get/list/update/delete, so `resource.id == id` ALLOWed where production DENIES: an over-permissive divergence, now removed. The negation cases (`resource.id != 'zzz'`, `!(resource.id == 'zzz')`) pin that the absent read is a propagating error rather than a false, which an absent-as-undefined model would false-ALLOW. [`oracle:rules-firestore-resource-document-identity`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-resource-document-identity.json) — replayed by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7). | 183 |\n| Rules simulator | | Request-target `resource` on a missing document (RULES-B13) — get/update/delete existence guards, null comparison, and data access all error to DENY | ✓ | NEW ROW, 2026-07-12: production capture proves the simulator matches the Firestore Rules Test API verdict-for-verdict on all 9 cases. A missing request target makes the `resource` global an error value: `resource != null`, `resource == null`, and `resource.data.value` all DENY. The simulator previously constructed `{ data: {} }` for missing get/update/delete requests, so the ordinary `resource != null` existence guard false-ALLOWed all three operations. Modeling the target as raw null fixes that guard but makes `resource == null` false-ALLOW, so the evaluator now raises `Null value error.` when the absent `resource` identifier itself is evaluated. Existing-document get/update/delete and data-access controls still ALLOW. [`oracle:rules-firestore-resource-missing-document`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-resource-missing-document.json) — replayed by [`packages/pyric/test/rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7). | 184 |\n| Rules simulator | | A ruleset with NO `rules_version` declaration — production accepts it and defaults to v1 | ✓ | NEW ROW, 2026-07-17: production capture proves a version-less ruleset deploys and evaluates (production defaults `rules_version` to v1; the shared grammar previously rejected the file at parse — PR #333 grammar fix, the same evidence that reclassified corpus file 001-missing-version.rules from invalid to edge-cases). [`oracle:rules-firestore-optional-rules-version`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-optional-rules-version.json) matches the local simulator verdict-for-verdict on all 3 cases. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7)) | 185 |\n| Rules simulator | | Function declarations at GLOBAL and SERVICE scope in a Firestore ruleset, with match-scope shadowing | ✓ | ROW CORRECTED, 2026-07-17 (same day): the simulator now seeds the match-tree walk with global- and service-scope function declarations (declaration order global → service → match preserves inner-shadows-outer), so the previously-abstaining cases evaluate. [`oracle:rules-firestore-global-and-service-scope-functions`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-global-and-service-scope-functions.json) — production Firestore Rules Test API verdicts captured 2026-07-17 — now replays verdict-for-verdict against the local simulator with all 5 cases matching (shadowing included), asserted by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7). The prior unsupported row recorded the honest UNSUPPORTED abstention; that gap is closed (#346). | 186 |\n| Rules simulator | | Hierarchical nested `match` composition — a child match resolves relative to its parent and grants only the exact nested document path | ✓ | [`oracle:rules-firestore-hierarchical-match-cascade`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-hierarchical-match-cascade.json) captures the exact nested child ALLOW plus parent, sibling, and over-deep DENY controls from the production Firestore Rules Test API, replayed verdict-for-verdict by [`packages/pyric/test/rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7). | 187 |\n| Rules simulator | | Strict boolean operands for `&&`, `\\|\\|`, and ternary control flow, plus create-time `resource` / `request.resource` semantics | ✓ | [`oracle:rules-firestore-strict-boolean-control-flow`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-strict-boolean-control-flow.json) captures three direct non-boolean DENYs, three tautological error discriminators that still DENY, a boolean ALLOW control, create-time `resource == null` DENY, and incoming `request.resource.data` ALLOW; replayed verdict-for-verdict by [`packages/pyric/test/rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7). | 188 |\n| Rules simulator | | The exact six shared `auth` and `membership` function bodies preserve signed-in, owner, custom-claim, explicit membership-map, and role semantics under Firestore Rules | ✓ | NEW ROW, 2026-07-21: one bounded, read-only Firestore Rules Test API request captured 12 verdicts for the exact bodies shipped from `auth` and `membership`; [`oracle:rules-firestore-common-auth-membership-firestore`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-common-auth-membership-firestore.json) matches the local Firestore rules simulator verdict-for-verdict (6 ALLOW, 6 DENY). The cross-service source-lock test requires these six Firestore corpus bodies, the previously captured Storage corpus bodies, and the shipped modules to remain normalized-AST-identical. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7)) | 189 |\n| Rules simulator | | In multi-document batched writes and transactions, getAfter() deep-merges incoming updates with pre-existing sibling document state, preserving unmodified fields; updates targeting non-existent siblings project to null so existsAfter() returns false. | ✓ | Captured production behavior via rules-firestore-atomic-batch-sibling-merge proves getAfter() evaluates against deep-merged sibling state during multi-document atomic updates. (Structured evidence: [`rules-firestore-atomic-batch-sibling-merge`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-atomic-batch-sibling-merge.json), [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L28), [`get-after-batch.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/simulator/get-after-batch.test.ts#L2)) | 190 |\n| Rules simulator | | Compile-time AST verification rejects non-conforming casts (bool()), unsupported helper methods (debug(), math.isInfinite()), and bare Map membership assertions (map.hasAll/hasAny/hasOnly). | ✓ | Captured production behavior via rules-firestore-ast-strictness-and-unsupported-casts proves compile-time AST verification and execution rejection of bool(), math.isInfinite(), debug(), and bare Map membership assertions. (Structured evidence: [`rules-firestore-ast-strictness-and-unsupported-casts`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-ast-strictness-and-unsupported-casts.json), [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L55), [`hallucinations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/linter/hallucinations.test.ts#L137)) | 191 |\n\n## Storage rules engine — `parseStorageRules` / `evaluateStorageRules` (rules-storage corpus)\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Rules | | `parseStorageRules(source)` returns an opaque handle | ✓ | [`unit:storage/sandbox/rules.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/sandbox/rules.test.ts#L2) (\"parses the canonical session-archive ruleset\") | 94 |\n| Rules | | `parseStorageRules` rejects non-`firebase.storage` service headers | ✓ | [`unit:storage/sandbox/rules.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/sandbox/rules.test.ts#L2) (\"rejects unknown service header\") | 95 |\n| Rules | | `evaluateStorageRules` supports granular verbs (`get`/`list`/`create`/`update`/`delete`) alongside `read`/`write` umbrella expansion, comma-separated verb lists, and per-verb default-deny | ✓ | STALE ROW, corrected 2026-07-10: production capture proves the evaluator already supports the full six-verb grant surface (umbrella read→{get,list}, write→{create,update,delete}, single granular grants, comma-separated grants, per-verb deny-by-default), matching production verdict-for-verdict on 12 of the scenario's 13 non-existence cases. [`oracle:rules-storage-verbs-umbrella-granular`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-verbs-umbrella-granular.json) (all `read`/`write`/`get`/comma-verb cases). One related existence-semantics case in the same scenario diverges over-permissively and is carried by `#118 Rules` (the `resource == null` create-if-absent guard), not by this granular-verb row. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L6)) | 96 |\n| Rules | | `parseStorageRules` rejects unterminated string literals with `SyntaxError` | ✓ | [`unit:storage/sandbox/rules.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/sandbox/rules.test.ts#L2) (\"rejects unterminated strings\") | 97 |\n| Rules | | `evaluateStorageRules` matches `match /sessions/{id} { allow read: if request.auth != null; }` for an authed read | ✓ | [`unit:storage/sandbox/rules-evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/sandbox/rules-evaluator.test.ts#L3) (\"allows authenticated reads of /sessions/{id}\") | 98 |\n| Rules | | `evaluateStorageRules` denies anonymous reads when the rule requires `request.auth != null` | ✓ | [`unit:storage/sandbox/rules-evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/sandbox/rules-evaluator.test.ts#L3) (\"denies anonymous reads\") | 99 |\n| Rules | | `evaluateStorageRules` supports `request.resource.size < N` constraints (with arithmetic literals like `10 * 1024 * 1024`) | ✓ | [`unit:storage/sandbox/rules-evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/sandbox/rules-evaluator.test.ts#L3) (\"allows JSON writes under 10MB\") | 100 |\n| Rules | | `evaluateStorageRules` supports `request.resource.contentType == '<mime>'` constraints | ✓ | [`unit:storage/sandbox/rules-evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/sandbox/rules-evaluator.test.ts#L3) (mime constraint inside the session-archive ruleset) | 101 |\n| Rules | | Multi-segment wildcard `{allPaths=**}` matches zero-or-more remaining segments | ✓ | [`unit:storage/sandbox/rules-evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/sandbox/rules-evaluator.test.ts#L2) (parser + evaluator both honor the `**` form) | 102 |\n| Rules | | Path-parameter binding (`{sessionId}`) accessible inside the `if` expression | ✓ | [`unit:storage/sandbox/rules-evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/sandbox/rules-evaluator.test.ts#L2) | 103 |\n| Rules | | User-defined `function` definitions — `let` bindings, functions calling functions, and match-block-scoped helper functions (lexical scoping) | ✓ | STALE ROW, corrected 2026-07-10: production capture proves the evaluator supports user-defined functions with `let` bindings, nested function calls, and block-scoped helpers. [`oracle:rules-storage-functions-let-scope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-functions-let-scope.json) matches production verdict-for-verdict on all 5 cases. Same-name shadowing and undefined-function calls are compile-time rejections in production and are covered by evaluator unit tests instead (they cannot be captured as a clean production verdict). (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L15)) | 104 |\n| Rules | | `request.time` compared against `timestamp.date(y,m,d)` and `timestamp.value(ms)` constructors | ✓ | NEW ROW, 2026-07-10: production capture proves the evaluator supports `request.time` comparisons against both timestamp constructors. [`oracle:rules-storage-request-time-timestamp`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-request-time-timestamp.json) matches production verdict-for-verdict on all 4 cases (deadline-before/after via `timestamp.date()`, epoch-bound before/after via `timestamp.value()`). (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L6)) | 112 |\n| Rules | | `string.matches(regex)` with whole-string anchoring (a partial match denies) | ✓ | NEW ROW, 2026-07-10: production capture proves `matches()` is whole-string anchored, matching a RE2 pattern only when it covers the entire string. [`oracle:rules-storage-matches-regex`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-matches-regex.json) matches production verdict-for-verdict on all 3 cases. RE2-inexpressible patterns are rejected at ruleset compile time by production and are covered by evaluator unit tests instead. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L194)) | 113 |\n| Rules | | `resource.metadata.<key>` custom-metadata access in dotted (`resource.metadata.owner`) and bracket (`resource.metadata['owner']`) form, including missing-key deny | ✓ | NEW ROW, 2026-07-10: production capture proves dotted and bracket metadata access resolve identically, and a missing key denies. [`oracle:rules-storage-metadata-access`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-metadata-access.json) matches production verdict-for-verdict on all 5 cases. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L131)) | 114 |\n| Rules | | Cross-service `firestore.get()` / `firestore.exists()` lookups from a Storage ruleset, with `$(expr)` path interpolation and qualified function-mock names | ✓ | NEW ROW, 2026-07-10: production capture proves the evaluator resolves cross-service Firestore lookups from Storage rules, including interpolated document paths and both the map-returning `get()` and bool-returning `exists()` forms. [`oracle:rules-storage-firestore-lookup`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-firestore-lookup.json) matches production verdict-for-verdict on all 4 cases. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L71)) | 115 |\n| Rules | | `resource.name` (full object path), `resource.timeCreated`, `resource.updated`, `resource.bucket` — the object-identity and time fields of the existing-object binding, plus `duration.value(n, unit)` | ✓ | ROW CORRECTED, 2026-07-12: the fields are now modeled, sourced from the persisted object record. [`oracle:rules-storage-resource-object-identity`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-resource-object-identity.json) matches production verdict-for-verdict on all 9 cases — an extension guard (`resource.name.matches`), a freshness window (`resource.timeCreated + duration.value(1, 'h')`), an immutability check (`resource.timeCreated == resource.updated`), and an absent-property negation. Production semantics pinned by the capture: `resource.name` is the object's FULL path within the bucket (GCS object-name convention, sourced from the persisted record's `fullPath`, not the client SDK's last-segment `name`); `timeCreated`/`updated` are timestamps (production rejects an int with \"Unsupported operation error. Received: int < timestamp\"); the update-time field is `updated` — there is NO `resource.timeUpdated`. An absent property is an evaluation error that absorbs to DENY and SURVIVES negation, so `resource.name != 'x'` on an object with no name denies rather than false-allowing. The prior row recorded these fields as an unsupported gap; that gap is closed. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L6)) | 116 |\n| Rules | | `request.resource.size` arithmetic bounds (`+`/`-`/`*`/`/`, `>=`/`<=`), request/existing content-type and custom `metadata` checks, `request.method`/`request.path`, a `request.auth.token` custom-claim branch, granular `allow list`, and a recursive `{allPaths=**}` public read tree | ✓ | NEW ROW, 2026-07-12: production capture proves the evaluator matches the production Storage Rules Test API verdict-for-verdict on all 8 cases (owner-scoped size-bounded upload, immutable-content-type update, list grant, public recursive read). [`oracle:rules-storage-metadata-verbs-and-arithmetic`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-metadata-verbs-and-arithmetic.json) — replayed by [`packages/pyric/test/storage/rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L75). | 117 |\n| Rules | | `resource` on a create where no object exists: the create-if-absent guard `allow create: if resource == null` | ✓ | GAP CLOSED, 2026-07-21: production raises a null-value error when `resource` is referenced on a create where no object exists, including `resource == null`, so the expression denies. The evaluator now represents the missing binding as a Rules error rather than a usable null. [`oracle:rules-storage-verbs-umbrella-granular`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-verbs-umbrella-granular.json) replays the captured DENY directly with no divergence pin; `unit:storage/sandbox/rules-evaluator.test.ts` covers the evaluator seam. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L6)) | 118 |\n| Rules | | Ternary `?:` — branch selection, error-in-condition deny, and `error \\|\\| true` absorption | ✓ | NEW ROW, 2026-07-17: production capture proves the evaluator selects ternary branches by condition, denies when the CONDITION errors (even with identical `true` branches — production errors the whole conditional rather than falling through to a branch), and still absorbs the same error through `\\|\\| true`. [`oracle:rules-storage-ternary-and-error-absorption`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-ternary-and-error-absorption.json) matches production verdict-for-verdict on all 5 cases. A division-by-zero in the condition previously yielded JS Infinity (no error) and false-ALLOWed the erroring-condition case; zero divisors now error-and-deny. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L6)) | 119 |\n| Rules | | `in` on list literals (membership) and on maps (OWN-key membership) — prototype names (`toString`/`constructor`/`hasOwnProperty`) are never keys | ✓ | NEW ROW, 2026-07-17: production capture proves `in` tests list membership and map OWN keys only — `'toString' in metadata` is false unless the map literally carries that key, for both custom-metadata maps and map literals. [`oracle:rules-storage-in-membership-and-proto-keys`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-in-membership-and-proto-keys.json) matches production verdict-for-verdict on all 8 cases. Pre-fix the evaluator used JS `in`, which walks the prototype chain and false-ALLOWed the proto-key cases (the same bug class the Firestore simulator fixed under RULES-B7); the own-key check now guards it, pinned by `unit:storage/sandbox/rules-values.test.ts` JS-semantics guards. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L6)) | 120 |\n| Rules | | `is` type tests over the storage bindings (`size is int`, `contentType is string`, `metadata is map`, `split() is list`) and int/float LITERAL typing (`1.0 is float`, `1 is int`, cross checks false) | ✓ | ROW CORRECTED, 2026-07-17 (same day): the evaluator now adopts the simulator's RULES-B5 float model — a `RulesFloat` wrapper tags float values (a numeric literal written with a decimal point converts to one) while a bare JS number means int — so `1.0 is float` / `!(1.0 is int)` type by literal form exactly as production does. [`oracle:rules-storage-type-checks-is`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-type-checks-is.json) now matches production verdict-for-verdict on all 3 cases with no knownGap abstentions. The prior same-day row recorded the value-typed divergence; that gap is closed. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L6)) | 121 |\n| Rules | | List/map literals, range-slice access `[i:j]` on lists and strings (out-of-range bounds ERROR, no JS clamp), and structural `==` on lists | ✓ | NEW ROW, 2026-07-17: production capture proves slices evaluate on `split()` lists and on strings, that an out-of-range slice END errors and denies (production does NOT clamp to length the way JS `.slice()` does — same semantics the Firestore capture pinned in rules-firestore-range-slice-list-and-string), and that a slice compares equal to a list literal structurally. [`oracle:rules-storage-list-map-literals-and-slice`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-list-map-literals-and-slice.json) matches production verdict-for-verdict on all 6 cases. Pre-fix the evaluator clamped bounds JS-style and compared lists by reference identity; both fixed and pinned by `unit:storage/sandbox/rules-values.test.ts` JS-semantics guards. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L6)) | 122 |\n| Rules | | Float literals, `%`, unary minus, float division, negative-dividend modulo (`-7 % 2 == -1`), and TRUNCATING int division (`10 / 4 == 2`, `-7 / 2 == -3`) | ✓ | ROW CORRECTED, 2026-07-17 (same day): with the RULES-B5 float model adopted from the simulator, int ÷ int truncates toward zero, float division stays float (re-tagged), and int-op-float promotes to float. [`oracle:rules-storage-float-modulo-unary-minus`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-float-modulo-unary-minus.json) now matches production verdict-for-verdict on all 6 cases with no knownGap abstentions. An int zero divisor errors-and-denies; a float zero divisor follows the simulator's CEL-pinned ±Infinity/NaN. The prior same-day row recorded the truncation divergence; that gap is closed. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L6)) | 123 |\n| Rules | | Function declarations at GLOBAL scope (above `service`) and SERVICE scope (inside `service`, outside any match), with inner-shadows-outer resolution | ✓ | NEW ROW, 2026-07-17: production capture proves production accepts functions declared at global and service scope and resolves a match-scope function over a same-named global (inner shadows outer). [`oracle:rules-storage-function-scopes-and-shadowing`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-function-scopes-and-shadowing.json) matches production verdict-for-verdict on all 5 cases. The shared grammar previously rejected both placements at parse (PR #333 grammar fix); the storage evaluator attaches global- and service-scope functions to the root match block, which the capture proves is verdict-equivalent to production's scoping for this surface. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L6)) | 124 |\n| Rules | | `2+modules` Storage rules resolve through service-aware contracts: the common `auth` and `membership` exports are admitted and retain their production auth, claim, map-membership, and role semantics; Firestore-only helpers are rejected before evaluation | ✓ | NEW ROW, 2026-07-20; paired proof added 2026-07-21: one targeted production Rules Test API capture proves the exact six bundled function bodies (`isAuthenticated`, `isOwner`, `hasClaim`, `hasClaimRole`, `isMemberOf`, `hasRole`) under `firebase.storage`, matching the local evaluator verdict-for-verdict on 12 allow/deny cases. [`oracle:rules-storage-common-auth-membership`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-common-auth-membership.json) supplies the Storage answer key; Firestore Rules row #189 independently captures the same exact bodies under `cloud.firestore` (12 matching verdicts). A separately authored, explicitly reconstructed normalized-AST digest locks the captured Storage corpus bodies, and the source-lock test requires the Firestore corpus and shipped modules to remain AST-identical. Resolver tests separately prove `2+modules` lowers these imports to plain v2 and fails closed with `INCOMPATIBLE_FUNCTION` for a Firestore-only export and for a transitive helper that reads `resource.data`; the Storage service integration test proves normal setup invokes that checked resolver. (Structured evidence: [`storage-stdlib-source-lock.test.ts`](https://github.com/davideast/pyric/blob/main/packages/conformance/test/src/storage-stdlib-source-lock.test.ts#L73), [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L7), [`resolver.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/modules/resolver.test.ts#L9), [`enforce.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/enforce.test.ts#L17)) | 125 |\n| Rules | | Storage-native upload primitives at security boundaries: inclusive byte maximums, exact and whole-string MIME checks, metadata-only updates with unchanged bytes/owner, string path wildcards, generation/metageneration identity, and strict creation-time windows | ✓ | NEW ROW, 2026-07-20: [`oracle:rules-storage-upload-primitives-boundaries`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-upload-primitives-boundaries.json) captures 26 production cases in one Rules Test API request. The local evaluator matches every case scoped to this row: size 0/exact/+1, MIME exact/case/parameter and regex anchoring, metadata update invariants, filename wildcard typing, exact/absent generation identity, and request.time just before/at/after a strict 60-second boundary plus a future timeCreated fixture. Metadata collection methods and missing request.resource are separated into divergence rows #127/#128 rather than contaminating this credit. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L7)) | 126 |\n| Rules | | Storage custom-metadata collection methods: `Map.keys().hasAll()` for required-key policy and `Map.get(key, default)` for an absent key | ✓ | GAP CLOSED, 2026-07-21: the Storage evaluator now implements own-key `Map.keys()`, structural `hasAll()`, and string-key `Map.get(key, default)`. [`oracle:rules-storage-upload-primitives-boundaries`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-upload-primitives-boundaries.json) replays the production required-key and absent-key-default cases directly with no divergence pin; public-seam unit cases cover both admitted forms. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L6)) | 127 |\n| Rules | | `request.resource` on delete where no incoming object exists: `request.resource == null` | ✓ | GAP CLOSED, 2026-07-21: production errors when `request.resource` is absent on delete, including a direct null comparison. The evaluator now carries the absent incoming binding as a Rules error. [`oracle:rules-storage-upload-primitives-boundaries`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-upload-primitives-boundaries.json) replays the captured DENY directly with no divergence pin; `unit:storage/sandbox/rules-evaluator.test.ts` covers the evaluator seam. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L128)) | 128 |\n| Rules | | Mocked Storage-to-Firestore lookup evaluation: get/exists return and missing-field errors, repeated and same-resolved-path calls, get+exists composition, and lazy `\\|\\|`/`&&`/ternary branches with hosted function-call diagnostics | ⚠ | DOCUMENTED MOCK-ORACLE DIVERGENCE, 2026-07-21: [`oracle:rules-storage-firestore-lookup-budget`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-firestore-lookup-budget.json) matches the local evaluator on 13 of 14 cases and hosted diagnostics prove lazy branches omit lookup calls. The one mismatch is intentional and pinned on both sides: the Rules Test API ALLOWs three distinct mocked exists calls, while the evaluator DENYs the third distinct path to match the separately captured real-resource production contract in row #131. This row remains diverged because its mocked observation is not verdict-identical; it supplies no budget credit. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L71)) | 129 |\n| Rules | | Anonymous `request.auth != null` used as a ternary condition before a cross-service lookup | ✓ | GAP CLOSED, 2026-07-21: anonymous `request.auth` is now represented as an absent-property Rules error, so a ternary condition denies before selecting either lookup branch, matching hosted diagnostics. [`oracle:rules-storage-firestore-lookup-budget`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-firestore-lookup-budget.json) replays the captured DENY directly with no divergence pin; `unit:storage/sandbox/rules-methods.test.ts` covers the lookup evaluator seam. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L71)) | 130 |\n| Rules | | Real-resource Storage-to-Firestore contract: at most two distinct document paths per evaluation with repeated-path caching; typed document data and missing-document errors; default-database-only and owning-project isolation; lazy/helper/let composition; and independence from Firestore client rules | ✓ | NEW ROW, 2026-07-21: four deploy-observe-restore captures establish the real-resource contract beyond Rules Test API mocks: [`oracle:stdlib-realstorage-p3-lookup-budget-iam-enabled`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/stdlib-realstorage-p3-lookup-budget-iam-enabled.json), [`oracle:stdlib-realstorage-p3-advanced-iam-enabled`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/stdlib-realstorage-p3-advanced-iam-enabled.json), [`oracle:stdlib-realstorage-p3-named-database`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/stdlib-realstorage-p3-named-database.json), and [`oracle:stdlib-realstorage-p3-project-isolation`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/stdlib-realstorage-p3-project-isolation.json). [`unit:conformance/storage-stdlib-real-replay.test.ts`](https://github.com/davideast/pyric/blob/main/packages/conformance/test/src/storage-stdlib-real-replay.test.ts#L90) executes the locally decidable lookup matrices and locks reviewed reconstructions of their normalized probe code; the lock records state whether the digest covers an injected block or a canonical synthetic wrapper. The surrounding deployed base rules were not retained, so no full-source digest is claimed for these historical captures. Future rigs emit both probe-block and full deployed-rules-file digests. [`unit:storage/enforce.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/enforce.test.ts#L368) proves the normal enforcement seam reads only its owning sandbox and bypasses Firestore client rules. The separate consistency capture remains bounded characterization and is not promoted into a latency guarantee. | 131 |\n| Rules | | The service-scoped `storage/uploads`, `storage/metadata`, `storage/objects`, and `storage/time` modules resolve through `2+modules` and preserve their production upload-bound, MIME, metadata, operation-identity, and strict timestamp-window semantics | ✓ | NEW ROW, 2026-07-21: one bounded Rules Test API batch captures 15 verdicts for the exact 13 deployable function bodies in the four shipped modules ([`oracle:rules-storage-stdlib-storage-modules`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-stdlib-storage-modules.json)). A separately authored, explicitly reconstructed normalized-AST digest locks the captured corpus bodies, and the source-lock test requires the shipped modules to remain AST-identical. The generic Storage stdlib fixture runner and oracle replay execute those bodies locally; resolver tests prove the modules are admitted only for `firebase.storage`, lower to plain v2, and fail closed under Firestore. The module surface intentionally excludes the advanced integrity, metadata-diff, and cross-service lookup candidates. (Structured evidence: [`storage-stdlib-source-lock.test.ts`](https://github.com/davideast/pyric/blob/main/packages/conformance/test/src/storage-stdlib-source-lock.test.ts#L53), [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L7), [`stdlib-cases.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/stdlib-cases.test.ts#L5), [`stdlib.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/stdlib.test.ts#L3)) | 132 |\n\n## RTDB rules engine — production simulator conformance (rules-rtdb corpus)\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Rules simulator | | `auth != null` gate on `.read`/`.write` — authed ops allow, signed-out ops deny | ✓ | [`oracle:rules-rtdb-r1-auth-only`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r1-auth-only.json) — production verdicts captured by deploy-observe-restore against the live oracle database (RTDB has no server-side rules test API), replayed verdict-for-verdict against the in-process simulator by [`packages/pyric/test/rules/rtdb/rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L2); all 4 cases match production. | 1 |\n| Rules simulator | | `$uid` path-variable ownership (`$uid === auth.uid`) — the owner path allows, a foreign uid and an anonymous request deny | ✓ | [`oracle:rules-rtdb-r2-own-uid`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r2-own-uid.json) — production verdicts captured by deploy-observe-restore, replayed verdict-for-verdict against the in-process simulator; all 5 cases match production, so the simulator binds the path variable against `auth.uid` exactly as production does. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L2)) | 2 |\n| Rules simulator | | Write-rule CASCADE — a truthy ancestor `.write` grants the write regardless of a deeper `!data.exists()` rule, so the populated-path write ALLOWS | ✓ | [`oracle:rules-rtdb-r3-data-exists`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r3-data-exists.json) — production verdicts captured by deploy-observe-restore, replayed verdict-for-verdict. Production ALLOWS the write to the populated path even though the child rule is `!data.exists()`: the ancestor `.write: auth != null` already granted it, and RTDB never consults a deeper rule to revoke a grant. All 3 cases match; the simulator models the same cascade. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L2)) | 3 |\n| Rules simulator | | `.validate` VETO — a failing child `.validate` denies a write the `.write` rule would otherwise permit (validate does not cascade and cannot be overridden) | ✓ | [`oracle:rules-rtdb-r4-validate-structure`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r4-validate-structure.json) — production verdicts captured by deploy-observe-restore, replayed verdict-for-verdict; both cases match production. This row was the corpus's one historical simulator-vs-production divergence: at the 2026-05-18 agreement capture the simulator ALLOWED the missing-body write because it did not veto on the child `.validate`. The simulator now denies it, and this fresh capture re-confirms production's DENY, so the divergence is RESOLVED and needs no pin. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L2)) | 4 |\n| Rules simulator | | Cascade is GRANT-ONLY — a root `.read: true` grants every descendant read (authed and anonymous), and a deeper `.write: false` cannot revoke a truthy ancestor `.write` grant | ✓ | [`oracle:rules-rtdb-r5-cascade-root-grant`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r5-cascade-root-grant.json) — production verdicts captured by deploy-observe-restore, replayed verdict-for-verdict; all 4 cases ALLOW in production, including the write to a node whose own `.write` is literal `false`. The simulator reproduces grant-only cascade rather than treating the deeper `false` as a veto. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L2)) | 5 |\n| Rules simulator | | Literal `.read`/`.write: false` — the deny-all baseline denies every op, authed or anonymous, read or write | ✓ | [`oracle:rules-rtdb-r6-deny-everything`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r6-deny-everything.json) — production verdicts captured by deploy-observe-restore, replayed verdict-for-verdict; all 3 cases DENY in production and in the simulator. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L2)) | 6 |\n| Rules simulator | | Nested path variable in an expression (`$sessionId === auth.uid`) — the matching session allows, a mismatched session denies, and an unmatched anonymous op denies with no matching rule | ✓ | [`oracle:rules-rtdb-r7-pathvar-binding`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r7-pathvar-binding.json) — production verdicts captured by deploy-observe-restore, replayed verdict-for-verdict; all 4 cases match. The simulator binds the nested variable identically and returns NO_MATCHING_RULE (read as deny) where production denies for want of a rule. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L2)) | 7 |\n| Rules simulator | | `newData` is projected at the RULE node, not the written path — a root `.write` reading `newData.hasChildren(['owner'])` sees `{item:{...}}` for a write one level deeper, so the predicate is false and the write DENIES | ✓ | [`oracle:rules-rtdb-r8-combined-check`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r8-combined-check.json) — production verdicts captured by deploy-observe-restore, replayed verdict-for-verdict. Production DENIES all three writes, including the \"matching owner\" case whose `owner` field lives under `/item` rather than at the rule node; only the auth-gated read allows. All 4 cases match, so the simulator projects `newData` at the same node production does — rule placement relative to the written path decides the outcome. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L2)) | 8 |\n| Rules simulator | | Arithmetic and comparison operators in quota rules — `+` `-` `*` `/` `%`, `>` `>=` `<` `<=`, `!==`, loose `==`, `\\|\\|`, the ternary, and unary minus | ✓ | NEW ROW, 2026-07-12: production capture proves the simulator evaluates every arithmetic and ordering operator as production does, across increment-only counters, doubling and halving deltas, a parity check, inclusive and exclusive range caps, a negative sentinel, loose equality against zero, a disjunction, and a ternary tier bound. [`oracle:rules-rtdb-r9-quota-arithmetic`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r9-quota-arithmetic.json) matches production verdict-for-verdict on all 23 cases. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L2)) | 9 |\n| Rules simulator | | Snapshot structure and type guards — `hasChild`, `parent()`, `isString`, `isNumber`, `isBoolean` | ✓ | NEW ROW, 2026-07-12: production capture proves the simulator resolves the type predicates and the `parent()` path climb as production does, including RTDB's rule that a child `.validate` runs only for children PRESENT in `newData` — the name-without-age write is denied by that field's own parent guard, not by the absent sibling's type rule, which never runs. [`oracle:rules-rtdb-r10-snapshot-type-guards`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r10-snapshot-type-guards.json) matches production verdict-for-verdict on all 7 cases. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L2)) | 10 |\n| Rules simulator | | String methods on a snapshot value — `beginsWith`, `contains`, `endsWith`, `matches` with a regex literal, `.length`, `toLowerCase`, `toUpperCase`, and replace-ALL `replace` | ✓ | NEW ROW, 2026-07-12: production capture proves `String.replace` substitutes EVERY occurrence of the substring, not only the first — production ALLOWS the write whose rule is `newData.val().replace('_', '-') === 'a-b-c'` for the value `a_b_c`, which holds only under replace-all. The simulator delegated to JavaScript's `String.prototype.replace`, which given a string pattern substitutes only the first occurrence, and DENIED that write; the substitution now replaces every occurrence and the two-underscore case guards the regression. The same capture proves `toUpperCase` is a valid production rule method that the simulator's validator previously rejected outright as unknown. [`oracle:rules-rtdb-r11-string-validation`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r11-string-validation.json) matches production verdict-for-verdict on all 18 cases. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L12)) | 11 |\n| Rules simulator | | `now` (the server evaluation clock) and `auth.token` claims, read both dotted (`auth.token.firebase.sign_in_provider`) and subscripted (`auth.token['firebase']['sign_in_provider']`) | ✓ | NEW ROW, 2026-07-12: production capture proves the simulator resolves the server clock and the decoded ID-token claims as production does, through both access forms. The subscript's receiver is load-bearing: production's rules compiler ACCEPTS `auth.token[...]`, a map indexed by key, but REJECTS the same subscript applied to a snapshot value — `newData.val()['kind']` fails to compile with \"No such method/property\" — so `[]` is a map accessor, not a general indexing operator. [`oracle:rules-rtdb-r12-server-time-and-token`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r12-server-time-and-token.json) matches production verdict-for-verdict on all 7 cases. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L31)) | 12 |\n| Rules simulator | | `getPriority()` on a plain `set` (an unset priority reads null) and the `.indexOn` query-planner directive | ✓ | NEW ROW, 2026-07-12: production capture proves a plain `set` leaves a node's priority unset, so `getPriority()` reports null in production exactly as the simulator does, and that a ruleset carrying `.indexOn` deploys and evaluates with every verdict intact. The scope is exactly that: `.indexOn` never changes an allow/deny verdict, so the twin evidences only that production's compiler accepts and deploys the directive, and it says nothing about query behavior. A NON-null priority stays unverified because writing one requires `setWithPriority`, outside the corpus op vocabulary of read and set. [`oracle:rules-rtdb-r13-priority-and-index-directive`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r13-priority-and-index-directive.json) matches production verdict-for-verdict on all 4 cases. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L2)) | 13 |\n| Rules simulator | | The `root` binding — `root.child(...).exists()` resolved against the database root | ✓ | NEW ROW, 2026-07-12: production capture proves the simulator resolves the `root` binding and reports a missing child as absent exactly as production does. The scope is bounded by the rig: the capture runner mounts the scenario subtree under a per-run randomly-named namespace while the replay suite mounts it at the root, so a rule naming any PRESENT path through `root` would address a different node on each side and diverge for harness reasons. Only a sentinel key absent under BOTH roots is mount-invariant, so that is what this row verifies; a populated `root` lookup remains unverified and needs a rig that can seed outside the scenario subtree and mount both sides at the same depth. [`oracle:rules-rtdb-r14-root-lookup`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r14-root-lookup.json) matches production verdict-for-verdict on all 3 cases. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L31)) | 14 |\n| Rules simulator | | `.validate` reaches ancestors of the written path — each applicable rule is evaluated against the merged post-write value at its own location | ✓ | [`oracle:rules-rtdb-r15-validate-ancestor-scope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r15-validate-ancestor-scope.json) — production DENIES a deep write that leaves its validated ancestor without the required child, while the same write under a rule-free ancestor ALLOWS and the write ALLOWS once the ancestor requirement is seeded. The simulator now walks validation from the root through the write location against the merged post-write tree and matches all 5 captured production cases. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L2)) | 15 |\n\n## Current gaps\n\n### Documented divergences\n\nKnown differences between Pyric and production Firebase. Each remains tracked as a non-conforming row.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">Rules</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Mocked Storage-to-Firestore lookup evaluation: get/exists return and missing-field errors, repeated and same-resolved-path calls, get+exists composition, and lazy <code>||</code>/<code>&&</code>/ternary branches with hosted function-call diagnostics</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">DOCUMENTED MOCK-ORACLE DIVERGENCE, 2026-07-21: <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-firestore-lookup-budget.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>oracle:rules-storage-firestore-lookup-budget</code></a> matches the local evaluator on 13 of 14 cases and hosted diagnostics prove lazy branches omit lookup calls. The one mismatch is intentional and pinned on both sides: the Rules Test API ALLOWs three distinct mocked exists calls, while the evaluator DENYs the third distinct path to match the separately captured real-resource production contract in row #131. This row remains diverged because its mocked observation is not verdict-identical; it supplies no budget credit. (Structured evidence: <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L71\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rules-oracle-conformance.test.ts</code></a>)</div></div></details>\n</div>\n" }];
|
|
1
|
+
export const CONFORMANCE_DOCS_PAGES = [{ "slug": "conformance-scores", "label": "Conformance scores", "title": "Conformance scores", "markdown": "<!-- Generated from the conformance model (registry rows + surface contracts). Do not edit by hand; run bun run compat:generate. -->\n\n# Conformance scores\n\nMirror services measure the share of Firebase's public API that Pyric supports. Rules engines measure production-verified rules-language constructs. Not-implemented-yet, deprecated, deferred, and unverified items remain in their respective denominators. Overall combines only the public API rows. [How does Pyric know it works like Firebase?](../trust/how-we-know-it-matches-firebase/) explains the evidence and its limits.\n\n## Services\n\n<table class=\"compat-score-table\">\n<thead><tr><th>Service</th><th>Measured coverage</th></tr></thead>\n<tbody>\n<tr>\n<th scope=\"row\" class=\"compat-score-name\"><a href=\"../app-compat/\">App</a></th>\n<td class=\"compat-score-cell\"><div class=\"compat-score-stack\"><div class=\"compat-score-bar\"><span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 81.3%\"></span></span><span class=\"compat-score-pct\">81.3%</span></div><div class=\"compat-score-basis\">13 of 16 public API</div></div></td>\n</tr>\n<tr>\n<th scope=\"row\" class=\"compat-score-name\"><a href=\"../ai-compat/\">AI Logic</a></th>\n<td class=\"compat-score-cell\"><div class=\"compat-score-stack\"><div class=\"compat-score-bar\"><span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 67.1%\"></span></span><span class=\"compat-score-pct\">67.1%</span></div><div class=\"compat-score-basis\">147 of 219 public API</div></div></td>\n</tr>\n<tr>\n<th scope=\"row\" class=\"compat-score-name\"><a href=\"../auth-compat/\">Auth</a></th>\n<td class=\"compat-score-cell\"><div class=\"compat-score-stack\"><div class=\"compat-score-bar\"><span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 63.8%\"></span></span><span class=\"compat-score-pct\">63.8%</span></div><div class=\"compat-score-basis\">95 of 149 public API</div></div></td>\n</tr>\n<tr>\n<th scope=\"row\" class=\"compat-score-name\"><a href=\"../firestore-compat/\">Firestore</a></th>\n<td class=\"compat-score-cell\"><div class=\"compat-score-stack\"><div class=\"compat-score-bar\"><span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 100%\"></span></span><span class=\"compat-score-pct\">100%</span></div><div class=\"compat-score-basis\">182 of 182 public API</div></div></td>\n</tr>\n<tr>\n<th scope=\"row\" class=\"compat-score-name\"><a href=\"../database-compat/\">Realtime Database</a></th>\n<td class=\"compat-score-cell\"><div class=\"compat-score-stack\"><div class=\"compat-score-bar\"><span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 100%\"></span></span><span class=\"compat-score-pct\">100%</span></div><div class=\"compat-score-basis\">59 of 59 public API</div></div></td>\n</tr>\n<tr>\n<th scope=\"row\" class=\"compat-score-name\"><a href=\"../storage-compat/\">Storage</a></th>\n<td class=\"compat-score-cell\"><div class=\"compat-score-stack\"><div class=\"compat-score-bar\"><span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 77.1%\"></span></span><span class=\"compat-score-pct\">77.1%</span></div><div class=\"compat-score-basis\">27 of 35 public API</div></div></td>\n</tr>\n<tr>\n<th scope=\"row\" class=\"compat-score-name\"><a href=\"../messaging-compat/\">Messaging</a></th>\n<td class=\"compat-score-cell\"><div class=\"compat-score-stack\"><div class=\"compat-score-bar\"><span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 100%\"></span></span><span class=\"compat-score-pct\">100%</span></div><div class=\"compat-score-basis\">13 of 13 public API</div></div></td>\n</tr>\n<tr>\n<th scope=\"row\" class=\"compat-score-name\"><a href=\"../functions-rtdb-compat/\">Functions · RTDB</a></th>\n<td class=\"compat-score-cell\"><span class=\"compat-score-tbd\">Gathering metrics. Total score TBD.</span></td>\n</tr>\n<tr class=\"compat-score-row--rules\">\n<th scope=\"row\" class=\"compat-score-name\"><a href=\"../rules-compat/\">Rules</a></th>\n<td class=\"compat-score-cell\">\n<div class=\"compat-score-group\">\n<div class=\"compat-score-engine\">\n<span class=\"compat-score-engine-name\">Firestore Rules</span>\n<div class=\"compat-score-stack\"><div class=\"compat-score-bar\"><span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 97.1%\"></span></span><span class=\"compat-score-pct\">97.1%</span></div><div class=\"compat-score-basis\">136 of 140 rules-language constructs verified</div></div>\n</div>\n<div class=\"compat-score-engine\">\n<span class=\"compat-score-engine-name\">Storage Rules</span>\n<div class=\"compat-score-stack\"><div class=\"compat-score-bar\"><span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 97.1%\"></span></span><span class=\"compat-score-pct\">97.1%</span></div><div class=\"compat-score-basis\">67 of 69 rules-language constructs verified</div></div>\n</div>\n<div class=\"compat-score-engine\">\n<span class=\"compat-score-engine-name\">Realtime Database Rules</span>\n<div class=\"compat-score-stack\"><div class=\"compat-score-bar\"><span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 100%\"></span></span><span class=\"compat-score-pct\">100%</span></div><div class=\"compat-score-basis\">56 of 56 rules-language constructs verified</div></div>\n</div>\n</div>\n</td>\n</tr>\n<tr class=\"compat-score-row--overall\">\n<th scope=\"row\" class=\"compat-score-name\">Overall</th>\n<td class=\"compat-score-cell\"><div class=\"compat-score-stack\"><div class=\"compat-score-bar\"><span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 79.6%\"></span></span><span class=\"compat-score-pct\">79.6%</span></div><div class=\"compat-score-basis\">536 of 673 public API</div></div></td>\n</tr>\n</tbody>\n</table>\n" }, { "slug": "app-compat", "label": "App", "title": "pyric/app compatibility matrix", "markdown": "<!-- Generated from the conformance model (registry rows + surface contracts). Do not edit by hand; run bun run compat:generate. -->\n\n# `pyric/app` compatibility matrix\n\n<div class=\"compat-stat\">\n<p class=\"compat-stat-figure\">\n<span class=\"compat-stat-pct\">81.3%</span>\n<span class=\"compat-stat-label\">of the public API supported</span>\n</p>\n<div class=\"compat-meters\">\n<span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 81.3%\"></span></span>\n</div>\n<p class=\"compat-stat-denom\">13 of 16 public API</p>\n</div>\n\n\n## Status legend\n\n| Status | Meaning |\n|---|---|\n| ✓ | **Conforming** — the mirror matches prod, locked by a passing replay |\n| ⚠ | **Diverged (documented)** — intentional difference with a written reason |\n| — | **Not implemented yet** — deliberately or deferred |\n| ? | **Unverified** — claim from docs not yet observed prod-side |\n\n## `initializeApp(config, name?)` — the app registry\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| initializeApp(config) | | Registers the default app under the name `'[DEFAULT]'`; `getApps()` has length 1 and `getApp()` (no arg) resolves the same instance | ✓ | oracle: [`app-registry-initializeapp-default`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-initializeapp-default.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L109) | 1 |\n| initializeApp(config, 'secondary') | | Registers a named app alongside the default; `getApp('secondary')` resolves it and `getApps()` has length 2 (default + named) | ✓ | oracle: [`app-registry-initializeapp-named`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-initializeapp-named.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L119) | 2 |\n| initializeApp(config) | | A same-name re-initialization with a DIFFERENT config throws `FirebaseError` code `app/duplicate-app`, with the app name embedded in the message | ✓ | oracle: [`app-registry-initializeapp-duplicate-name`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-initializeapp-duplicate-name.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L129) | 3 |\n| initializeApp(config) | | A same-name re-initialization with EQUAL config is idempotent — no throw, returns the existing instance, and `getApps()` stays length 1 | ✓ | oracle: [`app-registry-initializeapp-duplicate-config`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-initializeapp-duplicate-config.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L141) | 4 |\n| getApp() | | `getApp()` with no name resolves the default app instance; its name is `'[DEFAULT]'` | ✓ | oracle: [`app-registry-getapp-default`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-getapp-default.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L149) | 5 |\n| getApp('secondary') | | `getApp('secondary')` resolves the named app instance; its name is `'secondary'` | ✓ | oracle: [`app-registry-getapp-named`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-getapp-named.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L157) | 6 |\n| getApp(name) | | `getApp(name)` for a name that was never initialized throws `FirebaseError` code `app/no-app`, directing the caller to `initializeApp()` | ✓ | oracle: [`app-registry-getapp-unknown-name`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-getapp-unknown-name.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L166) | 7 |\n| getApps() | | `getApps()` returns an array containing every registered app by identity (the exact instances, not copies) | ✓ | oracle: [`app-registry-getapps-contents`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-getapps-contents.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L174) | 8 |\n| deleteApp(app) | | `deleteApp(app)` returns a Promise, deregisters the app (so `getApps()` shrinks), a later `getApp(name)` throws `app/no-app`, and the name can be re-initialized afterwards | ✓ | oracle: [`app-registry-deleteapp`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-deleteapp.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L185) | 9 |\n| deleteApp(app) | | `deleteApp` on an already-deleted app throws `FirebaseError` code `app/app-deleted` | ✓ | oracle: [`app-registry-deleteapp-double`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-deleteapp-double.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L205) | 10 |\n| SDK_VERSION | | `SDK_VERSION` is the Firebase client SDK semver string whose behavior pyric currently mirrors, pinned to the oracle version (`12.13.0`) | ✓ | oracle: [`app-registry-sdk-version`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-sdk-version.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L370) | 11 |\n| FirebaseError | | `FirebaseError` is an app-owned Error subclass: `instanceof Error`, `constructor.name` is `'FirebaseError'`, and it preserves `.code` and `.message` without loading `firebase/app` | ✓ | oracle: [`app-registry-firebaseerror-shape`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-firebaseerror-shape.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L379) | 12 |\n| onLog / setLogLevel | | `onLog(cb)` / `setLogLevel(level)` are a functioning app-owned diagnostic-logger seam: registering a handler returns undefined, raising the threshold takes effect, and a malformed `registerVersion` emits a `warn` entry (type `@firebase/app`) to the handler | ✓ | oracle: [`app-registry-onlog-setloglevel`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-onlog-setloglevel.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L390) | 13 |\n| registerVersion | | `registerVersion(library, version)` accepts a well-formed registration and returns undefined without throwing; malformed values emit the observed warning through the app-owned logger | ✓ | oracle: [`app-registry-registerversion`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-registerversion.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L417) | 14 |\n| initializeServerApp | | Not implemented; disposition `app.server-app` owns the reviewed availability and rationale | — | disposition:app.server-app | 15 |\n| initializeApp(options, settings) | | `initializeApp` snapshots options, accepts a settings object, initializes `automaticDataCollectionEnabled`, and leaves that app property mutable | ✓ | oracle: [`app-registry-initializeapp-settings-options`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-initializeapp-settings-options.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L279) | 16 |\n| initializeApp(equalOptions, 'secondary') | | Equal-config named apps are distinct app containers with equal option values and independent name-keyed registry identity | ✓ | oracle: [`app-registry-initializeapp-named-equal-config`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-initializeapp-named-equal-config.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L296) | 17 |\n| getAuth/getFirestore/getDatabase/getStorage(app) | | Equal-config named apps own distinct app-associated service handles while resolving the same configured RTDB and Storage backend locators | ✓ | oracle: [`app-registry-multi-app-service-containers`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-multi-app-service-containers.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L307) | 18 |\n| initializeApp(differentOptions, 'secondary') | | Production permits a differently configured named app; Pyric rejects it because one runtime currently owns exactly one sandbox backend | ⚠ | oracle: [`app-registry-initializeapp-named-different-config`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-initializeapp-named-different-config.json) (firebase 12.13.0) + replay pins `app/multiple-configs-not-supported`; [`packages/cli/test/e2e/app-multi-app.pw.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/e2e/app-multi-app.pw.ts#L148) proves the same lock is enforced by the authoritative SharedWorker across same-origin tabs, not only by one page registry (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L340)) | 19 |\n| deleteApp(app); initializeApp(differentOptions) | | Production permits a different configuration after deletion; Pyric retains the runtime backend lock and rejects the reinitialization | ⚠ | oracle: [`app-registry-delete-reinitialize-different-config`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-delete-reinitialize-different-config.json) (firebase 12.13.0) + replay pins `app/multiple-configs-not-supported` (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L351)) | 20 |\n| initializeApp() | | Without Hosting-provided defaults, omitting options throws FirebaseError code `app/no-options` with the production message | ✓ | oracle: [`app-registry-initializeapp-no-options`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-initializeapp-no-options.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L362) | 21 |\n| getAuth/getFirestore/getDatabase/getStorage() | | With a registered default app, each no-argument service factory resolves a service associated with that exact default app | ✓ | oracle: [`app-registry-default-service-factories`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-default-service-factories.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L100) | 22 |\n| initializeApp(equalOptions, name) | | Equal-config app instances connect to one logical backend: data written through one app is readable through another, while their active Auth sessions remain independent | ✓ | oracle: [`app-production-multi-app-topology`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-production-multi-app-topology.json) (firebase 12.13.0, real Chromium against production) + twin replay: [`production-multi-app-oracle.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/production-multi-app-oracle.test.ts#L15); served SharedWorker replay: [`app-multi-app.pw.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/e2e/app-multi-app.pw.ts#L148) | 23 |\n| deleteApp(app); app.name / app.options / app.automaticDataCollectionEnabled | | After deletion resolves, every public FirebaseApp property accessor throws `FirebaseError` code `app/app-deleted` | ✓ | oracle: [`app-registry-deleted-property-access`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-deleted-property-access.json) (firebase 12.13.0) + replay: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L215) | 24 |\n| deleteApp(app); getAuth/getFirestore/getDatabase/getStorage/getAI(app) | | After deletion, fresh Auth/Firestore/RTDB/Storage factories reject; cached factories return retained handles; retained Auth sign-out resolves but a new anonymous sign-in rejects `app/app-deleted`; Firestore reads report termination, RTDB refuses new refs, Storage refs remain constructible, and `getAI(deletedApp)` returns an app-associated handle | ✓ | oracles: [`app-registry-deleted-service-factories`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-deleted-service-factories.json) and real-Chromium [`app-production-multi-app-topology`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-production-multi-app-topology.json) (firebase 12.13.0) + replays: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L237), [`deleted-service-lifecycle.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/deleted-service-lifecycle.test.ts#L23), and [`production-multi-app-oracle.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/production-multi-app-oracle.test.ts#L15) | 25 |\n| deleteApp(app) | | Deleting one app terminates its Firestore listener through the error callback with code `aborted`, silently stops its RTDB listener, and leaves equal-config sibling listeners and the shared backend usable | ✓ | oracle: [`app-production-multi-app-topology`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-production-multi-app-topology.json) (firebase 12.13.0, real Chromium against production) + twin replay: [`production-multi-app-oracle.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/production-multi-app-oracle.test.ts#L15); focused family tests: [`multi-app-listener-auth.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/multi-app-listener-auth.test.ts#L2) and served [`app-multi-app.pw.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/e2e/app-multi-app.pw.ts#L183) | 26 |\n| getAI(app, customEngine); deleteApp(app); retainedModel operation | | Served worker mode rejects page-local custom AnswerEngine objects; a model retained from a deleted app rejects, while an equal-config sibling model remains usable through its own app-scoped worker port | ? | `packages/cli/test/e2e/app-deletion.pw.ts` observes AI code `unsupported` for a custom engine, then exercises retained and sibling models through canonical served imports after deleting one app; `packages/pyric/src/ai/sandbox-plane.ts` guards every model operation. A credentialed production AI lifecycle capture is still needed | 27 |\n\n## Current gaps\n\n### Documented divergences\n\nKnown differences between Pyric and production Firebase. Each remains tracked as a non-conforming row.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">initializeApp(differentOptions, 'secondary')</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Production permits a differently configured named app; Pyric rejects it because one runtime currently owns exactly one sandbox backend</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">oracle: <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-initializeapp-named-different-config.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>app-registry-initializeapp-named-different-config</code></a> (firebase 12.13.0) + replay pins <code>app/multiple-configs-not-supported</code>; <a href=\"https://github.com/davideast/pyric/blob/main/packages/cli/test/e2e/app-multi-app.pw.ts#L148\" target=\"_blank\" rel=\"noopener noreferrer\"><code>packages/cli/test/e2e/app-multi-app.pw.ts</code></a> proves the same lock is enforced by the authoritative SharedWorker across same-origin tabs, not only by one page registry (Structured evidence: <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L340\" target=\"_blank\" rel=\"noopener noreferrer\"><code>oracle-conformance.test.ts</code></a>)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">deleteApp(app); initializeApp(differentOptions)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Production permits a different configuration after deletion; Pyric retains the runtime backend lock and rejects the reinitialization</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">oracle: <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-registry-delete-reinitialize-different-config.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>app-registry-delete-reinitialize-different-config</code></a> (firebase 12.13.0) + replay pins <code>app/multiple-configs-not-supported</code> (Structured evidence: <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/oracle-conformance.test.ts#L351\" target=\"_blank\" rel=\"noopener noreferrer\"><code>oracle-conformance.test.ts</code></a>)</div></div></details>\n</div>\n\n### Not implemented yet\n\nTracked behavior that is not implemented in the current contract.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"unsupported\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">initializeServerApp</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Not implemented; disposition <code>app.server-app</code> owns the reviewed availability and rationale</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">disposition:app.server-app</div></div></details>\n</div>\n\n### Unverified\n\nTracked behavior whose available evidence does not yet establish the production result.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"unverified\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unverified\" role=\"img\" aria-label=\"Unverified\" title=\"Unverified\"></span><span class=\"compat-main\"><code class=\"compat-api\">getAI(app, customEngine); deleteApp(app); retainedModel operation</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Served worker mode rejects page-local custom AnswerEngine objects; a model retained from a deleted app rejects, while an equal-config sibling model remains usable through its own app-scoped worker port</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>packages/cli/test/e2e/app-deletion.pw.ts</code> observes AI code <code>unsupported</code> for a custom engine, then exercises retained and sibling models through canonical served imports after deleting one app; <code>packages/pyric/src/ai/sandbox-plane.ts</code> guards every model operation. A credentialed production AI lifecycle capture is still needed</div></div></details>\n</div>\n\n## Reviewed public-runtime gaps\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">app.server-app</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Server-app (SSR) initialization: a FirebaseServerApp carries per-request auth/heartbeat state with no decided sandbox mirror pattern yet. Deferred, not out of scope — tracked as an unsupported registry row (registry/app.ts app#15).</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>initializeServerApp</code></div>\n<div class=\"compat-note\">registry:app#15</div></div>\n</details>\n</div>\n" }, { "slug": "ai-compat", "label": "AI Logic", "title": "pyric/ai compatibility matrix", "markdown": "<!-- Generated from the conformance model (registry rows + surface contracts). Do not edit by hand; run bun run compat:generate. -->\n\n# `pyric/ai` compatibility matrix\n\n<div class=\"compat-stat\">\n<p class=\"compat-stat-figure\">\n<span class=\"compat-stat-pct\">67.1%</span>\n<span class=\"compat-stat-label\">of the public API supported</span>\n</p>\n<div class=\"compat-meters\">\n<span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 67.1%\"></span></span>\n</div>\n<p class=\"compat-stat-denom\">147 of 219 public API</p>\n</div>\n\n\n## Status legend\n\n| Status | Meaning |\n|---|---|\n| ✓ | **Conforming**: sandbox matches prod, locked by a passing probe |\n| ⚠ | **Diverged (documented)**: intentional difference with a written reason |\n\n## `getAI(target)` and dispatch\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getAI(target) | | `getAI(sandbox)` returns an AI handle bound to the sandbox target; a model minted from it answers through the in-process answer engine | ✓ | [`unit:instances.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/instances.test.ts#L22) test `ai#getai-sandbox-dispatch` (no capture; structural dispatch claim) | getai-sandbox-dispatch |\n| getAI(target) | | After package resolution selects the mirror, `getAI(app)` uses the app's sandbox and the returned handle carries the app | ✓ | [`unit:instances.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/instances.test.ts#L31) test `ai#getai-app-dispatch` (package-resolution dispatch claim) | getai-app-dispatch |\n| getAI(target) | | With no options the backend defaults to `GoogleAIBackend`, `backendType` is `GOOGLE_AI`, and the AI handle location is the empty string | ✓ | [`unit:instances.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/instances.test.ts#L47) test `ai#getai-default-backend` (matches upstream AIOptions default) | getai-default-backend |\n| getAI(target) | | Repeat `getAI` calls with the same target return a stable handle | ✓ | [`unit:instances.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/instances.test.ts#L54) test `ai#getai-idempotent` (no capture; structural claim) | getai-idempotent |\n| getAI(target, options) | | `getAI(sandbox, { backend: new GoogleAIBackend(), engine: { kind: \"scripted\" } })` selects the scripted engine explicitly and behaves identically to the zero-config default | ✓ | [`unit:instances.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/instances.test.ts#L58) test `ai#getai-engine-option` (engine seam per packages/conformance/docs/ai/cdd-deltas.md) | getai-engine-option |\n| VertexAIBackend | | `VertexAIBackend` carries `backendType` `VERTEX_AI`; its location and the resulting AI handle location default to `us-central1` | ✓ | [`unit:instances.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/instances.test.ts#L72) test `ai#backend-vertex` (matches upstream constructor default) | backend-vertex |\n| getGenerativeModel(ai, modelParams) | | A short model name such as `gemini-flash-lite-latest` normalizes to the `models/` resource name on `GenerativeModel.model` | ✓ | [`unit:instances.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/instances.test.ts#L80) test `ai#model-name-short` (upstream AIModel normalization on the GoogleAI backend) | model-name-short |\n| getGenerativeModel(ai, modelParams) | | A `models/`-prefixed name is accepted without double prefixing | ⚠ normalization | [`unit:instances.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/instances.test.ts#L86) test `ai#model-name-prefixed` (no capture; normalization claim) | model-name-prefixed |\n| getGenerativeModel(ai, modelParams) | | `getGenerativeModel` without `modelParams.model` throws an `AIError` with code `no-model` | ✓ | [`unit:instances.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/instances.test.ts#L93) test `ai#model-name-required` (upstream throw contract) | model-name-required |\n| getAI(sandbox) | | The sandbox target with the scripted engine performs no network I/O for generateContent | ✓ | [`unit:instances.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/instances.test.ts#L105) test `ai#getai-sandbox-no-network` (ruling 1 of the engine placement deltas: the scripted engine does no I/O anywhere) | getai-sandbox-no-network |\n\n## `GenerativeModel.generateContent` envelope\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| generateContent() | | The response envelope top-level key set is exactly `candidates`, `modelVersion`, `responseId`, `usageMetadata` | ✓ | Capture ai-generate-minimal-envelope replayed by packages/pyric/test/ai/generate-content.test.ts test `ai#generate-envelope-keys` (Structured evidence: [`ai-generate-minimal-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-minimal-envelope.json), [`generate-content.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/generate-content.test.ts#L4)) | generate-envelope-keys |\n| generateContent() | | The candidate key set is `content`, `finishReason`, `index`, and `index` is present on the wire (0 for the single candidate) | ✓ | Capture ai-generate-minimal-envelope (candidateHasIndexOnWire) replayed by packages/pyric/test/ai/generate-content.test.ts test `ai#generate-candidate-keys` (Structured evidence: [`ai-generate-minimal-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-minimal-envelope.json), [`generate-content.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/generate-content.test.ts#L4)) | generate-candidate-keys |\n| generateContent() | | Candidate content carries role `model` and the content key set is `parts`, `role` | ✓ | Capture ai-generate-minimal-envelope replayed by packages/pyric/test/ai/generate-content.test.ts test `ai#generate-role-model` (Structured evidence: [`ai-generate-minimal-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-minimal-envelope.json), [`generate-content.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/generate-content.test.ts#L4)) | generate-role-model |\n| generateContent() | | A normal completion finishes with `finishReason` `STOP` | ✓ | Capture ai-generate-minimal-envelope replayed by packages/pyric/test/ai/generate-content.test.ts test `ai#generate-finish-stop` (Structured evidence: [`ai-generate-minimal-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-minimal-envelope.json), [`generate-content.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/generate-content.test.ts#L4)) | generate-finish-stop |\n| generateContent() | | The usageMetadata key set on a minimal text call is `candidatesTokenCount`, `promptTokenCount`, `promptTokensDetails`, `serviceTier`, `totalTokenCount` | ✓ | Capture ai-generate-minimal-envelope replayed by packages/pyric/test/ai/generate-content.test.ts test `ai#generate-usage-key-set` (Structured evidence: [`ai-generate-minimal-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-minimal-envelope.json), [`generate-content.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/generate-content.test.ts#L4)) | generate-usage-key-set |\n| generateContent() | | `usageMetadata.serviceTier` rides the wire even though the 2.12.0 SDK typings do not declare it | ✓ | Capture ai-generate-minimal-envelope (usageServiceTierPresent) replayed by packages/pyric/test/ai/generate-content.test.ts test `ai#generate-usage-service-tier` (Structured evidence: [`ai-generate-minimal-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-minimal-envelope.json), [`generate-content.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/generate-content.test.ts#L4)) | generate-usage-service-tier |\n| generateContent() | | `modelVersion` and `responseId` are present nonempty strings; the sandbox mints them deterministically | ✓ | Capture ai-generate-minimal-envelope replayed by packages/pyric/test/ai/generate-content.test.ts test `ai#generate-modelversion-responseid` (Structured evidence: [`ai-generate-minimal-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-minimal-envelope.json), [`generate-content.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/generate-content.test.ts#L4)) | generate-modelversion-responseid |\n| generateContent(request) | | A plain string request is wrapped as a single user turn before it reaches the engine | ✓ | [`unit:generate-content.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/generate-content.test.ts#L82) test `ai#generate-string-request` (no capture; upstream request formatting claim) | generate-string-request |\n| generateContent(request) | | A top-level `systemInstruction` is accepted and the response envelope shape is unaffected | ✓ | Capture ai-system-instruction-accepted replayed by packages/pyric/test/ai/generate-content.test.ts test `ai#generate-system-instruction` (Structured evidence: [`ai-system-instruction-accepted`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-system-instruction-accepted.json), [`generate-content.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/generate-content.test.ts#L100)) | generate-system-instruction |\n| generateContent(request) | | `responseMimeType` `application/json` plus a `responseSchema` yields a text part that parses as JSON with the schema key set | ✓ | Capture ai-structured-output-shape replayed by packages/pyric/test/ai/generate-content.test.ts test `ai#generate-structured-output` (Structured evidence: [`ai-structured-output-shape`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-structured-output-shape.json), [`generate-content.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/generate-content.test.ts#L110)) | generate-structured-output |\n| generateContent(request) | | With `thinkingConfig` on the probe model, text parts carry `thoughtSignature` and no part is flagged `thought: true` | ✓ | Capture ai-thinking-thought-parts (partKeySets, anyThoughtPart false) replayed by packages/pyric/test/ai/generate-content.test.ts test `ai#generate-thinking-signature` (Structured evidence: [`ai-thinking-thought-parts`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-thinking-thought-parts.json), [`generate-content.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/generate-content.test.ts#L125)) | generate-thinking-signature |\n| generateContent(request, singleRequestOptions) | | A pre-aborted `SingleRequestOptions.signal` rejects the call | ✓ | [`unit:generate-content.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/generate-content.test.ts#L138) test `ai#generate-abort-signal` (no capture; upstream SingleRequestOptions contract) | generate-abort-signal |\n| generateContent() | | Token counts are minted without a tokenizer, and the minimal envelope omits `safetyRatings`, matching the captured candidate key set | ✓ | Capture ai-generate-minimal-envelope replayed by packages/pyric/test/ai/generate-content.test.ts test `ai#generate-decoration-synthesized` (Structured evidence: [`ai-generate-minimal-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-minimal-envelope.json), [`generate-content.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/generate-content.test.ts#L4)) | generate-decoration-synthesized |\n\n## `generateContentStream` framing and aggregation\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| generateContentStream() | | `result.stream` async-iterates response chunks via `for await`; each chunk is a complete GenerateContentResponse | ✓ | Capture ai-generate-stream-framing replayed by packages/pyric/test/ai/streaming.test.ts test `ai#stream-async-iterable` (Structured evidence: [`ai-generate-stream-framing`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-stream-framing.json), [`streaming.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/streaming.test.ts#L4)) | stream-async-iterable |\n| generateContentStream() wire framing | | Every SSE event is `data: ` prefixed and its payload parses as a complete JSON document | ✓ | Capture ai-generate-stream-framing (allEventsDataPrefixed) replayed byte-level by packages/pyric/test/ai/streaming.test.ts test `ai#stream-data-prefixed` (Structured evidence: [`ai-generate-stream-framing`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-stream-framing.json), [`streaming.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/streaming.test.ts#L4)) | stream-data-prefixed |\n| generateContentStream() wire framing | | SSE events are separated by CRLF CRLF | ✓ | Capture ai-generate-stream-framing (separatorIsCrlfCrlf) replayed byte-level by packages/pyric/test/ai/streaming.test.ts test `ai#stream-separator-crlf` (Structured evidence: [`ai-generate-stream-framing`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-stream-framing.json), [`streaming.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/streaming.test.ts#L4)) | stream-separator-crlf |\n| generateContentStream() | | `finishReason` appears only on the last chunk of a stream | ✓ | Capture ai-generate-stream-framing (finishReasonOnlyOnLastChunk) replayed by packages/pyric/test/ai/streaming.test.ts test `ai#stream-finish-last-chunk` (Structured evidence: [`ai-generate-stream-framing`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-stream-framing.json), [`streaming.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/streaming.test.ts#L4)) | stream-finish-last-chunk |\n| generateContentStream() | | `usageMetadata` rides every chunk, not only the last one | ✓ | Capture ai-generate-stream-framing (usageMetadataChunkIndexes covers all chunks) replayed by packages/pyric/test/ai/streaming.test.ts test `ai#stream-usage-every-chunk` (Structured evidence: [`ai-generate-stream-framing`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-stream-framing.json), [`streaming.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/streaming.test.ts#L4)) | stream-usage-every-chunk |\n| generateContentStream() | | Every chunk carries `candidates` or `usageMetadata` | ✓ | Capture ai-generate-stream-framing (everyEventHasCandidatesOrUsage) replayed by packages/pyric/test/ai/streaming.test.ts test `ai#stream-chunk-envelope` (Structured evidence: [`ai-generate-stream-framing`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-stream-framing.json), [`streaming.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/streaming.test.ts#L4)) | stream-chunk-envelope |\n| generateContentStream() | | `result.response` resolves to an aggregated response whose text is the concatenation of the streamed text parts | ✓ | [`unit:streaming.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/streaming.test.ts#L107) test `ai#stream-response-aggregate` (aggregation semantics; text values come from an explicit script) | stream-response-aggregate |\n| generateContentStream() | | The aggregated response carries the final chunk `finishReason` and `usageMetadata` | ⚠ metadata carry | [`unit:streaming.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/streaming.test.ts#L117) test `ai#stream-aggregate-final-meta` (aggregation semantics derived from the framing capture) | stream-aggregate-final-meta |\n\n## `ChatSession` history and streaming turns\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| GenerativeModel.startChat() | | `startChat` returns a `ChatSession` seeded with `StartChatParams.history` | ✓ | [`unit:upstream-ai-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/upstream-ai-probes.test.ts#L6) (I1 validateChatHistory accept/reject via startChat) + [`unit:chat-session.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/chat-session.test.ts#L32) test `ai#chat-startchat` | chat-startchat |\n| ChatSession.sendMessage() / getHistory() | | `sendMessage` appends the user turn and the model turn; `getHistory()` returns the ordered `Content[]` with alternating roles | ⚠ clone | [`unit:chat-session.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/chat-session.test.ts#L45) test `ai#chat-history-threads` (no capture; history threading claim) | chat-history-threads |\n| ChatSession.getHistory() | | Blocked prompts and blocked candidates are excluded from `getHistory()` | ⚠ blocked history | [`unit:chat-session.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/chat-session.test.ts#L61) test `ai#chat-history-excludes-blocked` (upstream JSDoc contract; exercised with a scripted blocked envelope) | chat-history-excludes-blocked |\n| ChatSession.sendMessage() | | A `sendMessage` result carries the same envelope facts as `generateContent`: the four top-level keys and role `model` | ✓ | Capture ai-generate-minimal-envelope replayed by packages/pyric/test/ai/chat-session.test.ts test `ai#chat-sendmessage-envelope` (Structured evidence: [`ai-generate-minimal-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-minimal-envelope.json), [`chat-session.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/chat-session.test.ts#L11)) | chat-sendmessage-envelope |\n| ChatSession.sendMessageStream() | | `sendMessageStream` returns a stream plus a response promise; history updates after aggregation completes | ✓ | [`unit:chat-session.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/chat-session.test.ts#L80) test `ai#chat-sendmessagestream` (no capture; streaming turn claim) | chat-sendmessagestream |\n| ChatSession.sendMessageStream() | | Exactly one user turn is recorded per `sendMessageStream` call; the mirror implements the 2.13.0 fixed semantics, not the installed 2.12.0 duplicate-user-turn bug | ⚠ 2.13.0 semantics | [`unit:chat-session.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/chat-session.test.ts#L93) test `ai#chat-stream-single-user-turn` (no capture; divergence pinned by ruling, see notes) | chat-stream-single-user-turn |\n| POSSIBLE_ROLES | | `POSSIBLE_ROLES` is exactly `[\"user\", \"model\", \"function\", \"system\"]` | ✓ | [`unit:chat-session.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/chat-session.test.ts#L108) test `ai#chat-role-vocabulary` (upstream constant; distinct from the production wire role vocabulary in ai-error-bad-role) | chat-role-vocabulary |\n\n## Function calling\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| functionCall parts | | A functionCall part carries the key set `args`, `id`, `name`, and `args` arrives as a parsed JSON object, not a string | ✓ | Capture ai-function-call-shape (functionCallKeySet, argsIsObjectNotString) replayed by packages/pyric/test/ai/function-calling.test.ts test `ai#fncall-part-shape` (Structured evidence: [`ai-function-call-shape`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-function-call-shape.json), [`function-calling.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/function-calling.test.ts#L3)) | fncall-part-shape |\n| toolConfig.functionCallingConfig | | Mode `ANY` forces a functionCall part in the response and the candidate finishes `STOP` | ✓ | Capture ai-function-call-shape (captured under mode ANY, finishReason STOP) replayed by packages/pyric/test/ai/function-calling.test.ts test `ai#fncall-mode-any` (Structured evidence: [`ai-function-call-shape`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-function-call-shape.json), [`function-calling.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/function-calling.test.ts#L3)) | fncall-mode-any |\n| functionCall parts | | `functionCall.id` is present on the GoogleAI wire; the mirror emits an id on synthesized calls | ✓ | Capture ai-function-call-shape (id in functionCallKeySet) replayed by packages/pyric/test/ai/function-calling.test.ts test `ai#fncall-id-present` (Structured evidence: [`ai-function-call-shape`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-function-call-shape.json), [`function-calling.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/function-calling.test.ts#L3)) | fncall-id-present |\n| functionResponse round trip | | A round trip that threads the model functionCall turn back verbatim, thoughtSignature preserved, is accepted: the answer has a text part and no further functionCall part | ✓ | Capture ai-function-response-round replayed by packages/pyric/test/ai/function-calling.test.ts test `ai#fncall-round-trip` (Structured evidence: [`ai-function-response-round`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-function-response-round.json), [`function-calling.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/function-calling.test.ts#L4)) | fncall-round-trip |\n| functionResponse round trip | | A replayed model functionCall turn lacking `thoughtSignature` is rejected 400 INVALID_ARGUMENT with the thought-signature message | ✓ | Capture ai-error-fncall-missing-thought-signature replayed by packages/pyric/test/ai/function-calling.test.ts test `ai#fncall-thought-signature-required` (Structured evidence: [`ai-error-fncall-missing-thought-signature`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-error-fncall-missing-thought-signature.json), [`function-calling.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/function-calling.test.ts#L4)) | fncall-thought-signature-required |\n| scripted engine synthesis | | The engine mints a `thoughtSignature` on every functionCall part it synthesizes, so scripted tool round trips replay cleanly | ✓ | [`unit:function-calling.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/function-calling.test.ts#L4) test `ai#fncall-signature-minted` (capture ai-error-fncall-missing-thought-signature cited as the motivating rejection) (Structured evidence: [`ai-error-fncall-missing-thought-signature`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-error-fncall-missing-thought-signature.json)) | fncall-signature-minted |\n\n## `countTokens`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| countTokens() | | The countTokens envelope key set is exactly `promptTokensDetails`, `totalTokens` | ✓ | Capture ai-counttokens-envelope replayed by packages/pyric/test/ai/errors-counttokens.test.ts test `ai#counttokens-envelope` (Structured evidence: [`ai-counttokens-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-counttokens-envelope.json), [`errors-counttokens.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/errors-counttokens.test.ts#L39)) | counttokens-envelope |\n| countTokens() | | An identical payload returns an identical `totalTokens` across calls | ✓ | Capture ai-counttokens-envelope (deterministicAcrossTwoCalls) replayed by packages/pyric/test/ai/errors-counttokens.test.ts test `ai#counttokens-deterministic` (Structured evidence: [`ai-counttokens-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-counttokens-envelope.json), [`errors-counttokens.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/errors-counttokens.test.ts#L40)) | counttokens-deterministic |\n\n## Error envelopes\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| error envelope | | A model name production has never served fails 404 NOT_FOUND with the error key set `code`, `message`, `status` and no details | ✓ | Capture ai-error-unknown-model replayed by packages/pyric/test/ai/errors-counttokens.test.ts test `ai#error-unknown-model` (Structured evidence: [`ai-error-unknown-model`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-error-unknown-model.json), [`errors-counttokens.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/errors-counttokens.test.ts#L59)) | error-unknown-model |\n| error envelope | | A retired model family (Gemini 1.5) fails 404 NOT_FOUND with an ErrorInfo detail and a retirement message distinct from unknown-model | ✓ | Capture ai-error-retired-model replayed by packages/pyric/test/ai/errors-counttokens.test.ts test `ai#error-retired-model` (Structured evidence: [`ai-error-retired-model`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-error-retired-model.json), [`errors-counttokens.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/errors-counttokens.test.ts#L70)) | error-retired-model |\n| error envelope | | An invalid API key fails 400 INVALID_ARGUMENT, not 401, with ErrorInfo plus LocalizedMessage details and the message `API key not valid. Please pass a valid API key.` | ✓ | Capture ai-error-bad-api-key replayed by packages/pyric/test/ai/errors-counttokens.test.ts test `ai#error-bad-api-key` (Structured evidence: [`ai-error-bad-api-key`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-error-bad-api-key.json), [`errors-counttokens.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/errors-counttokens.test.ts#L82)) | error-bad-api-key |\n| error envelope | | An empty `contents` array fails 400 INVALID_ARGUMENT with the message `contents is not specified` | ✓ | Capture ai-error-empty-contents replayed by packages/pyric/test/ai/errors-counttokens.test.ts test `ai#error-empty-contents` (Structured evidence: [`ai-error-empty-contents`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-error-empty-contents.json), [`errors-counttokens.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/errors-counttokens.test.ts#L116)) | error-empty-contents |\n| error envelope | | An invalid content role fails 400 INVALID_ARGUMENT and the message lists the production role vocabulary: SYSTEM, SYSTEM_1, USER, ASSISTANT, DEVELOPER, CONTEXT, USER_CONTEXT, MODEL, USER | ✓ | Capture ai-error-bad-role replayed by packages/pyric/test/ai/errors-counttokens.test.ts test `ai#error-bad-role` (Structured evidence: [`ai-error-bad-role`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-error-bad-role.json), [`errors-counttokens.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/errors-counttokens.test.ts#L125)) | error-bad-role |\n| AIError | | HTTP failures surface as `AIError` with an `AIErrorCode` code and `customErrorData` carrying `status`, `statusText`, and `errorDetails` | ✓ | [`unit:errors-counttokens.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/errors-counttokens.test.ts#L83) test `ai#error-aierror-shape` (capture ai-error-bad-api-key cited as the sample envelope) (Structured evidence: [`ai-error-bad-api-key`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-error-bad-api-key.json)) | error-aierror-shape |\n| AIErrorCode | | `AIErrorCode` exposes the 14 documented codes, from `error` through `unsupported` | ✓ | [`unit:errors-counttokens.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/errors-counttokens.test.ts#L154) test `ai#error-code-vocabulary` (upstream constant vocabulary) | error-code-vocabulary |\n\n## Response helpers (`EnhancedGenerateContentResponse`)\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| response.text() | | `text()` concatenates the text parts of the first candidate | ✓ | [`unit:upstream-ai-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/upstream-ai-probes.test.ts#L11) (I3 text() across mixed parts) + [`unit:helpers-schema.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/helpers-schema.test.ts#L33) test `ai#helper-text` | helper-text |\n| response.text() | | `text()` throws on bad finish reasons such as `SAFETY` and on a blocked prompt | ✓ | [`unit:helpers-schema.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/helpers-schema.test.ts#L51) test `ai#helper-text-throws` (exercised with a scripted SAFETY envelope) | helper-text-throws |\n| response.functionCalls() | | `functionCalls()` returns the `FunctionCall` array from the functionCall parts, args as parsed objects | ✓ | [`unit:upstream-ai-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/upstream-ai-probes.test.ts#L113) (I3 text+functionCall mix) + Capture ai-function-call-shape replayed by [`unit:helpers-schema.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/helpers-schema.test.ts#L70) test `ai#helper-functioncalls` (Structured evidence: [`ai-function-call-shape`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-function-call-shape.json)) | helper-functioncalls |\n| response.thoughtSummary() | | `thoughtSummary()` returns undefined when no part is flagged `thought: true`, the captured lite-model case | ✓ | [`unit:upstream-ai-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/upstream-ai-probes.test.ts#L11) (I3 thoughtSummary from thought parts) + Capture ai-thinking-thought-parts replayed by [`unit:helpers-schema.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/helpers-schema.test.ts#L85) test `ai#helper-thoughtsummary` (Structured evidence: [`ai-thinking-thought-parts`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-thinking-thought-parts.json)) | helper-thoughtsummary |\n| response.inlineDataParts() | | `inlineDataParts()` returns the `InlineDataPart` array when inlineData parts exist and undefined when none do | ✓ | [`unit:helpers-schema.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/helpers-schema.test.ts#L93) test `ai#helper-inlinedataparts` (exercised with a scripted raw envelope) | helper-inlinedataparts |\n| response helpers | | Helpers tolerate omitted decoration: an envelope without `usageMetadata`, `finishReason`, or `safetyRatings` still serves `text()` without throwing | ✓ | [`unit:helpers-schema.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/helpers-schema.test.ts#L124) test `ai#helper-tolerates-missing-decor` (exercised with a scripted bare envelope) | helper-tolerates-missing-decor |\n\n## `Schema` builders\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Schema.object() | | `Schema.object` serializes to type `object` with `properties`, and `required` is derived by excluding `optionalProperties` | ✓ | [`unit:upstream-ai-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/upstream-ai-probes.test.ts#L8) (I2 empty optionalProperties + propertyOrdering) + [`unit:helpers-schema.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/helpers-schema.test.ts#L139) test `ai#schema-object-tojson` | schema-object-tojson |\n| Schema.enumString() | | `Schema.enumString` serializes the enum values with type `string` and format `enum` | ⚠ format | [`unit:helpers-schema.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/helpers-schema.test.ts#L153) test `ai#schema-string-enum` (upstream toJSON request shape; GoogleAI accepts only enum and date-time formats) | schema-string-enum |\n| Schema.string()/integer()/number()/boolean()/array() | | Each primitive builder serializes its `SchemaType`, and `array` carries `items` | ✓ | [`unit:helpers-schema.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/helpers-schema.test.ts#L160) test `ai#schema-primitives` (upstream toJSON request shape) | schema-primitives |\n| Schema.anyOf() | | `Schema.anyOf` returns an `AnyOfSchema` whose JSON carries an `anyOf` array of sub-schemas and no top-level type | ✓ | [`unit:upstream-ai-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/upstream-ai-probes.test.ts#L8) (I2 empty anyOf → invalid-schema) + [`unit:helpers-schema.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/helpers-schema.test.ts#L170) test `ai#schema-anyof` | schema-anyof |\n| generationConfig.responseSchema | | A built `Schema` serializes into `generationConfig.responseSchema` on the request and drives JSON output | ✓ | Capture ai-structured-output-shape replayed by packages/pyric/test/ai/helpers-schema.test.ts test `ai#schema-rides-request` (Structured evidence: [`ai-structured-output-shape`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-structured-output-shape.json), [`helpers-schema.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/helpers-schema.test.ts#L179)) | schema-rides-request |\n\n## Sandbox answer engine: scripted\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| scripted engine | | With no script the engine returns a deterministic synthesized response derived from the request, wire-true in shape: the captured envelope key sets hold | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L10) test `ai#scripted-zero-config` (capture ai-generate-minimal-envelope cited as the shape source) (Structured evidence: [`ai-generate-minimal-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-minimal-envelope.json)) | scripted-zero-config |\n| scripted engine | | The same unscripted request twice yields an identical envelope, candidates and usage included | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L47) test `ai#scripted-deterministic` (determinism claim from the scripted authoring deltas) | scripted-deterministic |\n| script(ai, entries) | | Script entries without matchers are consumed in FIFO queue order | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L57) test `ai#scripted-queue-order` (ruling 2 of the scripted authoring deltas) | scripted-queue-order |\n| script(ai, entries) | | Entries match by substring, regex, or predicate on the request; a matching entry wins over the plain queue | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L66) test `ai#scripted-matchers` (ruling 2 of the scripted authoring deltas) | scripted-matchers |\n| script(ai, entries) | | A raw Gemini envelope entry is returned verbatim, so an observation `behavior.raw` pastes in directly and captures are the corpus | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L79) test `ai#scripted-raw-envelope` (ruling 3 of the scripted authoring deltas) | scripted-raw-envelope |\n| script(ai, entries) | | A `text` shorthand expands to a wire-true envelope: finishReason STOP, usageMetadata with serviceTier, modelVersion, responseId | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L10) test `ai#scripted-shorthand-text` (capture ai-generate-minimal-envelope cited as the expansion target) (Structured evidence: [`ai-generate-minimal-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-minimal-envelope.json)) | scripted-shorthand-text |\n| script(ai, entries) | | A `functionCall` shorthand expands to a model turn whose functionCall part carries a minted `thoughtSignature` | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L116) test `ai#scripted-shorthand-functioncall` (capture ai-error-fncall-missing-thought-signature cited as the motivating rejection) (Structured evidence: [`ai-error-fncall-missing-thought-signature`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-error-fncall-missing-thought-signature.json)) | scripted-shorthand-functioncall |\n| script(ai, entries) | | A chunk-array shorthand declares chunk boundaries and the engine applies the captured framing, so authors never hand-write SSE | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L11) test `ai#scripted-stream-chunks` (capture ai-generate-stream-framing cited as the framing source) (Structured evidence: [`ai-generate-stream-framing`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/ai/ai-generate-stream-framing.json)) | scripted-stream-chunks |\n| script(ai, entries) | | Scripted text is the one place generated text values may be asserted: `response.text()` returns the scripted string exactly | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L142) test `ai#scripted-text-assertable` (evidence tier ruling 1: generated text is never compared anywhere else) | scripted-text-assertable |\n\n## Sandbox answer engine: openai translation\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| openai engine | | Gemini `contents` and `systemInstruction` translate to OpenAI chat messages, and the OpenAI response translates back to a Gemini envelope with role `model` | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L185) test `ai#openai-request-translation` (translation exercised against a local OpenAI-compatible mock) | openai-request-translation |\n| openai engine | | OpenAI `tool_call` ids are matched FIFO against Gemini functionResponse parts when replaying tool history | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L198) test `ai#openai-fifo-tool-ids` (lossy translation edge from ticket #96) | openai-fifo-tool-ids |\n| openai engine | | Streamed OpenAI tool_call deltas are buffered; the Gemini stream emits whole functionCall parts with parsed args, never partial fragments | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L230) test `ai#openai-buffered-fncalls` (lossy translation edge from ticket #96) | openai-buffered-fncalls |\n| openai engine | | The OpenAI `[DONE]` sentinel is never forwarded as a Gemini chunk; every emitted chunk is a parseable Gemini envelope | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L254) test `ai#openai-done-not-forwarded` (lossy translation edge from ticket #96) | openai-done-not-forwarded |\n| openai engine | | Parts flagged `thought: true` in history are skipped when replaying to an OpenAI upstream | ✓ | [`unit:engines.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/engines.test.ts#L271) test `ai#openai-thought-parts-skipped` (lossy translation edge from ticket #96) | openai-thought-parts-skipped |\n\n## Current gaps\n\n### Documented divergences\n\nKnown differences between Pyric and production Firebase. Each remains tracked as a non-conforming row.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">getGenerativeModel(ai, modelParams)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">A <code>models/</code>-prefixed name is accepted without double prefixing</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/instances.test.ts#L86\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:instances.test.ts</code></a> test <code>ai#model-name-prefixed</code> (no capture; normalization claim)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">generateContentStream()</code><span class=\"compat-sub\"><span class=\"compat-behavior\">The aggregated response carries the final chunk <code>finishReason</code> and <code>usageMetadata</code></span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/streaming.test.ts#L117\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:streaming.test.ts</code></a> test <code>ai#stream-aggregate-final-meta</code> (aggregation semantics derived from the framing capture)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">ChatSession.sendMessage() / getHistory()</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>sendMessage</code> appends the user turn and the model turn; <code>getHistory()</code> returns the ordered <code>Content[]</code> with alternating roles</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/chat-session.test.ts#L45\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:chat-session.test.ts</code></a> test <code>ai#chat-history-threads</code> (no capture; history threading claim)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">ChatSession.getHistory()</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Blocked prompts and blocked candidates are excluded from <code>getHistory()</code></span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/chat-session.test.ts#L61\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:chat-session.test.ts</code></a> test <code>ai#chat-history-excludes-blocked</code> (upstream JSDoc contract; exercised with a scripted blocked envelope)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">ChatSession.sendMessageStream()</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Exactly one user turn is recorded per <code>sendMessageStream</code> call; the mirror implements the 2.13.0 fixed semantics, not the installed 2.12.0 duplicate-user-turn bug</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/chat-session.test.ts#L93\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:chat-session.test.ts</code></a> test <code>ai#chat-stream-single-user-turn</code> (no capture; divergence pinned by ruling, see notes)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">Schema.enumString()</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>Schema.enumString</code> serializes the enum values with type <code>string</code> and format <code>enum</code></span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/ai/helpers-schema.test.ts#L153\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:helpers-schema.test.ts</code></a> test <code>ai#schema-string-enum</code> (upstream toJSON request shape; GoogleAI accepts only enum and date-time formats)</div></div></details>\n</div>\n\n## Reviewed public-runtime gaps\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Out of scope\" title=\"Out of scope\"></span><span class=\"compat-main\"><code class=\"compat-api\">ai.imagen-retirement</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Imagen is deprecated upstream; all Imagen models shut down as early as June 2026 (upstream 2.11.0 deprecation). Mirroring an API whose production counterpart is retiring would freeze dead behavior.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>ImagenAspectRatio</code> <code>ImagenImageFormat</code> <code>ImagenModel</code> <code>ImagenPersonFilterLevel</code> <code>ImagenSafetyFilterLevel</code> <code>getImagenModel</code></div>\n<div class=\"compat-note\">upstream:firebase/ai@2.11.0</div></div>\n</details>\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">ai.hybrid-inference</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Hybrid/on-device inference is deferred, not out of scope — the sandbox runs in the browser and can model the on-device path through the answer-engine seam; the mode has not been wired yet.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>InferenceMode</code></div>\n<div class=\"compat-note\">upstream:firebase/ai</div></div>\n</details>\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">ai.inference-source</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Deferred with hybrid/on-device inference — the source marker is meaningful once the hybrid mode lands.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>InferenceSource</code></div>\n<div class=\"compat-note\">disposition:ai.hybrid-inference</div></div>\n</details>\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">ai.live-api</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Live API is deferred, not out of scope — it is a bidirectional websocket protocol the sandbox can model with a scripted session engine, the same seam pattern the REST plane already uses; the work has not happened yet.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>LiveGenerativeModel</code> <code>LiveResponseType</code> <code>LiveSession</code> <code>getLiveGenerativeModel</code></div>\n<div class=\"compat-note\">upstream:firebase/ai</div></div>\n</details>\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">ai.server-templates</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Server-side templates are deferred, not out of scope — hosting template state is exactly the kind of server infrastructure the sandbox exists to model; the template store has not been built yet.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>TemplateGenerativeModel</code> <code>getTemplateGenerativeModel</code></div>\n<div class=\"compat-note\">upstream:firebase/ai</div></div>\n</details>\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Out of scope\" title=\"Out of scope\"></span><span class=\"compat-main\"><code class=\"compat-api\">ai.template-imagen-retirement</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Template-served Imagen models retire with the rest of Imagen (deprecated upstream, June 2026 shutdown); the template transport does not outlive the model family it serves.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>TemplateImagenModel</code> <code>getTemplateImagenModel</code></div>\n<div class=\"compat-note\">upstream:firebase/ai@2.11.0</div></div>\n</details>\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">ai.live-audio</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Deferred with the rest of the Live API family — the browser audio helper rides the Live session seam once that lands.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>startAudioConversation</code></div>\n<div class=\"compat-note\">disposition:ai.live-api</div></div>\n</details>\n</div>\n" }, { "slug": "auth-compat", "label": "Auth", "title": "pyric/auth compatibility matrix", "markdown": "<!-- Generated from the conformance model (registry rows + surface contracts). Do not edit by hand; run bun run compat:generate. -->\n\n# `pyric/auth` compatibility matrix\n\n<div class=\"compat-stat\">\n<p class=\"compat-stat-figure\">\n<span class=\"compat-stat-pct\">63.8%</span>\n<span class=\"compat-stat-label\">of the public API supported</span>\n</p>\n<div class=\"compat-meters\">\n<span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 63.8%\"></span></span>\n</div>\n<p class=\"compat-stat-denom\">95 of 149 public API</p>\n</div>\n\n\n## Status legend\n\n| Status | Meaning |\n|---|---|\n| ✓ | **Conforming** — sandbox matches prod, locked by a passing probe |\n| ⚠ | **Diverged (documented)** — intentional difference with a written reason |\n| — | **Not implemented yet** — deliberately or pending |\n| ? | **Unverified** — claim from docs that we haven't yet observed prod-side |\n\n## `getAuth(target)` — initializer\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getAuth(target) | | Returns a stable `Auth` handle for repeated calls with the same input | ✓ | [`unit:sandbox-anonymous.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-anonymous.test.ts#L13) + canonical Node register child ([`register-child.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/register/register-child.test.ts#L152)) | 1 |\n| getAuth(target) | | `getAuth(sandbox)` dispatches to the sandbox backend | ✓ | [`unit:sandbox-anonymous.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-anonymous.test.ts#L13) | 2 |\n| getAuth(target) | | Without sandbox package swapping, canonical `firebase/auth` imports remain Firebase and never enter this mirror | ? | Direct mirror rejection is locked by `unit:package-resolution.test.ts`; an unswapped production-resolution observation is still needed | 3 |\n| getAuth(target) | | After package swapping, bare `getAuth()` resolves the registered default sandbox app; without swapping, Firebase retains its `app/no-app` behavior when no default app exists | ✓ (wrap) | canonical Node register child ([`register-child.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/register/register-child.test.ts#L152)) + oracle: [`packages/conformance/observations/auth/auth-bare-getauth-no-default-app.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-bare-getauth-no-default-app.json) (`code: 'app/no-app'` against blockingfun, fb-js-sdk 12.13.0 — confirms unswapped Firebase behavior) | 4 |\n| getAuth(target) | | `auth.currentUser` is a live getter, not a snapshot — reads through to the backend on every access | ✓ | implicit in [`unit:sandbox-anonymous.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-anonymous.test.ts#L13) | 5 |\n\n## Multi-app Auth session topology\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getAuth(app) | | Equal-config Firebase apps have independent active Auth sessions; signing in or out on one app does not change another app's currentUser | ✓ | oracle: [`app-production-multi-app-topology`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-production-multi-app-topology.json) (firebase 12.13.0, real Chromium against production) + twin replay: [`packages/pyric/test/app/production-multi-app-oracle.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/app/production-multi-app-oracle.test.ts#L15) | 183 |\n| browserLocalPersistence across same-named app instances in sibling tabs | | After the sibling receives the LOCAL persistence event, same-named Firebase app instances in sibling tabs keep independent active Auth sessions; an anonymous sign-in in one tab does not update the sibling tab's currentUser during the bounded post-signal observation window | ✓ | oracle: [`app-production-cross-tab-auth-persistence`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/app/app-production-cross-tab-auth-persistence.json) (firebase 12.13.0, two real Chromium pages in one browser context) + served twin: [`packages/cli/test/e2e/app-multi-app.pw.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/e2e/app-multi-app.pw.ts#L12) | 184 |\n\n## `signInAnonymously(auth)` — anonymous\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| signInAnonymously(auth) | | Returns `UserCredential` with `providerId: null`, `operationType: 'signIn'`, and a `User` with `isAnonymous: true`, `email: null`, `displayName: null` | ✓ | [`unit:sandbox-anonymous.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-anonymous.test.ts#L30), `playground:auth-anonymous` (bundled) + `playground:auth-row-6-anon-credential-shape` (one-claim) + oracle: [`packages/conformance/observations/auth/auth-anonymous-credential-providerid.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-anonymous-credential-providerid.json) (`providerId: null` against blockingfun, fb-js-sdk 12.13.0). Prior matrix language said `providerId: 'anonymous'`; corrected after empirical observation. Sandbox aligned to prod in the same commit. | 6 |\n| signInAnonymously(auth) | | Auto-generates a uid for fresh sign-ins (sandbox format: `anonymous-{N}`) | ⚠ format | [`unit:sandbox-anonymous.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-anonymous.test.ts#L7) — prod uids are 28-char base64-ish; sandbox uses a readable counter for debuggability | 7 |\n| signInAnonymously(auth) | | If an anonymous user is already signed in, returns the SAME user (no fresh uid mint) | ✓ | [`unit:sandbox-anonymous.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-anonymous.test.ts#L7) (\"idempotent while signed in\") — fix from #399 | 8 |\n| signInAnonymously(auth) | | After `signOut`, a subsequent `signInAnonymously` mints a fresh uid | ✓ | [`unit:sandbox-anonymous.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-anonymous.test.ts#L7), `playground:auth-anonymous` (bundled) + `playground:auth-row-9-anon-fresh-uid-after-signout` (one-claim) | 9 |\n| signInAnonymously(auth) | | Fires `onAuthStateChanged` exactly once per state transition (no same-value double-fire) | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L24), `playground:auth-anonymous` (bundled) + [`playground:auth-row-10-onauthstatechanged-one-per-transition`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-10-onauthstatechanged-one-per-transition.json) (one-claim) — fix from #399 + oracle: `packages/conformance/observations/auth/auth-row-10-onauthstatechanged-one-per-transition.json` (against blockingfun, fb-js-sdk 12.13.0: subscribe → 1 initial null fire → `signInAnonymously` → +1 → `signOut` → +1 → `signInAnonymously` → +1. `eachTransitionFiredExactlyOnce: true` — every transition produces exactly one fire) | 10 |\n| signInAnonymously(auth) | | Writes through to `sandbox.currentUser` so rules engines see `request.auth.uid` | ✓ | [`unit:sandbox-anonymous.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-anonymous.test.ts#L7), `playground:auth-anonymous` (bundled) + `playground:auth-row-11-anon-uid-visible-to-rules` (one-claim) | 11 |\n| signInAnonymously(auth) | | Anonymous users persist across page reload via configured `Persistence` (prod only — sandbox has no persistence layer) | ⚠ | divergence: sandbox memory only; within one tab the user persists, across reload they don't | 12 |\n\n## `signInWithEmailAndPassword(auth, email, password)` — password\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| signInWithEmailAndPassword(auth, email, password) | | Returns `UserCredential` with `providerId: null` (NOT `'password'` — only OAuth/phone responses carry a providerId; upstream `providerIdForResponse` returns null for email/password), `operationType: 'signIn'`, and a `User` with the stored uid + email | ✓ | [`unit:sandbox-email-password.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-password.test.ts#L29) + oracle: [`packages/conformance/observations/auth/auth-createUser-operationType.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-createUser-operationType.json) (`providerId: null` against blockingfun, fb-js-sdk 12.13.0). Prior matrix language said `providerId: 'password'`; corrected after the oracle contradicted it (AUTH-B2). | 13 |\n| signInWithEmailAndPassword(auth, email, password) | | Throws `auth/user-not-found` when the email isn't seeded / hasn't been created | ✓ | [`unit:sandbox-email-password.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-password.test.ts#L4) + oracle: [`packages/conformance/observations/auth/auth-user-not-found-error-code.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-user-not-found-error-code.json) (`code: 'auth/user-not-found'` against blockingfun, fb-js-sdk 12.13.0; matches sandbox) | 14 |\n| signInWithEmailAndPassword(auth, email, password) | | Throws `auth/wrong-password` when the password doesn't match | ✓ | [`unit:sandbox-email-password.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-password.test.ts#L4), `playground:auth-email-password` (bundled) + `playground:auth-row-15-wrong-password-rejects` (one-claim) + oracle: [`packages/conformance/observations/auth/auth-wrong-password-error-code.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-wrong-password-error-code.json) (`code: 'auth/wrong-password'` against blockingfun, fb-js-sdk 12.13.0; matches sandbox) | 15 |\n| signInWithEmailAndPassword(auth, email, password) | | An EMPTY password throws `auth/missing-password` (message \"A non-empty password must be provided\"), fired before the user-DB lookup so it can't be used to enumerate seeded emails. Upstream maps the `MISSING_PASSWORD` server error (`core/errors.ts:92,282,563`). ⚠ best-known semantics — message text not yet captured against a live project (STOP-flagged for an oracle pass; the `.code` is the load-bearing part). | ⚠ | [`unit:sandbox-cluster-b9-b12.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-cluster-b9-b12.test.ts#L18) (locks AUTH-B11) | 15a |\n| signInWithEmailAndPassword(auth, email, password) | | Re-signing in after `signOut` returns the **same** uid (passwords persist within the sandbox lifetime) | ✓ | `playground:auth-email-password` (bundled) + `playground:auth-row-16-resignin-same-uid` (one-claim) | 16 |\n| signInWithEmailAndPassword(auth, email, password) | | Fires `onAuthStateChanged` with the new user once | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L25) + oracle: [`packages/conformance/observations/auth/auth-row-17-signin-email-password-fires-once.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-17-signin-email-password-fires-once.json) (against blockingfun, fb-js-sdk 12.13.0: createUser → signOut → subscribe (1 initial null fire) → `signInWithEmailAndPassword` → `firesForSignIn: 1` with the signed-in uid, `lastFireUidMatches: true`) | 17 |\n| signInWithEmailAndPassword(auth, email, password) | | Email validation (RFC 5322ish) — rejects empty, missing `@`, missing local-part, missing domain with `auth/invalid-email`. Runs on both `signInWithEmailAndPassword` and `createUserWithEmailAndPassword` before any user-DB lookup, so consumers shipping malformed input see the same error sandbox vs prod. | ✓ | [`unit:sandbox-email-password.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-password.test.ts#L151) + oracle: [`packages/conformance/observations/auth/auth-row-18-invalid-email-error-code.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-18-invalid-email-error-code.json) (`code: 'auth/invalid-email'`, message `Firebase: Error (auth/invalid-email).` against blockingfun, fb-js-sdk 12.13.0) | 18 |\n| signInWithEmailAndPassword(auth, email, password) | | Password strength requirements — rejects passwords shorter than 6 chars with `auth/weak-password` on `createUserWithEmailAndPassword`. Strength is NOT enforced on `signInWithEmailAndPassword` so previously-seeded weak passwords still let the user in (matches prod's separation of registration vs sign-in). | ✓ | [`unit:sandbox-email-password.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-password.test.ts#L225) + oracle: [`packages/conformance/observations/auth/auth-row-19-weak-password-error-code.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-19-weak-password-error-code.json) (`code: 'auth/weak-password'`, message `Firebase: Password should be at least 6 characters (auth/weak-password).` against blockingfun, fb-js-sdk 12.13.0; matrix language \"≥6 chars per prod default\" empirically confirmed) | 19 |\n| signInWithEmailAndPassword(auth, email, password) | | Disabled accounts (`sandbox.updateUser(…, {disabled: true})`) reject sign-in with `auth/user-disabled` and prod's documented message (`The user account has been disabled by an administrator.`). Sandbox checks disabled BEFORE the password compare (anti-probing); the exact prod ordering of disabled-vs-wrong-password needs an oracle capture | ✓ code / ? ordering | [`unit:sandbox-user-admin.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-user-admin.test.ts#L19) (\"disabled users\") | 69 |\n\n## `createUserWithEmailAndPassword(auth, email, password)` — register\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| createUserWithEmailAndPassword(auth, email, password) | | Creates a new user, signs them in automatically (currentUser becomes the new user) | ✓ | `playground:auth-email-password` (bundled) + `playground:auth-row-20-create-user-auto-signs-in` (one-claim), [`unit:sandbox-email-password.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-password.test.ts#L6) | 20 |\n| createUserWithEmailAndPassword(auth, email, password) | | Returns `UserCredential` with `operationType: 'signIn'` (NOT `'register'` — matches prod) | ✓ | [`unit:sandbox-email-password.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-password.test.ts#L29) + oracle: [`packages/conformance/observations/auth/auth-createUser-operationType.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-createUser-operationType.json) (`operationType: 'signIn'` against blockingfun, fb-js-sdk 12.13.0; matches sandbox) (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L145)) | 21 |\n| createUserWithEmailAndPassword(auth, email, password) | | Throws `auth/email-already-in-use` when the email is already registered | ✓ | [`unit:sandbox-email-password.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-password.test.ts#L6) + oracle: [`packages/conformance/observations/auth/auth-email-already-in-use-error-code.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-email-already-in-use-error-code.json) (`code: 'auth/email-already-in-use'` against blockingfun, fb-js-sdk 12.13.0; matches sandbox) | 22 |\n| createUserWithEmailAndPassword(auth, email, password) | | The created user has `isAnonymous: false`, `email: <input>`, `displayName: null` | ✓ | [`unit:sandbox-email-password.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-password.test.ts#L6) + `playground:auth-row-23-create-user-shape` (one-claim) | 23 |\n| createUserWithEmailAndPassword(auth, email, password) | | Fires `onAuthStateChanged` with the new user once | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L23) + oracle: [`packages/conformance/observations/auth/auth-row-24-createuser-fires-once.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-24-createuser-fires-once.json) (against blockingfun, fb-js-sdk 12.13.0: subscribe (1 initial null fire) → `createUserWithEmailAndPassword` → `firesForCreate: 1` with the newly-created uid, `lastFireUidMatches: true`) | 24 |\n\n## `signOut(auth)`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| signOut(auth) | | Sets `currentUser` to `null` synchronously after resolution | ✓ | `playground:auth-anonymous` (bundled) + `playground:auth-row-25-signout-currentuser-null` (one-claim), [`unit:sandbox-anonymous.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-anonymous.test.ts#L7) + oracle: [`packages/conformance/observations/auth/auth-row-25-signout-currentuser-null-sync.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-25-signout-currentuser-null-sync.json) (`currentUserIsNullSync: true` against blockingfun, fb-js-sdk 12.13.0 — `auth.currentUser` read in the synchronous continuation immediately after `await signOut(auth)` is already `null`, with no microtask/macrotask required to settle) | 25 |\n| signOut(auth) | | Fires `onAuthStateChanged` with `null` exactly once | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L26), `playground:auth-anonymous` (bundled) + [`playground:auth-row-26-signout-fires-null-once`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-26-signout-fires-null-once.json) (one-claim) + oracle: `packages/conformance/observations/auth/auth-row-26-signout-fires-null-once.json` (against blockingfun, fb-js-sdk 12.13.0: subscribe (1 initial null fire) → `signInAnonymously` → `signOut` → `firesForSignOut: 1` with `lastFireUidWasNull: true`) | 26 |\n| signOut(auth) | | Idempotent — `signOut` on already-signed-out user is a no-op (no listener fire) | ✓ | [`playground:auth-signout-idempotent`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-signout-idempotent.json) + oracle-confirmed: `packages/conformance/observations/auth/auth-signout-idempotent.json` (`threw: false, redundantSignOutFiredListener: false` against blockingfun) | 27 |\n| signOut(auth) | | Clears the active session's persistence in prod; sandbox has no persistence | ⚠ | divergence: same memory-only constraint as the anonymous persistence row | 28 |\n\n## `onAuthStateChanged(auth, observer)`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| onAuthStateChanged(auth, observer) | | Fires immediately on subscribe with the current value (microtask-deferred) | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L5) + oracle: [`packages/conformance/observations/auth/auth-row-29-onauthstatechanged-initial-fire-timing.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-29-onauthstatechanged-initial-fire-timing.json) (`firedSynchronously: 0, firedAfterMicrotask: 1` against blockingfun, fb-js-sdk 12.13.0 — initial fire does NOT arrive in the synchronous tick of `onAuthStateChanged(...)`; it lands after the first microtask flush) | 29 |\n| onAuthStateChanged(auth, observer) | | Fires on every subsequent identity change | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L5), `playground:auth-anonymous` + oracle: [`packages/conformance/observations/auth/auth-row-30-onauthstatechanged-fires-on-every-transition.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-30-onauthstatechanged-fires-on-every-transition.json) (against blockingfun, fb-js-sdk 12.13.0: subscribe → `signIn` → `signOut` → `signIn` → `signOut`, each of the 4 transitions produced exactly 1 fire; `eachTransitionFiredExactlyOnce: true`) | 30 |\n| onAuthStateChanged(auth, observer) | | Dedups by uid: a same-uid update (profile-shape change, or a same-uid re-sign-in) does NOT re-fire `onAuthStateChanged` — only an actual uid change does. Mirrors upstream `notifyAuthListeners`'s `lastNotifiedUid` gate (`auth_impl.ts:718-723`). (`onIdTokenChanged` still fires on those same-uid updates — see row 38a.) | ✓ | [`unit:sandbox-uid-dedup.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-uid-dedup.test.ts#L7) (locks AUTH-B7 / B8) | 30a |\n| onAuthStateChanged(auth, observer) | | **No duplicate fire** when subscribe is followed by a synchronous `setCurrentUser` — dedup ensures observer sees the new value once, not twice. Sandbox-only concern: prod has no synchronous state-change API, so the dedup window can't be exercised against the cloud SDK; subscribe-then-async-signIn naturally fires twice (initial + new value) because the microtask between them flushes the initial fire | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L5) (regression test from #399), `playground:auth-anonymous` + oracle baseline: [`packages/conformance/observations/auth/auth-row-31-onauthstatechanged-no-dup-on-sync-transition.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-31-onauthstatechanged-no-dup-on-sync-transition.json) (against blockingfun, fb-js-sdk 12.13.0: subscribe + async `signInAnonymously` in the same tick produces `totalFires: 2` — `{uid: null, ts: 0}` then `{uid: <user>, ts: ~400ms}`. Confirms prod cannot exhibit the same-tick race; the dedup behavior remains a sandbox-only property) | 31 |\n| onAuthStateChanged(auth, observer) | | Returned `Unsubscribe` removes the observer; subsequent state changes do NOT fire it | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L5), `playground:auth-listener-unsub` (bundled) + [`playground:auth-row-32-unsubscribe-stops-fires`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-32-unsubscribe-stops-fires.json) (one-claim) + oracle: `packages/conformance/observations/auth/auth-row-32-unsubscribe-stops-fires.json` (against blockingfun, fb-js-sdk 12.13.0: subscribe → 1 fire on `signInAnonymously` → call `unsub()` → `signOut` + `signInAnonymously` + `signOut` produce zero further fires; `postUnsubFires: 0, unsubscribeStoppedFires: true`) | 32 |\n| onAuthStateChanged(auth, observer) | | Multiple subscribers all fire on each change | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L5) + oracle: [`packages/conformance/observations/auth/auth-row-33-multiple-subscribers-all-fire.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-33-multiple-subscribers-all-fire.json) (against blockingfun, fb-js-sdk 12.13.0: two subscribers registered back-to-back each see 1 initial null fire, +1 on `signInAnonymously`, +1 on `signOut`; `bothFiredOnSignIn: true, bothFiredOnSignOut: true`) | 33 |\n| onAuthStateChanged(auth, observer) | | Registry is array-backed (matches upstream `util/subscribe.ts`): the SAME observer fn registered N times produces N independent registrations that each fire, and one `Unsubscribe` removes exactly one registration. A resubscribe of a previously-unsubscribed fn fires its initial value again. (Per-registration initial-fire bookkeeping, not a shared per-observer dedup.) | ✓ | [`unit:sandbox-listener-registry.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listener-registry.test.ts#L19) (locks AUTH-B3 + AUTH-B4) | 33a |\n| onAuthStateChanged(auth, observer) | | Unsubscribing during emission does not skip remaining subscribers (snapshotted iteration) | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L5) | 34 |\n| onAuthStateChanged(auth, observer) | | A throwing observer doesn't block other observers from firing | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L5) + oracle: [`packages/conformance/observations/auth/auth-row-35-throwing-observer-doesnt-block-others.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-35-throwing-observer-doesnt-block-others.json) (`secondObserverContinuedFiring: true` against blockingfun, fb-js-sdk 12.13.0 — observer #1 throws on every call, observer #2 still counts the initial fire AND the post-sign-in fire) | 35 |\n| onAuthStateChanged(auth, observer) | | Observer object form (`{next, error, complete}`) works alongside the function form | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L5) + oracle: [`packages/conformance/observations/auth/auth-row-36-observer-object-form-works.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-36-observer-object-form-works.json) (against blockingfun, fb-js-sdk 12.13.0: one observer as `NextFn`, another as `{next, error, complete}`. Both forms see 1 initial null fire, +1 on `signInAnonymously`, +1 on `signOut`; `bothFormsFiredOnSignIn: true, bothFormsFiredOnSignOut: true`) | 36 |\n| onAuthStateChanged(auth, observer) | | Setting the same user twice does NOT double-fire (structural-equality no-op). Sandbox-internal `setCurrentUser` claim; the prod analog is `signInAnonymously` called twice in a row (per fix #399, the second call returns the same user). | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L5) + oracle: [`packages/conformance/observations/auth/auth-row-37-same-user-no-double-fire.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-37-same-user-no-double-fire.json) (against blockingfun, fb-js-sdk 12.13.0: first `signInAnonymously` fires the listener once; second call returns the same uid (`sameUserAcrossCalls: true`) and does NOT produce a fresh fire (`secondSignInProducedFire: false`). Prod also recognizes the same-user no-op) | 37 |\n\n## `onIdTokenChanged(auth, observer)`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| onIdTokenChanged(auth, observer) | | Fires on user change (sandbox shares the auth-state path) | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L12) + oracle: [`packages/conformance/observations/auth/auth-row-38-onidtokenchanged-fires-on-user-change.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-38-onidtokenchanged-fires-on-user-change.json) (against blockingfun, fb-js-sdk 12.13.0: subscribe → 1 initial fire (null) → `signInAnonymously` → +1 (user₁) → `signOut` → +1 (null) → `signInAnonymously` → +1 (user₂ with fresh uid). Every identity transition produces exactly one fire, matching `onAuthStateChanged`) | 38 |\n| onIdTokenChanged(auth, observer) | | Fires on EVERY sign-in, including a same-uid re-sign-in (no `signOut` first) — which mints a fresh token, so a subsequent `getIdToken()` returns a new string. Mirrors upstream `notifyAuthListeners`, which calls `idTokenSubscription.next` on every identity update (`auth_impl.ts:716`). `onAuthStateChanged` stays silent on the same-uid case (row 30a). | ✓ | [`unit:sandbox-uid-dedup.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-uid-dedup.test.ts#L5) (locks AUTH-B8) | 38a |\n| onIdTokenChanged(auth, observer) | | Fires on token refresh (`getIdToken(true)`) | ✓ | [`unit:sandbox-token-refresh.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-token-refresh.test.ts#L12) — was ⚠ (documented divergence); aligned to prod in commit — sandbox now mints a fresh token on forceRefresh and fires `onIdTokenChanged` (NOT `onAuthStateChanged`, since identity is unchanged). Oracle: [`packages/conformance/observations/auth/auth-onidtokenchanged-force-refresh.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-onidtokenchanged-force-refresh.json) defines the target shape (`refreshFiredListener: true` against blockingfun; subscribe → null fire → `signInAnonymously` → +1 → `getIdToken(true)` → +1 for a total of 3 fires). | 39 |\n| onIdTokenChanged(auth, observer) | | Initial-fire semantics match `onAuthStateChanged` | ✓ | [`unit:sandbox-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-listeners.test.ts#L12) + oracle: [`packages/conformance/observations/auth/auth-row-40-onidtokenchanged-matches-onauthstatechanged-initial-fire.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-row-40-onidtokenchanged-matches-onauthstatechanged-initial-fire.json) (against blockingfun, fb-js-sdk 12.13.0: subscribing both listeners in the same tick yields `sync: {auth: 0, idToken: 0}` → `microtask: {auth: 1, idToken: 1}` → no further fires. Both listeners share the microtask-deferred initial-fire timing) | 40 |\n\n## `setPersistence(auth, persistence)`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| setPersistence(auth, persistence) | | Accepts `inMemoryPersistence` / `browserSessionPersistence` / `browserLocalPersistence` markers without throwing | ✓ | [`unit:types.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/types.test.ts#L2) | 41 |\n| setPersistence(auth, persistence) | | Returns `Promise<void>` | ✓ | [`unit:types.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/types.test.ts#L2) | 42 |\n| setPersistence(auth, persistence) | | Actually changes where the auth state is persisted | ⚠ | divergence: sandbox is a no-op. Prod respects the marker. | 43 |\n| setPersistence(auth, persistence) | | An unrecognized persistence marker is rejected with `auth/argument-error` rather than silently coerced to LOCAL | ✓ | [`unit:sandbox-cluster-b9-b12.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-cluster-b9-b12.test.ts#L17) (locks AUTH-B12) | 43a |\n\n## `signInWithPopup(auth, provider)` / `signInWithCredential(auth, credential)`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| signInWithPopup(auth, provider)` / `signInWithCredential(auth, credential) | | Returns the pre-staged `UserCredential` registered via `sandbox.mockSignInResult(auth, …)` | ✓ | [`unit:sandbox-providers.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-providers.test.ts#L2) | 44 |\n| signInWithPopup(auth, provider)` / `signInWithCredential(auth, credential) | | Throws `auth/no-mock-configured` when no mock is pre-staged | ✓ | [`unit:sandbox-providers.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-providers.test.ts#L2) | 45 |\n| signInWithPopup(auth, provider)` / `signInWithCredential(auth, credential) | | Mock is consumed after one read (subsequent call without a fresh stage throws again) | ✓ | [`unit:sandbox-providers.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-providers.test.ts#L2) | 46 |\n| signInWithPopup(auth, provider)` / `signInWithCredential(auth, credential) | | After successful sign-in, `currentUser` becomes the mock's `user`, listeners fire | ✓ | [`unit:sandbox-providers.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-providers.test.ts#L2) | 47 |\n| signInWithPopup(auth, provider)` / `signInWithCredential(auth, credential) | | The credential's rich `User` (email / displayName / isAnonymous) survives the transition — popup/redirect/credential/`setUser` do NOT clobber it down to the bare `AuthState`; `cred.user === auth.currentUser` (reference identity, matches upstream `_updateCurrentUser(userCredential.user)`) | ✓ | [`unit:sandbox-user-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-user-identity.test.ts#L21) (locks AUTH-B1) | 47a |\n| signInWithPopup(auth, provider)` / `signInWithCredential(auth, credential) | | Opens a popup window in prod | ⚠ | divergence: sandbox skips the popup; mock pre-stage replaces the popup result | 48 |\n| signInWithPopup(auth, provider)` / `signInWithCredential(auth, credential) | | Cancels with `auth/popup-closed-by-user` when the user dismisses the popup (prod) | — | not modeled — would require the host to expose a \"cancel\" affordance on the mock | 49 |\n| signInWithPopup(auth, provider)` / `signInWithCredential(auth, credential) | | Provider-flow sign-ins (popup / redirect / credential) record the flow's `providerId` on the identity in the user DB (upsert for unknown uids; append-if-missing for known ones) and reject disabled accounts with `auth/user-disabled` before any state change | ✓ | [`unit:sandbox-user-admin.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-user-admin.test.ts#L20) (\"provider tracking\", \"disabled users\") — provider recording is sandbox bookkeeping for `listIdentities`/`listUsers`; prod's auto-link semantics are narrower (same-email Google auto-link only) and not modeled | 70 |\n\n## `signInWithRedirect` / `getRedirectResult` / resolver seam\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| signInWithRedirect` / `getRedirectResult` / resolver seam | | `signInWithRedirect(auth, provider, resolver?)` resolves the flow (per-call resolver → injected → one-shot mock → `auth/argument-error`), signs the user in, and stashes the credential for one `getRedirectResult` | ✓ | [`unit:sandbox-resolver.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-resolver.test.ts#L5) | 49a |\n| signInWithRedirect` / `getRedirectResult` / resolver seam | | `getRedirectResult(auth)` returns the stashed credential once, then `null` (one-shot, matches prod) | ✓ | [`unit:sandbox-resolver.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-resolver.test.ts#L5) | 49b |\n| signInWithRedirect` / `getRedirectResult` / resolver seam | | `sandbox.setAuthFlowResolver(auth, resolver \\| null)` installs / clears the popup/redirect resolver (the analog of browser `getAuth` wiring `browserPopupRedirectResolver`) | ✓ | [`unit:sandbox-resolver.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-resolver.test.ts#L5) | 49c |\n| signInWithRedirect` / `getRedirectResult` / resolver seam | | `sandbox.listIdentities(auth)` snapshots every seeded/created identity for a host account-picker (sandbox-only — no `firebase/auth` equivalent) | ✓ | [`unit:sandbox-resolver.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-resolver.test.ts#L5) | 49d |\n\n## `Auth` surface + error shape\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Auth` surface + error shape | | `auth.signOut()` method form works alongside the free `signOut(auth)` function (`firebase/auth`'s `Auth` exposes both) (AUTH-GAP) | ✓ | [`unit:auth-gap-surface.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/auth-gap-surface.test.ts#L2) | 49e |\n| Auth` surface + error shape | | Sandbox auth errors are real `FirebaseError` instances (`err instanceof FirebaseError`) carrying the prod message wrapper `Firebase: <message> (<auth/...>).` — e.g. `Firebase: Error (auth/invalid-email).`, matching the oracle (AUTH-GAP) | ✓ | [`unit:auth-gap-surface.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/auth-gap-surface.test.ts#L2) | 49f |\n\n## Provider classes (`GoogleAuthProvider`, `EmailAuthProvider`, etc.)\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | Exports the same constructor signatures as `firebase/auth` for each provider | ✓ | type-only smoke in [`unit:types.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/types.test.ts#L8) | 50 |\n| | | `Provider.credential(...)` static factories produce `AuthCredential`-shaped objects | ✓ | [`unit:sandbox-providers.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-providers.test.ts#L14) | 51 |\n| | | `GoogleAuthProvider.providerId === 'google.com'` (and per-provider analogs) | ✓ | [`unit:sandbox-providers.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-providers.test.ts#L18) | 52 |\n| | | Custom scopes / params / language code | — | sandbox ignores; prod forwards | 53 |\n\n## `User` methods\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| User` methods | | `user.getIdToken()` returns a stable opaque token in sandbox (`sandbox-id-token-…`) | ✓ | [`unit:sandbox-anonymous.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-anonymous.test.ts#L4) | 54 |\n| User` methods | | `user.getIdToken(true)` (forceRefresh) returns a NEW token; subsequent `getIdToken(false)` returns the cached new token | ✓ | [`unit:sandbox-token-refresh.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-token-refresh.test.ts#L8) — was ⚠ (documented divergence); aligned to prod in commit — sandbox now mints a fresh token on forceRefresh and fires `onIdTokenChanged`. Oracle: [`packages/conformance/observations/auth/auth-getidtoken-force-refresh.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-getidtoken-force-refresh.json) defines the target shape (`forceRefreshReturnedDifferentString: true`, `token1EqualsToken2: true` against blockingfun — the refreshed token is cached, so a subsequent non-forced read returns it, not yet another fresh one). Sandbox tokens stay `sandbox-id-token-<uid>-<serial>:<json>` strings; prod's are real JWTs. | 55 |\n| User` methods | | `user.getIdTokenResult()` returns claims | ✓ | [`unit:sandbox-providers.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-providers.test.ts#L24) (custom-claims path) | 56 |\n| User` methods | | `user.uid`, `user.email`, `user.displayName`, `user.isAnonymous` reflect the source | ✓ | `playground:auth-anonymous`, `playground:auth-email-password` | 57 |\n| User` methods | | `user.emailVerified` — present on every sandbox-minted `User` (default `false`; sandbox has no verification flow). Prod passes the real value through (no longer stripped). The admin record (`sandbox.listUsers`) carries it too | ✓ | [`unit:auth-gap-surface.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/auth-gap-surface.test.ts#L2) (locks AUTH-GAP) | 58 |\n| User` methods | | `user.photoURL` / `user.phoneNumber` — present (sandbox default `null`; prod passes through, no longer stripped) | ✓ | [`unit:auth-gap-surface.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/auth-gap-surface.test.ts#L2) | 58a |\n| User` methods | | `user.providerId` (aggregate, `'firebase'`) + `user.providerData: UserInfo[]` — sandbox synthesizes one provider entry for non-anonymous users, empty for anonymous; prod passes the real array through (no longer stripped). The admin record carries the emulator-shaped `providerUserInfo` | ✓ | [`unit:auth-gap-surface.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/auth-gap-surface.test.ts#L2) | 58b |\n| User` methods | | `user.metadata.creationTime` / `lastSignInTime` | — | client `User.metadata` not exposed (AUTH-GAP); the admin record carries `createdAt`/`lastLoginAt` (ISO) | 59 |\n| User` methods | | `IdTokenResult.signInProvider` reflects the session's provider per flow (`'anonymous'` / `'password'` / `'google.com'` / …); claims include the reserved `firebase.sign_in_provider` (custom claims can't shadow it) | ✓ | [`unit:sandbox-user-admin.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-user-admin.test.ts#L9) (\"IdTokenResult.signInProvider\") — prod shape is documented SDK behavior; no oracle capture yet (Structured evidence: [`auth-signinprovider-per-flow`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-signinprovider-per-flow.json)) | 68 |\n| User` methods | | Custom-claims changes (`sandbox.updateUser` / re-seed) reach an active session on the next FORCED token refresh, not immediately — claims are read live from the user DB at mint time (prod's refresh-propagation story; AUTH-B10) | ✓ | [`unit:sandbox-user-admin.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-user-admin.test.ts#L9), [`unit:sandbox-cluster-b9-b12.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-cluster-b9-b12.test.ts#L24) (Structured evidence: [`auth-claims-forced-refresh-propagation`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-claims-forced-refresh-propagation.json)) | 75 |\n| User` methods | | `user.reload()` / `user.delete()` / `user.toJSON()` / `user.refreshToken` / `user.tenantId` | — | not modeled by the sandbox; retained as an explicit unsupported behavior row rather than synthesized (AUTH-GAP) | 61 |\n| updateProfile(user, {displayName, photoURL}) | | `updateProfile(user, {displayName, photoURL})` mutates the held user + stored record in place (null clears a field; omitted fields untouched) without an extra `onAuthStateChanged` fire. Profile survives `signOut` → `signInWithEmailAndPassword`. | ✓ | [`unit:sandbox-update-profile.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-update-profile.test.ts#L2) (in-place mutation, listUsers persist, null clear, no extra listener); re-sign-in persistence: [`unit:upstream-auth-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/upstream-auth-probes.test.ts#L8) | 62 |\n\n## `beforeAuthStateChanged(auth, callback, onAbort?)`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| beforeAuthStateChanged(auth, callback, onAbort?) | | Registers a BLOCKING gate that runs before a real sign-in/sign-out transition commits. Callbacks run in registration order; a callback that throws (or returns a rejected promise) aborts the transition entirely: the pending `signInWith…`/`signOut` call rejects with `auth/login-blocked`, `currentUser` is unchanged, and `onAuthStateChanged`/`onIdTokenChanged` do NOT fire. Covers every sign-in path that exists in `pyric/auth`: `signInAnonymously`, `signInWithEmailAndPassword`, `createUserWithEmailAndPassword`, `signInWithPopup`, `signInWithRedirect`, `signInWithCredential`, `signInWithCustomToken`, and `signOut`. Modeled after the real `@firebase/auth` `AuthMiddlewareQueue.runMiddleware` (`auth_impl.ts`), read directly from the installed SDK source, not just its `.d.ts`. | ✓ | [`unit:sandbox-before-auth-state-changed.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-before-auth-state-changed.test.ts#L2) — implementation cross-checked against `@firebase/auth`'s `AuthMiddlewareQueue` source (registration-order queue, per-callback try/await, `auth/login-blocked` wrap). Custom-token allow/block: [`unit:upstream-auth-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/upstream-auth-probes.test.ts#L9). No live-oracle capture (no observable client-visible signal to probe beyond the documented+sourced contract already read). | 76 |\n| beforeAuthStateChanged(auth, callback, onAbort?) | | `onAbort` semantics: when a callback throws, every `onAbort` registered by a callback that ALREADY SUCCEEDED in the same pass runs, in REVERSE registration order — matches upstream's rollback-stack (`runMiddleware`'s `onAbortStack`). An `onAbort` that itself throws is swallowed so it can't mask the original block reason or skip the remaining rollbacks. | ✓ | [`unit:sandbox-before-auth-state-changed.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-before-auth-state-changed.test.ts#L2) (\"onAbort runs (in reverse order)…\", \"a callback whose own onAbort throws…\") | 76a |\n| beforeAuthStateChanged(auth, callback, onAbort?) | | Fires on BOTH directions: a real sign-in (`nextUser` non-null) and a real sign-out (`nextUser === null`) — a throwing callback blocks `signOut` too, leaving the previous user signed in. Matches upstream, where the same middleware queue gates `_updateCurrentUser` and `signOut`. | ✓ | [`unit:sandbox-before-auth-state-changed.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-before-auth-state-changed.test.ts#L2) (\"fires on sign-out too…\") | 76b |\n| beforeAuthStateChanged(auth, callback, onAbort?) | | `sandbox.setUser` (the sandbox-only test driver) BYPASSES the gate entirely — it is a raw identity force with no prod analog (same bypass it already has for provider enforcement / `signInProvider` tracking), so no registered `beforeAuthStateChanged` callback runs and none can block it. | ⚠ | [`unit:sandbox-before-auth-state-changed.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-before-auth-state-changed.test.ts#L2) (\"sandbox.setUser test driver bypasses the gate\") | 76c |\n| beforeAuthStateChanged(auth, callback, onAbort?) | | Served-worker path (SharedWorker-backed auth in `@pyric/cli`): the worker owns the shared user pool and commits transitions on its own side of the port, so a page-local `beforeAuthStateChanged` registration can't actually gate a worker-driven sign-in. Rather than silently accept a callback that could never run, registering THROWS immediately (`auth/operation-not-supported-in-this-environment`) — same defensive pattern as `signInWithCredential` over the worker. | ⚠ | `packages/cli/src/serve/worker/client.ts` (`beforeAuthStateChanged` throws `makeUnsupported`) | 76d |\n\n## User-management and session exports\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| initializeAuth(app, deps?) | | Aliases `getAuth(app)` and returns the same stable `Auth` handle — an app that calls `initializeAuth` instead of `getAuth` gets an equivalent, working instance. The optional `Dependencies` arg (persistence / popupRedirectResolver) is accepted for signature parity but not applied (persistence is already a documented sandbox no-op). Repeated calls return the cached handle rather than throwing `auth/already-initialized` | ✓ | [`unit:fruit-aliases.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/fruit-aliases.test.ts#L13) — returns the same instance as `getAuth`, with a live `currentUser` | 82 |\n| deleteUser(user) | | Deletes the account from the user store AND signs the user out if they are current (fires `onAuthStateChanged(null)`), matching prod where deleting the signed-in user clears `auth.currentUser`. Real behavior: a subsequent `signInWithEmailAndPassword` for that identity throws `auth/user-not-found`; `reload(user)` after delete rejects with `auth/user-token-expired` | ✓ | [`unit:fruit-aliases.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/fruit-aliases.test.ts#L17) — user removed from the store, sign-out fired, re-sign-in throws `auth/user-not-found`; post-delete `reload` → `auth/user-token-expired`: [`unit:upstream-auth-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/upstream-auth-probes.test.ts#L7) | 83 |\n| updateEmail(user, newEmail) | | Changes the stored email (via the same path as `sandbox.updateUser`, rejecting `auth/email-already-in-use` / `auth/invalid-email`) and mutates the held `user` in place, so the next sign-in resolves against the new email. Leniency vs prod: the sandbox does NOT enforce `auth/requires-recent-login` and is not routed through `verifyBeforeUpdateEmail` (which the real SDK requires when email-enumeration protection is on) | ⚠ email really changes; no requires-recent-login / verifyBeforeUpdateEmail enforcement | [`unit:fruit-aliases.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/fruit-aliases.test.ts#L18) — stored email changes; re-sign-in works with the new email, fails with the old | 84 |\n| updatePassword(user, newPassword) | | Sets the stored password (validated for strength). The sandbox DOES store and verify passwords, so this is a real mutation: a subsequent `signInWithEmailAndPassword` with the new password succeeds and the old one throws `auth/wrong-password`. Leniency vs prod: no `auth/requires-recent-login` enforcement | ⚠ password really changes + is verified; no requires-recent-login enforcement | [`unit:fruit-aliases.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/fruit-aliases.test.ts#L19) — new password signs in, old password throws `auth/wrong-password` | 85 |\n| reload(user) | | Re-reads the stored record into the `user` object in place, so a change made out of band (e.g. `sandbox.updateUser`) is reflected on the held reference — matching prod's server refresh. If the identity is absent from the store (including after `deleteUser`), rejects with `auth/user-token-expired` | ✓ | [`unit:fruit-aliases.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/fruit-aliases.test.ts#L20) — an out-of-band `sandbox.updateUser` displayName change is visible on the held user after `reload`; missing-store / post-delete: [`unit:upstream-auth-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/upstream-auth-probes.test.ts#L7) | 86 |\n| updateCurrentUser(auth, user) | | Sets the sandbox's current user (pass `null` to sign out), firing `onAuthStateChanged` — `auth.currentUser` reflects the passed user afterward | ✓ | [`unit:fruit-aliases.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/fruit-aliases.test.ts#L21) — `auth.currentUser` becomes the passed user; `null` signs out | 87 |\n| useDeviceLanguage(auth) | | Accepted no-op — the sandbox has no device locale to read, so there is no language to set; accepted so init code that calls it compiles + runs | ⚠ no device locale in the sandbox | [`unit:fruit-aliases.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/fruit-aliases.test.ts#L22) — resolves/returns without error | 88 |\n\n## `ActionCodeURL` / email-link / action-code — the out-of-band family\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| ActionCodeURL.parseLink(link) / parseActionCodeURL(link) | | Parses an out-of-band action link. The `mode` query param maps to a NORMALIZED operation (`mode=resetPassword` -> `PASSWORD_RESET`, `mode=signIn` -> `EMAIL_SIGNIN`), `oobCode` surfaces as `code`, `lang` as `languageCode`, and `continueUrl` comes out URL-DECODED. A link missing `mode`, missing `oobCode`, carrying an unknown mode, or that is not a URL at all parses to `null` — the parse NEVER throws. `parseActionCodeURL` and `ActionCodeURL.parseLink` agree. | ✓ | ORACLE-BACKED and project-independent: this is a PURE client-side parse (no network, no project, no mailbox), so the sandbox owes prod an exact match and there is no room for a divergence. Oracle: [`auth-actioncodeurl-parse`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-actioncodeurl-parse.json) against firebase-js-sdk 12.13.0. Replayed in [`unit:oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L563). (Structured evidence: [`sandbox-email-link.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L15)) | 150 |\n| isSignInWithEmailLink(auth, link) | | Pure predicate over the link string — no network. `true` only for a link whose mode is `signIn` AND which carries an `oobCode`; `false` for a password-reset link, for `mode=signIn` with no code, for garbage, and for the empty string. Never throws. | ✓ | ORACLE-BACKED, project-independent (no server involved): [`auth-issigninwithemaillink-predicate`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-issigninwithemaillink-predicate.json) captured all five cases against prod. Replayed in [`unit:oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L599). (Structured evidence: [`sandbox-email-link.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L22)) | 151 |\n| applyActionCode(auth, code) | | Redeems an out-of-band code and performs its state change (`VERIFY_EMAIL` sets `emailVerified`, `VERIFY_AND_CHANGE_EMAIL` moves the account to the new address). Throws `auth/invalid-action-code` for a code the project never issued, for the empty string, and for a code already redeemed (single-use). A `PASSWORD_RESET` code is refused — `confirmPasswordReset` owns that one. | ✓ | ORACLE-BACKED on the reject path: [`auth-action-code-invalid`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-action-code-invalid.json) confirmed prod emits `auth/invalid-action-code` for both a bogus code and the empty string (this endpoint is NOT gated on the password provider, so it answered honestly). The APPLY path (a real code, redeemed) cannot be probed from a client — it needs a code from a real inbox — and is unit-backed end to end against the sandbox outbox in [`unit:sandbox-email-link.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L16). (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L609)) | 152 |\n| sendSignInLinkToEmail(auth, email, settings) | | ActionCodeSettings validation, enforced CLIENT-side before any request leaves the process: a missing or unparseable `url` throws `auth/invalid-continue-uri` (NOT `auth/missing-continue-uri`, despite the constant's name), and `handleCodeInApp` other than `true` throws `auth/argument-error`. On success the sandbox mints a single-use code and posts the message to the outbox. | ✓ | ORACLE-BACKED on both client-side arms (project-independent — prod threw before any network call): [`auth-sendsigninlinktoemail-settings-validation`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-sendsigninlinktoemail-settings-validation.json) captured `missingUrl: auth/invalid-continue-uri` and `handleCodeInAppFalse: auth/argument-error`. Replayed in [`unit:oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L624); the send path is unit-backed in [`unit:sandbox-email-link.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L26). | 153 |\n| signInWithEmailLink(auth, email, link) | | Redeems the code in a sign-in link. A link carrying no `oobCode` throws `auth/argument-error` CLIENT-side (the SDK never reaches the server to ask about a code it cannot find in the link). A first-time sign-in for an address CREATES the account, `getAdditionalUserInfo(cred).isNewUser` is `true`, and the account arrives `emailVerified: true` — redeeming a code mailed to that address is proof of control. The code is single-use. | ✓ | ORACLE-BACKED on the client-side reject ([`auth-signinwithemaillink-invalid-link`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-signinwithemaillink-invalid-link.json): `noOobCode: auth/argument-error`). The REDEMPTION path could not be probed: the oracle project has email-link sign-in disabled, so the server arm answered `auth/operation-not-allowed`. That half is unit-backed end to end against the sandbox outbox ([`unit:sandbox-email-link.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L28) drives send -> read the outbox -> sign in). (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L644)) | 154 |\n| sendPasswordResetEmail(auth, email) | | For an address NO account owns, prod RESOLVES silently — it does not throw `auth/user-not-found`. Email Enumeration Protection is on by default and refusing to leak account existence is the point. The sandbox matches: it resolves and mails nothing. A malformed address still throws `auth/invalid-email`. For a real account, the sandbox mails a reset code; `confirmPasswordReset` redeems it and the new password signs in while the old one throws `auth/wrong-password`. | ✓ | ORACLE-BACKED on the enumeration-protection behavior — the fact most likely to be got wrong: [`auth-sendpasswordresetemail-unknown-user`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-sendpasswordresetemail-unknown-user.json) captured `resolvedForUnknownUser: true`, `unknownUserCode: null`, `malformedEmailCode: auth/invalid-email`. A shim that threw `auth/user-not-found` here would hand agent code an account oracle production deliberately removed. Reset round trip unit-backed in [`unit:sandbox-email-link.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L25). (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L656)) | 155 |\n| sendEmailVerification(user) | | Throws `auth/missing-email` for a user with no email on the account (an anonymous user). For a real account the mail goes out and NOTHING ELSE HAPPENS: `user.emailVerified` stays `false`. Verification happens when the code in the message is redeemed (`applyActionCode`), not when it is sent. | ✓ | ORACLE-BACKED on the anonymous reject: [`auth-sendemailverification-shape`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-sendemailverification-shape.json) captured `anonymousUserCode: auth/missing-email` against prod. The send-does-not-verify property is the one the whole flow turns on and is unit-backed ([`unit:sandbox-email-link.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L24) asserts `emailVerified` is still false after the send and true only after the code is applied) — it cannot be oracle-confirmed end to end because confirming it would require reading a real inbox. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L676)) | 156 |\n| verifyBeforeUpdateEmail(user, newEmail) | | Mails a code to the NEW address and returns. The account's email does NOT change until that code is redeemed — the single guarantee separating this API from a bare `updateEmail`: the user must prove control of the new address before it becomes theirs. On redemption the account moves and the new address arrives `emailVerified: true`. `checkActionCode` on the code reports `data.email` = the new address and `data.previousEmail` = the old one. | ✓ | UNIT-BACKED, not oracle-backed, and the reason is recorded rather than hidden: the probe ([`auth-verifybeforeupdateemail-shape`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-verifybeforeupdateemail-shape.json)) ran against the real project and came back `auth/operation-not-allowed` on every arm, because the oracle project has the Email/Password provider DISABLED and the probe could not even create the user whose email it would change. The capture is committed showing exactly that. Behavior is proven end to end against the sandbox in [`unit:sandbox-email-link.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L30). (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L89)) | 157 |\n| sandbox.takeAuthMail(auth) / sandbox.setAuthMailResolver(auth, resolver) | | The sandbox IS the mail server. Every send posts a real message (operation, recipient, single-use code, and the full action link) to an outbox; `sandbox.takeAuthMail` reads it — the program's substitute for a human opening an inbox. The mailed link round-trips through the public `isSignInWithEmailLink` / `parseActionCodeURL` unchanged, and its code is the code the redeemer accepts, so the round trip production cannot complete without a human closes in-process. An installed `AuthMailResolver` is additionally notified per message; a resolver that THROWS does not fail the send that produced it. | ✓ | [`unit:sandbox-email-link.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L102) — sandbox-only driver (no `firebase/auth` counterpart; this is the seam that makes the family testable at all). | 158 |\n| checkActionCode(auth, code) / verifyPasswordResetCode(auth, code) / confirmPasswordReset(auth, code, newPassword) | | `checkActionCode` and `verifyPasswordResetCode` INSPECT a code without burning it — a check must not destroy the code the subsequent apply needs. `confirmPasswordReset` redeems it and sets the password, running the same strength check `createUserWithEmailAndPassword` runs; a weak new password throws `auth/weak-password` and does NOT burn the code (a typo must not destroy the user's one reset link). A code staged as expired throws `auth/expired-action-code`. | ✓ | UNIT-BACKED, stated honestly: these three endpoints answered `auth/operation-not-allowed` in the oracle run (`auth-action-code-invalid`) because the oracle project's disabled password provider replies before the invalid-code contract can. That is a fact about the project's configuration, not about the API, so it is NOT asserted as evidence. The error codes used here are matched to the oracle-CAPTURED `AuthErrorCodes` map (`INVALID_OOB_CODE = auth/invalid-action-code`, `EXPIRED_OOB_CODE = auth/expired-action-code` — see auth#172). Behavior proven in [`unit:sandbox-email-link.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L17). | 159 |\n| sendSignInLinkToEmail | `auth/unauthorized-continue-uri` (domain allowlist) | Prod rejects a continue URL whose domain is not on the project's authorized-domains list. The sandbox has NO domain allowlist and does not invent one: it accepts any parseable continue URL. A continue URL that would be rejected in production is accepted here. | ⚠ | The sandbox has no project config to hold an authorized-domains list, so there is nothing to check against; inventing an allowlist would mean inventing a policy the user never set. The probe attempted the server arm (`auth-sendsigninlinktoemail-settings-validation`, `unauthorizedDomain`) but the oracle project has email-link sign-in disabled, so it answered `auth/operation-not-allowed` rather than `auth/unauthorized-continue-uri` — the divergence is declared from the documented contract, not from a capture we do not have. (Structured evidence: [`sandbox-email-link.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L26)) | 163 |\n\n## `linkWithCredential` / `linkWithPopup` / `linkWithRedirect` / `unlink` — account linking\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| linkWithCredential(user, credential) | | The ANONYMOUS UPGRADE. Linking an email credential onto an anonymous account upgrades it IN PLACE: the uid is PRESERVED, `isAnonymous` flips to `false`, the email is set, and `providerData` gains the provider. Preserving the uid is what keeps the data the user created while anonymous theirs. Returns a `UserCredential` with `operationType: 'link'` and `getAdditionalUserInfo().isNewUser === false` (a link never creates an identity). The linked credential then works as a first-class `signInWithEmailAndPassword`. | ✓ | UNIT-BACKED, not oracle-backed — stated plainly. The probe ([`auth-link-email-credential-to-anonymous`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-link-email-credential-to-anonymous.json)) ran against the real project and returned `linkCode: auth/operation-not-allowed`: the oracle project has the Email/Password provider DISABLED, so no email credential can be minted there and the flow cannot be reached. The capture is committed showing exactly that rather than being dropped. Behavior proven in [`unit:sandbox-linking-reauth.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-linking-reauth.test.ts#L25), including the uid-preservation invariant. | 160 |\n| linkWithCredential | conflict codes | `auth/provider-already-linked` when the account already carries the provider (one identity per provider). `auth/email-already-in-use` when the email credential belongs to a DIFFERENT account — an address can back only one identity, so the link cannot be granted without stealing it. | ✓ | UNIT-BACKED. The probe ([`auth-link-conflicts`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-link-conflicts.json)) ran and every arm returned `auth/operation-not-allowed` (same disabled Email/Password provider on the oracle project), so the conflict codes could not be observed against prod. They are matched instead to the oracle-CAPTURED `AuthErrorCodes` map (`PROVIDER_ALREADY_LINKED = auth/provider-already-linked`, `CREDENTIAL_ALREADY_IN_USE = auth/credential-already-in-use` — auth#172) and proven in [`unit:sandbox-linking-reauth.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-linking-reauth.test.ts#L25). NOTE: for an EMAIL credential the sandbox emits `auth/email-already-in-use`, the code prod uses for an address collision; `credential-already-in-use` remains the OAuth-credential case. | 161 |\n| unlink(user, providerId) | | Detaches a provider and returns the updated user with a SHRUNKEN `providerData`. Unlinking a provider that was never linked throws `auth/no-such-provider`. Unlinking `'password'` takes the password with it, so `signInWithEmailAndPassword` for that account stops working. Unlinking the LAST provider does NOT re-anonymize the account — `isAnonymous` describes how an identity was born, not what it currently carries. | ✓ | ORACLE-BACKED on the reject path — the ONE linking fact the oracle could reach, because it needs no email credential: [`auth-unlink-provider`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-unlink-provider.json) captured `noSuchProviderCode: auth/no-such-provider` against prod on an anonymous user. Replayed in [`unit:oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L685). The detach path is unit-backed ([`unit:sandbox-linking-reauth.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-linking-reauth.test.ts#L33)). | 162 |\n| linkWithPopup(user, provider, resolver?) / linkWithRedirect(...) | | Route through the SAME `AuthFlowResolver` seam as `signInWithPopup`, with `authType: 'link'` on the request so a host UI can present 'link your Google account' rather than 'sign in'. The resolved credential names the provider to attach; the sandbox performs the attach and the uid is preserved. A disabled provider throws `auth/operation-not-allowed` AHEAD of the resolver check — a code deliberately distinct from `auth/argument-error`, which keeps meaning 'enabled, but no resolver/mock wired'. | ✓ | [`unit:sandbox-linking-reauth.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-linking-reauth.test.ts#L26) — asserts the resolver sees `authType: 'link'`, the uid is preserved, and the two error codes stay distinct. The OAuth arm cannot be oracle-probed at all (it needs a real IdP popup and a human), which is precisely why it goes through the resolver seam. | 164 |\n\n## `reauthenticateWithCredential` / `reauthenticateWithPopup` / `reauthenticateWithRedirect` — re-authentication\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| reauthenticateWithCredential(user, credential) | | Really re-verifies: an email credential is checked against the stored password exactly as `signInWithEmailAndPassword` checks it. A wrong password throws `auth/wrong-password`. A credential belonging to a DIFFERENT account throws `auth/user-mismatch` — checked BEFORE the password compare, so it cannot leak whether the other account's password was right. On success a fresh ID token is minted (a new `authTime`), and the returned `UserCredential` carries `operationType: 'reauthenticate'`. | ✓ | UNIT-BACKED, and the reason it is not oracle-backed is recorded rather than glossed: the probe ([`auth-reauthenticate-with-credential`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-reauthenticate-with-credential.json)) ran against the real project and could not even create the two accounts it needs — `setupCode: auth/operation-not-allowed`, because the oracle project has the Email/Password provider DISABLED. The capture is committed showing that. `auth/user-mismatch` is matched to the oracle-captured `AuthErrorCodes` map (auth#172). Behavior proven in [`unit:sandbox-linking-reauth.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-linking-reauth.test.ts#L27). | 170 |\n| reauthenticate* | `auth/requires-recent-login` is NOT enforced | In production the POINT of re-authentication is the `auth/requires-recent-login` gate: `updateEmail` / `updatePassword` / `deleteUser` refuse to run on a session whose sign-in is older than a few minutes, and re-auth is how you clear it. The sandbox does NOT enforce that gate — those three mutations already run on a session of any age (a pre-existing documented divergence), so there is no gate here for re-auth to clear. What re-auth DOES do here is real but narrower: it genuinely re-verifies the credential, mints a fresh token, and returns `operationType: 'reauthenticate'`. Code that calls it runs unchanged against prod, where it also clears the gate. | ⚠ | Declared divergence, not a bug: inventing a recent-login gate would break every existing sandbox flow (which never re-authenticates) while proving nothing. [`unit:sandbox-linking-reauth.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-linking-reauth.test.ts#L27) pins the behavior that IS provided (real credential re-verification + a fresh token). | 176 |\n| reauthenticateWithPopup(user, provider, resolver?) / reauthenticateWithRedirect(...) | | Route through the shared resolver seam with `authType: 'reauth'`. The identity the resolver produces MUST be the user being re-authenticated — a resolver that hands back a different uid throws `auth/user-mismatch`. That check is the entire security content of the flow; without it 're-authentication' would accept anyone. | ✓ | [`unit:sandbox-linking-reauth.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-linking-reauth.test.ts#L28) — asserts the resolver sees `authType: 'reauth'` and that an impostor identity is rejected with `auth/user-mismatch`. | 177 |\n\n## Constants, credentials, and inert config tokens\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getAdditionalUserInfo(userCredential) | | Returns `{ isNewUser, profile, providerId }`. For a fresh anonymous sign-in prod reports `{ isNewUser: true, providerId: null, profile: {} }` — `providerId` is NULL, not `'anonymous'`, because anonymous is not a federated provider. `isNewUser` is `true` for `createUserWithEmailAndPassword`, a first-time email-link sign-in, and a custom-token sign-in that minted the account; `false` for a returning `signInWithEmailAndPassword` and for every `link` / `reauthenticate`. | ✓ | ORACLE-BACKED on the anonymous shape: [`auth-additional-user-info-shape`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-additional-user-info-shape.json) captured `{isNewUser: true, providerId: null, profile: {}}` against prod. Replayed in [`unit:oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L695). Email create/sign-in + custom-token mint/return isNewUser matrix (providerId null per AUTH-B2): [`unit:upstream-auth-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/upstream-auth-probes.test.ts#L6). Email-link + link/reauth arms: [`unit:sandbox-email-link.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L20), [`unit:sandbox-linking-reauth.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-linking-reauth.test.ts#L22). | 171 |\n| ProviderId / SignInMethod / OperationType / ActionCodeOperation / AuthErrorCodes | | The constant maps, value for value. `OperationType.SIGN_IN === 'signIn'`, `SignInMethod.EMAIL_LINK === 'emailLink'`, `ProviderId.GOOGLE === 'google.com'`, `ActionCodeOperation.PASSWORD_RESET === 'PASSWORD_RESET'`, and the 106-entry `AuthErrorCodes` map (`INVALID_OOB_CODE === 'auth/invalid-action-code'`, `PROVIDER_ALREADY_LINKED === 'auth/provider-already-linked'`, `NO_SUCH_PROVIDER === 'auth/no-such-provider'`, `USER_MISMATCH === 'auth/user-mismatch'`, …). | ✓ | ORACLE-BACKED, snapshotted straight from the shipped SDK: [`auth-mechanical-surface-constants`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-mechanical-surface-constants.json) against firebase-js-sdk 12.13.0, replayed value-for-value in [`unit:oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L711). This matters more than it looks: consumer code COMPARES against these constants, so a mirror that got a string wrong would turn every such comparison into a silent `false` — a worse failure than the export simply being absent, because it typechecks and runs. NOTE: the capture's `persistenceTypes` block is deliberately NOT asserted — the harness runs under Node, where firebase/auth stubs the browser-only persistence tokens to `type: 'NONE'` (it reports 'NONE' even for `browserLocalPersistence`, which is unambiguously 'LOCAL'); asserting it would be asserting a harness artifact. See auth#178. | 172 |\n| signInWithCustomToken(auth, customToken) | | Throws `auth/invalid-custom-token` for a malformed token and for the empty string. The sandbox accepts a token in the two shapes it can honestly read: a JSON (optionally base64url) `{uid, claims}` payload — exactly what `admin.auth().createCustomToken` signs, so the pyric-admin mint and this redeem compose — or a real three-part JWT whose payload segment carries `uid`/`sub`. The SIGNATURE IS NOT VERIFIED: the sandbox has no service-account key. The identity is created if it does not exist, and the credential carries `providerId: null` (custom-token sign-in is not a federated provider). | ⚠ | ORACLE-BACKED on the reject path: [`auth-signinwithcustomtoken-invalid`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-signinwithcustomtoken-invalid.json) captured `auth/invalid-custom-token` for both a malformed token and the empty string, replayed in [`unit:oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L742). Diverged on the ACCEPT path, declared rather than hidden: prod verifies an RS256 signature against the project's service-account key and the sandbox has no key, so it reads the token's claims WITHOUT verifying them. A forged token that prod would reject is accepted here. The happy path cannot be oracle-probed from a Web SDK client at all (it needs an Admin-SDK-signed JWT). | 173 |\n| validatePassword(auth, password) | | Returns a `PasswordValidationStatus` against the project's password policy, WITHOUT attempting a sign-up (so a UI can show live strength feedback). The sandbox's policy is `minPasswordLength: 6`, `maxPasswordLength: 4096`, `enforcementState: 'ENFORCE'`, with every character-class requirement UNSET — so a password this function calls valid is exactly one `createUserWithEmailAndPassword` will accept. The character-class fields are `undefined`, not `false`: upstream distinguishes 'not required' from 'required and unmet', and reporting `false` would claim the password failed a rule the project never had. | ✓ | ORACLE-BACKED: [`auth-validatepassword-status-shape`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-validatepassword-status-shape.json) captured prod's live policy (minPasswordLength 6, maxPasswordLength 4096, ENFORCE, character classes unset) and the status shape for a weak and a strong password. Replayed in [`unit:oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L753). The 6-char minimum agrees with the separately oracle-pinned `auth/weak-password` threshold on the create path, so the two surfaces draw the same line here exactly as they do in prod. | 174 |\n| fetchSignInMethodsForEmail(auth, email) | | Not implemented; disposition `auth.email-enumeration` owns the reviewed availability and rationale. | — | disposition:auth.email-enumeration; the [`auth-fetchsigninmethodsforemail-deprecated`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-fetchsigninmethodsforemail-deprecated.json) capture is retained but inconclusive because the oracle project's Email/Password provider is disabled. | 175 |\n| inMemoryPersistence / browserSessionPersistence / browserLocalPersistence / indexedDBLocalPersistence / browserCookiePersistence / browserPopupRedirectResolver / debugErrorMap / prodErrorMap | | Inert configuration tokens, accepted so the idiomatic `initializeAuth(app, { persistence: indexedDBLocalPersistence })` and `setPersistence(auth, browserCookiePersistence)` compile, run, and behave identically. The `.type` discriminant matches upstream exactly (`NONE` / `SESSION` / `LOCAL` / `LOCAL` / `COOKIE`) because consumer code branches on it. `browserPopupRedirectResolver` is accepted and ignored — the sandbox has its own first-class pluggable equivalent (`sandbox.setAuthFlowResolver`). `prodErrorMap` is accepted and DELIBERATELY not honored: installing it upstream strips error messages, and doing that in a sandbox whose purpose is to tell a developer what went wrong would be actively hostile. | ⚠ | In an in-memory sandbox the persistence CHOICE has no observable consequence, so accepting the token and recording the mode is the honest behavior — the same inert-token pattern `pyric/firestore` uses for its cache-factory tokens. [`unit:types.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/types.test.ts#L1) + [`unit:sandbox-email-link.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L1) cover the exports. Oracle caveat recorded on auth#172: the `persistenceTypes` block of `auth-mechanical-surface-constants` reports `NONE` for every token including `browserLocalPersistence` — a Node-build stub artifact, not the browser contract, so the `.type` values here follow the documented `Persistence.type` union instead. | 178 |\n| revokeAccessToken(auth, token) | | Accepted no-op. In production this reaches OUTSIDE Firebase entirely — it tells the identity provider (in practice Apple) to revoke an OAuth access token, which is a call landing on Apple's servers. There is no external IdP behind a sandbox sign-in, so there is no token out there to revoke and nothing this call could truthfully do. It resolves (so the account-deletion flow Apple requires an app to ship runs end to end against the sandbox) and changes no sandbox state (because claiming otherwise would be a lie). | ⚠ | [`unit:types.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/types.test.ts#L2) — the export resolves. Deliberately not oracle-probed: a successful probe would revoke a real token at a real IdP, which is a side effect on someone else's system that a conformance run has no business causing. | 179 |\n| AuthCredential / EmailAuthCredential / OAuthCredential | | Real classes, not markers. `EmailAuthProvider.credential(email, password)` returns an `EmailAuthCredential` that CARRIES THE SECRET — which is what makes `linkWithCredential`, `reauthenticateWithCredential`, and `signInWithCredential` decidable in the sandbox with no resolver and no mock (the backend already stores and verifies passwords). `credentialWithLink` carries the link instead, and `signInMethod` discriminates (`'password'` vs `'emailLink'`). The backing secret is non-enumerable, so a spread or `Object.keys` walk does not pick it up; `toJSON()` DOES carry it, matching upstream (whose `fromJSON` needs it to round-trip). `OAuthCredential` carries the IdP tokens, which the sandbox cannot verify — those flows keep going through the resolver seam. | ✓ | [`unit:sandbox-linking-reauth.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-linking-reauth.test.ts#L323) — pins the secret-carrying behavior, the enumerable/toJSON split (so nobody 'hardens' it into a divergence later), and that a real email credential now signs in via `signInWithCredential` instead of throwing the sandbox-only `auth/no-mock-configured` it used to. | 180 |\n| TwitterAuthProvider / SAMLAuthProvider | | Provider marker classes. `TwitterAuthProvider.credential(token, secret)` takes a token AND a secret — Twitter is the one OAuth 1.0a provider in the set, where the OAuth 2.0 providers take a single access token. `SAMLAuthProvider`'s constructor ENFORCES the `saml.` providerId prefix (throwing `auth/argument-error` otherwise), because that id is what routes an assertion to the right configured IdP and a typo there would silently target nothing. SAML has no client-constructible credential, so the class has no `credential()` factory. | ✓ | [`unit:types.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/types.test.ts#L1) — construction, PROVIDER_ID values, and the SAML prefix guard. | 181 |\n| User.providerData | | One entry PER LINKED PROVIDER, read from the identity's stored record. Previously the sandbox synthesized a single `{providerId: 'password'}` entry for EVERY non-anonymous user, so a Google popup sign-in reported its provider as `'password'` and a linked account could never show more than one. Consumer code branches on this array (that is what it is for — 'is this account linked to Google?'), so the synthesized version was actively misleading. Empty for anonymous users; after `unlink` of the last provider it is genuinely empty (it does not resurrect the removed provider). | ✓ | Was a BUG until this climb, and worth naming as one: the array was fabricated, not read. Now fixed and locked. [`unit:sandbox-linking-reauth.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-linking-reauth.test.ts#L54) now pins that a Google link surfaces `google.com` in `providerData`, that `unlink` shrinks it, and that unlinking the last provider leaves it empty rather than falling back to a synthesized `'password'` entry. | 182 |\n\n## Current gaps\n\n### Documented divergences\n\nKnown differences between Pyric and production Firebase. Each remains tracked as a non-conforming row.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">signInAnonymously(auth)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Auto-generates a uid for fresh sign-ins (sandbox format: <code>anonymous-{N}</code>)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-anonymous.test.ts#L7\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:sandbox-anonymous.test.ts</code></a> — prod uids are 28-char base64-ish; sandbox uses a readable counter for debuggability</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">signInAnonymously(auth)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Anonymous users persist across page reload via configured <code>Persistence</code> (prod only — sandbox has no persistence layer)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence: sandbox memory only; within one tab the user persists, across reload they don't</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">signInWithEmailAndPassword(auth, email, password)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">An EMPTY password throws <code>auth/missing-password</code> (message \"A non-empty password must be provided\"), fired before the user-DB lookup so it can't be used to enumerate seeded emails. Upstream maps the <code>MISSING_PASSWORD</code> server error (<code>core/errors.ts:92,282,563</code>). ⚠ best-known semantics — message text not yet captured against a live project (STOP-flagged for an oracle pass; the <code>.code</code> is the load-bearing part).</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-cluster-b9-b12.test.ts#L18\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:sandbox-cluster-b9-b12.test.ts</code></a> (locks AUTH-B11)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">signOut(auth)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Clears the active session's persistence in prod; sandbox has no persistence</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence: same memory-only constraint as the anonymous persistence row</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">setPersistence(auth, persistence)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Actually changes where the auth state is persisted</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence: sandbox is a no-op. Prod respects the marker.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">signInWithPopup(auth, provider)` / `signInWithCredential(auth, credential)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Opens a popup window in prod</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence: sandbox skips the popup; mock pre-stage replaces the popup result</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">beforeAuthStateChanged(auth, callback, onAbort?)</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>sandbox.setUser</code> (the sandbox-only test driver) BYPASSES the gate entirely — it is a raw identity force with no prod analog (same bypass it already has for provider enforcement / <code>signInProvider</code> tracking), so no registered <code>beforeAuthStateChanged</code> callback runs and none can block it.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-before-auth-state-changed.test.ts#L2\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:sandbox-before-auth-state-changed.test.ts</code></a> (\"sandbox.setUser test driver bypasses the gate\")</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">beforeAuthStateChanged(auth, callback, onAbort?)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Served-worker path (SharedWorker-backed auth in <code>@pyric/cli</code>): the worker owns the shared user pool and commits transitions on its own side of the port, so a page-local <code>beforeAuthStateChanged</code> registration can't actually gate a worker-driven sign-in. Rather than silently accept a callback that could never run, registering THROWS immediately (<code>auth/operation-not-supported-in-this-environment</code>) — same defensive pattern as <code>signInWithCredential</code> over the worker.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>packages/cli/src/serve/worker/client.ts</code> (<code>beforeAuthStateChanged</code> throws <code>makeUnsupported</code>)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">updateEmail(user, newEmail)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Changes the stored email (via the same path as <code>sandbox.updateUser</code>, rejecting <code>auth/email-already-in-use</code> / <code>auth/invalid-email</code>) and mutates the held <code>user</code> in place, so the next sign-in resolves against the new email. Leniency vs prod: the sandbox does NOT enforce <code>auth/requires-recent-login</code> and is not routed through <code>verifyBeforeUpdateEmail</code> (which the real SDK requires when email-enumeration protection is on)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/fruit-aliases.test.ts#L18\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:fruit-aliases.test.ts</code></a> — stored email changes; re-sign-in works with the new email, fails with the old</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">updatePassword(user, newPassword)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Sets the stored password (validated for strength). The sandbox DOES store and verify passwords, so this is a real mutation: a subsequent <code>signInWithEmailAndPassword</code> with the new password succeeds and the old one throws <code>auth/wrong-password</code>. Leniency vs prod: no <code>auth/requires-recent-login</code> enforcement</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/fruit-aliases.test.ts#L19\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:fruit-aliases.test.ts</code></a> — new password signs in, old password throws <code>auth/wrong-password</code></div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">useDeviceLanguage(auth)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Accepted no-op — the sandbox has no device locale to read, so there is no language to set; accepted so init code that calls it compiles + runs</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/fruit-aliases.test.ts#L22\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:fruit-aliases.test.ts</code></a> — resolves/returns without error</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">sendSignInLinkToEmail</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Prod rejects a continue URL whose domain is not on the project's authorized-domains list. The sandbox has NO domain allowlist and does not invent one: it accepts any parseable continue URL. A continue URL that would be rejected in production is accepted here.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">The sandbox has no project config to hold an authorized-domains list, so there is nothing to check against; inventing an allowlist would mean inventing a policy the user never set. The probe attempted the server arm (<code>auth-sendsigninlinktoemail-settings-validation</code>, <code>unauthorizedDomain</code>) but the oracle project has email-link sign-in disabled, so it answered <code>auth/operation-not-allowed</code> rather than <code>auth/unauthorized-continue-uri</code> — the divergence is declared from the documented contract, not from a capture we do not have. (Structured evidence: <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L26\" target=\"_blank\" rel=\"noopener noreferrer\"><code>sandbox-email-link.test.ts</code></a>)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">reauthenticate*</code><span class=\"compat-sub\"><span class=\"compat-behavior\">In production the POINT of re-authentication is the <code>auth/requires-recent-login</code> gate: <code>updateEmail</code> / <code>updatePassword</code> / <code>deleteUser</code> refuse to run on a session whose sign-in is older than a few minutes, and re-auth is how you clear it. The sandbox does NOT enforce that gate — those three mutations already run on a session of any age (a pre-existing documented divergence), so there is no gate here for re-auth to clear. What re-auth DOES do here is real but narrower: it genuinely re-verifies the credential, mints a fresh token, and returns <code>operationType: 'reauthenticate'</code>. Code that calls it runs unchanged against prod, where it also clears the gate.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Declared divergence, not a bug: inventing a recent-login gate would break every existing sandbox flow (which never re-authenticates) while proving nothing. <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-linking-reauth.test.ts#L27\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:sandbox-linking-reauth.test.ts</code></a> pins the behavior that IS provided (real credential re-verification + a fresh token).</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">signInWithCustomToken(auth, customToken)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Throws <code>auth/invalid-custom-token</code> for a malformed token and for the empty string. The sandbox accepts a token in the two shapes it can honestly read: a JSON (optionally base64url) <code>{uid, claims}</code> payload — exactly what <code>admin.auth().createCustomToken</code> signs, so the pyric-admin mint and this redeem compose — or a real three-part JWT whose payload segment carries <code>uid</code>/<code>sub</code>. The SIGNATURE IS NOT VERIFIED: the sandbox has no service-account key. The identity is created if it does not exist, and the credential carries <code>providerId: null</code> (custom-token sign-in is not a federated provider).</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">ORACLE-BACKED on the reject path: <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-signinwithcustomtoken-invalid.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>auth-signinwithcustomtoken-invalid</code></a> captured <code>auth/invalid-custom-token</code> for both a malformed token and the empty string, replayed in <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/oracle-conformance.test.ts#L742\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:oracle-conformance.test.ts</code></a>. Diverged on the ACCEPT path, declared rather than hidden: prod verifies an RS256 signature against the project's service-account key and the sandbox has no key, so it reads the token's claims WITHOUT verifying them. A forged token that prod would reject is accepted here. The happy path cannot be oracle-probed from a Web SDK client at all (it needs an Admin-SDK-signed JWT).</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">inMemoryPersistence / browserSessionPersistence / browserLocalPersistence / indexedDBLocalPersistence / browserCookiePersistence / browserPopupRedirectResolver / debugErrorMap / prodErrorMap</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Inert configuration tokens, accepted so the idiomatic <code>initializeAuth(app, { persistence: indexedDBLocalPersistence })</code> and <code>setPersistence(auth, browserCookiePersistence)</code> compile, run, and behave identically. The <code>.type</code> discriminant matches upstream exactly (<code>NONE</code> / <code>SESSION</code> / <code>LOCAL</code> / <code>LOCAL</code> / <code>COOKIE</code>) because consumer code branches on it. <code>browserPopupRedirectResolver</code> is accepted and ignored — the sandbox has its own first-class pluggable equivalent (<code>sandbox.setAuthFlowResolver</code>). <code>prodErrorMap</code> is accepted and DELIBERATELY not honored: installing it upstream strips error messages, and doing that in a sandbox whose purpose is to tell a developer what went wrong would be actively hostile.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">In an in-memory sandbox the persistence CHOICE has no observable consequence, so accepting the token and recording the mode is the honest behavior — the same inert-token pattern <code>pyric/firestore</code> uses for its cache-factory tokens. <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/types.test.ts#L1\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:types.test.ts</code></a> + <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/sandbox-email-link.test.ts#L1\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:sandbox-email-link.test.ts</code></a> cover the exports. Oracle caveat recorded on auth#172: the <code>persistenceTypes</code> block of <code>auth-mechanical-surface-constants</code> reports <code>NONE</code> for every token including <code>browserLocalPersistence</code> — a Node-build stub artifact, not the browser contract, so the <code>.type</code> values here follow the documented <code>Persistence.type</code> union instead.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">revokeAccessToken(auth, token)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Accepted no-op. In production this reaches OUTSIDE Firebase entirely — it tells the identity provider (in practice Apple) to revoke an OAuth access token, which is a call landing on Apple's servers. There is no external IdP behind a sandbox sign-in, so there is no token out there to revoke and nothing this call could truthfully do. It resolves (so the account-deletion flow Apple requires an app to ship runs end to end against the sandbox) and changes no sandbox state (because claiming otherwise would be a lie).</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/auth/types.test.ts#L2\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:types.test.ts</code></a> — the export resolves. Deliberately not oracle-probed: a successful probe would revoke a real token at a real IdP, which is a side effect on someone else's system that a conformance run has no business causing.</div></div></details>\n</div>\n\n### Not implemented yet\n\nTracked behavior that is not implemented in the current contract.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"unsupported\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">signInWithPopup(auth, provider)` / `signInWithCredential(auth, credential)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Cancels with <code>auth/popup-closed-by-user</code> when the user dismisses the popup (prod)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">not modeled — would require the host to expose a \"cancel\" affordance on the mock</div></div></details>\n<details class=\"compat-row\" data-status=\"unsupported\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\">Custom scopes / params / language code</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">sandbox ignores; prod forwards</div></div></details>\n<details class=\"compat-row\" data-status=\"unsupported\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">User` methods</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>user.metadata.creationTime</code> / <code>lastSignInTime</code></span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">client <code>User.metadata</code> not exposed (AUTH-GAP); the admin record carries <code>createdAt</code>/<code>lastLoginAt</code> (ISO)</div></div></details>\n<details class=\"compat-row\" data-status=\"unsupported\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">User` methods</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>user.reload()</code> / <code>user.delete()</code> / <code>user.toJSON()</code> / <code>user.refreshToken</code> / <code>user.tenantId</code></span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">not modeled by the sandbox; retained as an explicit unsupported behavior row rather than synthesized (AUTH-GAP)</div></div></details>\n<details class=\"compat-row\" data-status=\"unsupported\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">fetchSignInMethodsForEmail(auth, email)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Not implemented; disposition <code>auth.email-enumeration</code> owns the reviewed availability and rationale.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">disposition:auth.email-enumeration; the <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/auth/auth-fetchsigninmethodsforemail-deprecated.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>auth-fetchsigninmethodsforemail-deprecated</code></a> capture is retained but inconclusive because the oracle project's Email/Password provider is disabled.</div></div></details>\n</div>\n\n### Unverified\n\nTracked behavior whose available evidence does not yet establish the production result.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"unverified\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unverified\" role=\"img\" aria-label=\"Unverified\" title=\"Unverified\"></span><span class=\"compat-main\"><code class=\"compat-api\">getAuth(target)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Without sandbox package swapping, canonical <code>firebase/auth</code> imports remain Firebase and never enter this mirror</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Direct mirror rejection is locked by <code>unit:package-resolution.test.ts</code>; an unswapped production-resolution observation is still needed</div></div></details>\n</div>\n\n## Reviewed public-runtime gaps\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">auth.mfa-phone-recaptcha</code><span class=\"compat-sub\"><span class=\"compat-behavior\">MFA / phone / reCAPTCHA is deferred, not out of scope — reCAPTCHA/SMS are external infrastructure Pyric can mock with the OAuth resolver pattern, and TOTP is pure algorithm work.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>FactorId</code> <code>PhoneAuthCredential</code> <code>PhoneAuthProvider</code> <code>PhoneMultiFactorGenerator</code> <code>RecaptchaVerifier</code> <code>TotpMultiFactorGenerator</code> <code>TotpSecret</code> <code>getMultiFactorResolver</code> <code>initializeRecaptchaConfig</code> <code>linkWithPhoneNumber</code> <code>multiFactor</code> <code>reauthenticateWithPhoneNumber</code> <code>signInWithPhoneNumber</code></div>\n<div class=\"compat-note\">registry:auth#180</div></div>\n</details>\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Out of scope\" title=\"Out of scope\"></span><span class=\"compat-main\"><code class=\"compat-api\">auth.email-enumeration</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Deprecated upstream as a security retraction, not a cosmetic one: the shipped @firebase/auth declaration states it \"returns an empty list when Email Enumeration Protection is enabled, irrespective of the number of authentication methods available\", and that \"migrating off of this method is recommended as a security best-practice\". Enumeration protection is on by default, so against a modern project the production function always returns []. A mirror that reproduced the pre-deprecation behavior would be MORE capable than prod and would silently mislead agent code that branches on the result; a mirror that reproduced the post-deprecation behavior would be a no-op. Same criterion applied to Imagen in firebase/ai — an API whose production counterpart is retiring is out of scope, because mirroring it freezes dead behavior.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>fetchSignInMethodsForEmail</code></div>\n<div class=\"compat-note\">registry:auth#175, upstream:firebase/auth</div></div>\n</details>\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">auth.phone-mutation</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Phone number mutation is deferred alongside the rest of the phone-auth family — buildable via a mocked SMS/verification-code resolver, the same shape as the OAuth resolver.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>updatePhoneNumber</code></div>\n<div class=\"compat-note\">registry:auth#181</div></div>\n</details>\n</div>\n" }, { "slug": "firestore-compat", "label": "Firestore", "title": "pyric/firestore compatibility matrix", "markdown": "<!-- Generated from the conformance model (registry rows + surface contracts). Do not edit by hand; run bun run compat:generate. -->\n\n# `pyric/firestore` compatibility matrix\n\n<div class=\"compat-stat\">\n<p class=\"compat-stat-figure\">\n<span class=\"compat-stat-pct\">100%</span>\n<span class=\"compat-stat-label\">of the public API supported</span>\n</p>\n<div class=\"compat-meters\">\n<span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 100%\"></span></span>\n</div>\n<p class=\"compat-stat-denom\">182 of 182 public API</p>\n</div>\n\n\n## Status legend\n\n| Status | Meaning |\n|---|---|\n| ✓ | **Conforming** — sandbox matches prod, locked by a passing probe |\n| ⚠ | **Diverged (documented)** — intentional difference with a written reason |\n\n## `getFirestore(target)` — initializer\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getFirestore(target) | | `getFirestore(ctx)` returns a tagged sandbox-target handle (frozen identity) | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L16) | 1 |\n| getFirestore(target) | | `getFirestore(sandbox)` returns a tagged sandbox-live handle (per-op identity) | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L2) | 2 |\n| getFirestore(target) | | Package resolution owns production selection: direct `pyric/firestore` rejects a real `FirebaseApp`, while inactive canonical `firebase/firestore` imports remain the real Firebase SDK | ✓ | [`unit:package-resolution.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/package-resolution.test.ts#L8), [`node-register:register-child.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/register/register-child.test.ts#L140) (inactive canonical imports are not rewritten) | 3 |\n| getFirestore(target) | | `getFirestore(undefined)` is wrapped in the playground preview to default to the sandbox; production's unactivated canonical SDK still throws `app/no-app`, while a direct mirror call rejects the missing sandbox owner | ✓ (wrap) | `playground:firestore-bare-getfirestore` — fix from PR #397 + oracle: [`packages/conformance/observations/firestore/firestore-bare-getfirestore-no-default-app.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-bare-getfirestore-no-default-app.json) (`code: 'app/no-app'` against blockingfun, fb-js-sdk 12.13.0 — confirms prod throw shape) | 4 |\n| getFirestore(target) | | Two `getFirestore(sandbox)` calls share state (same underlying `LocalEnvironment`) | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L2) (\"two handles share the same sandbox\") | 5 |\n| getFirestore(target) | | Handle dispatch by `TARGET_SYMBOL` brand — refs/queries route to their owning target via `refToTarget` WeakMap | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L16) (\"throws TypeError for refs not produced by this package\") | 6 |\n\n## Path constructors — `doc` / `collection` / `collectionGroup`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Path constructors | | `doc(db, path)` returns a tagged `DocumentReference` with `id` / `path` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L18) | 7 |\n| Path constructors | | `doc(db, 'a', 'b', 'c', 'd')` joins variadic path segments | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L330) | 8 |\n| Path constructors | | `collection(db, path)` returns a tagged `CollectionReference` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L19) | 9 |\n| Path constructors | | `doc(coll, id)` appends under a collection ref | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L18) | 10 |\n| Path constructors | | `doc(coll)` (no id) mints an auto-id `DocumentReference` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L18) | 11 |\n| Path constructors | | `collection(docRef, name)` builds a subcollection ref | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L19) | 12 |\n| Path constructors | | `collectionGroup(db, id)` returns a query spanning every collection with that id | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L19) (\"gathers documents across every parent collection\") | 13 |\n| Path constructors | | Unknown ref (not produced by this package) → `TypeError` with \"unrecognized reference\" | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L18) | 14 |\n| Path constructors | | Held doc/coll ref under `sandbox-live` re-resolves to the chainable under the current user at op time (via rebuild closure) | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L26) (\"held doc ref re-resolves under the current user\") | 15 |\n\n## `getDoc(ref)` — single-doc read\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getDoc(ref) | | Returns `DocumentSnapshot` with `id`, `exists` (method form), `data()` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L21) | 16 |\n| getDoc(ref) | | `snap.exists` is normalized to method form (`snap.exists()` returns boolean) to match the modular SDK | ✓ | `playground:firestore-onsnapshot` (bundled, assertion-shape compat) + `playground:firestore-row-17-snap-exists-method` (one-claim) | 17 |\n| getDoc(ref) | | `snap.data()` returns `undefined` for missing doc | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L21) | 18 |\n| getDoc(ref) | | `snap.ref` is tagged so it routes through `targetOf` in follow-up ops | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L21) | 19 |\n| getDoc(ref) | | Re-evaluates rules under current user on every call (sandbox-live) — read denied throws `permission-denied` | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L28) (\"doc read denied when current user lacks read access\"), oracle: [`packages/conformance/observations/firestore/firestore-read-denied-error-code.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-read-denied-error-code.json) (prod `getDoc` on a denied path throws a `FirebaseError` with `.code === 'permission-denied'`, `.message === 'Missing or insufficient permissions.'`, `instanceof Error`) (Structured evidence: [`query-rules-enforcement.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/admin-compat/query-rules-enforcement.test.ts#L4)) | 20 |\n| getDoc(ref) | | Rules-denied modular writes throw a `FirebaseError('permission-denied')`, matching the production error class and code | ✓ | Oracle-locked by [`packages/conformance/observations/firestore/firestore-rules-denied-error.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-rules-denied-error.json): prod throws a `FirebaseError` (name + constructor name both `FirebaseError`), `.code === 'permission-denied'`, and the value is an `instanceof FirebaseError` and `Error`. `oracle:firestore#21` replays the same denied modular write against the sandbox and asserts that complete class shape. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L569)) | 21 |\n\n## `getDocs(query)` — bulk read\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getDocs(query) | | Returns `QuerySnapshot` with `size`, `empty`, `docs` (`QueryDocumentSnapshot[]`) | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L22), `playground:firestore-query` (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L41)) | 22 |\n| getDocs(query) | | Each `snap.docs[i].ref` is tagged for follow-up ops | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L22) | 23 |\n| getDocs(query) | | Sandbox-live: re-evaluates filters under the current user (different docs visible per identity) | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L29) (\"query results re-evaluate under the current user\") | 24 |\n| getDocs(query) | | **Query reads enforce security rules (FS-B1)** — a deny-all / auth-gated rule set throws `permission-denied`. Pre-FS-B1 query reads went through the rules-bypassing `listDocuments` and returned the whole collection. | ✓ | [`unit:admin-compat/query-rules-enforcement.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/admin-compat/query-rules-enforcement.test.ts#L4) (deny-all + auth-gated `getDocs`/aggregate), [`unit:admin-compat/per-op-auth.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/admin-compat/per-op-auth.test.ts#L158) (\"Query.get enforces rules\") | 24a |\n| getDocs(query) | | **Enforcement follows production's QUERY-PROOF model (RULES-B11)** — \"rules are not filters\": a doc-data-dependent `list` rule (`resource.data.visibility == 'public'`, `resource.data.owner == request.auth.uid`) is ALLOWED when the query's `where()` equalities discharge it and the whole query is `permission-denied` otherwise — never silently truncated to the readable subset. Per-doc `get` rules do NOT filter query results (the `list` rule alone governs queries — granular-operations docs). Applies to `getDocs`, aggregates, and `onSnapshot` alike. Pre-fix: rules-as-filters (per-doc `get` omission) + blanket denial of every doc-data-dependent list, even provable ones. | ✓ | [`unit:firestore/query-proof-enforcement.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/query-proof-enforcement.test.ts#L19) (provable/unprovable getDocs + onSnapshot, owner-pinned uid, get-rules-don't-filter, request.query.limit; verified failing pre-fix), [`unit:simulator/local-environment.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/simulator/local-environment.test.ts#L617) (Slice 6 — flipped from per-doc-filter assertions); prod truth: firebase.google.com/docs/firestore/security/rules-query | 24b |\n| getDocs(query) | | Query-proof **prover scope is conservative, with full accounting and a fail-closed classifier** — user functions are inlined (nested helper-calling-helper and multi-parameter helpers included, matching production's rules-function inlining), and a `list` rule is provable only when its **entire** doc-dependence reduces to top-level AND-conjunct `resource.data.<field> == <literal>` equalities (with `request.auth.uid` pinned to the caller) each discharged by a `where(field, '==', value)` filter. Doc-dependence is decided by a fail-closed classifier: an expression counts as doc-independent only when every node is positively recognized as such — any touch of the `resource` root in any syntactic form (`resource.data`, `resource['data']`, slices, `resource.id`, path-literal segments feeding `exists`/`get` lookups) and any unrecognized node shape classifies doc-dependent. Any doc-dependent conjunct that is not a discharged equality — disjunctions over doc data, inequality/range predicates (`resource.data.score > 10` + `where('score','>',10)`), `in` / negated-`in` membership checks, `get(key, default)`, `keys().hasOnly(...)`, `is` type checks, nested-path predicates, data-keyed document lookups — makes the whole query conservatively DENY, even when every equality conjunct is discharged; production's prover may allow some of these. Divergence is deny-only: mixed equality + non-equality shapes are rejected up front rather than trusted to a residual evaluation that absent-tolerant predicates could pass vacuously. | ⚠ | [`unit:rules/simulator/query-proof.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/query-proof.test.ts#L7) (function-inlining provable cases; full-accounting rejections for mixed equality + `in`/`get`/`hasOnly`/range/`is` shapes in inline and helper form; fail-closed classifier probes: bracket-access `resource['data']`, `exists()` keyed by `resource.data` through path-literal segments, slice access, `resource.id`, and an unrecognized node shape), [`unit:firestore/query-proof-enforcement.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/query-proof-enforcement.test.ts#L19) (seeded end-to-end denials of the demonstrated attacks: forbidden-field absence check in member and bracket form, banned-owner `exists()` lookup, slice conjunct — each returning zero documents) | 24c |\n| getDocs(query) | | Empty result for a collection with no docs (`size === 0`, `empty === true`) | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L22) | 25 |\n\n## `setDoc(ref, data[, options])` — full write\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| setDoc(ref, data[, options]) | | No options → replaces the existing document entirely | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L24) (\"setDoc default replaces\") | 26 |\n| setDoc(ref, data[, options]) | | `{ merge: true }` → **deep-merges nested maps** (FS-B6), preserving unspecified fields at every level: `setDoc({a:{b:2}}, {merge:true})` over `{a:{c:1}}` yields `{a:{b:2,c:1}}`. Pre-FS-B6 the wrapper shallow-replaced the whole `a` map. | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L24), [`unit:admin-compat/field-path-merge.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/admin-compat/field-path-merge.test.ts#L2) (FS-B6 nested deep-merge; verified failing pre-fix) | 27 |\n| setDoc(ref, data[, options]) | | `{ mergeFields: [...] }` → writes only the listed **dot-separated field paths** into the existing doc (FS-B6); other keys in `data` are ignored, other fields in the existing doc preserved. `mergeFields: ['a.b']` reaches into a nested map. | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L24), [`unit:admin-compat/field-path-merge.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/admin-compat/field-path-merge.test.ts#L2) (dotted mergeField); mask edges (delete/transform outside mask, empty mask, deleteField in mask): [`unit:upstream-write-aggregate-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/upstream-write-aggregate-probes.test.ts#L19) | 28 |\n| setDoc(ref, data[, options]) | | Passing both `merge` and `mergeFields` — `mergeFields` wins on sandbox (matches JS SDK effective behavior) | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L24) | 29 |\n| setDoc(ref, data[, options]) | | Sentinels (`serverTimestamp`, `increment`, `arrayUnion`, `arrayRemove`, `deleteField`) resolve in the same call | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L24), `playground:firestore-sentinels`, oracle: [`packages/conformance/observations/firestore/firestore-row-30-sentinels-in-setdoc.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-30-sentinels-in-setdoc.json) — `setDoc({createdAt: serverTimestamp(), count: 5, tags: ['a']})` followed by `getDoc` returns `createdAt` as a `Timestamp` instance (constructor name `Timestamp`, has `seconds` + `nanoseconds`), `count === 5` (number), `tags === ['a']`. Sentinels resolve server-side and the follow-up read sees concrete values, not the sentinel placeholders. | 30 |\n| setDoc(ref, data[, options]) | | Converter (via `withConverter`) runs `toFirestore(data)` before the write | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L24) (\"withConverter on a DocumentReference round-trips\") | 31 |\n| setDoc(ref, data[, options]) | | Rules-denied `setDoc` throws a `FirebaseError` with code `permission-denied`, matching production | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L24) (\"getDoc denies when rules reject\"), `playground:rules-data-validation`, oracle: [`packages/conformance/observations/firestore/firestore-write-denied-error-code.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-write-denied-error-code.json) (prod `setDoc` on a denied path throws a `FirebaseError` with `.code === 'permission-denied'`, `.message === '7 PERMISSION_DENIED: Missing or insufficient permissions.'`, `instanceof Error`); `oracle:firestore#32` pins the sandbox error shape (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L557)) | 32 |\n\n## `updateDoc(ref, data)` — partial write\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| updateDoc(ref, data) | | Merges `data` into the existing doc; missing fields preserved. **Top-level keys are dot-separated FieldPaths** (FS-B5): `updateDoc({'a.b': 2})` sets the nested leaf `a.b` (preserving `a.c`), not a literal `\"a.b\"` key; a single-segment map value replaces that field wholesale; `deleteField()` at a dotted path removes the nested leaf. | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L25), [`unit:admin-compat/field-path-merge.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/admin-compat/field-path-merge.test.ts#L2) (FS-B5 dot-path nested write + delete; verified failing pre-fix) (Structured evidence: [`firestore-updatedoc-dotpath-fieldpath`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-updatedoc-dotpath-fieldpath.json)) | 33 |\n| updateDoc(ref, data) | | Throws `not-found` (sandbox) / `FirebaseError('not-found')` (prod) on missing doc | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L25) (implicit in writes-fail-on-missing tests), oracle: [`packages/conformance/observations/firestore/firestore-updatedoc-missing-error.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-updatedoc-missing-error.json) (prod throws `FirebaseError` with `code: 'not-found'`, message `\"5 NOT_FOUND: No document to update: …\"`) | 34 |\n| updateDoc(ref, data) | | Does NOT run a converter — partial updates don't have a typed home (matches JS SDK) | ✓ | (documented in `withConverter` block) | 35 |\n| updateDoc(ref, data) | | Sentinels resolve mid-update (`increment(1)` against an existing numeric field, etc.) | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L25), `playground:firestore-sentinels`, oracle: [`packages/conformance/observations/firestore/firestore-row-36-sentinels-in-updatedoc.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-36-sentinels-in-updatedoc.json) — after `setDoc({count: 5, tags: ['a'], oldField: 'keep-then-remove'})` then `updateDoc({count: increment(3), tags: arrayUnion('b'), oldField: deleteField()})`, the follow-up `getDoc` returns `count: 8`, `tags: ['a', 'b']`, and `oldField` absent from the doc (the deleteField sentinel actually removes the key). All three sentinels apply in one mid-update commit. | 36 |\n| updateDoc(ref, data) | | Sandbox-live: each call re-evaluates auth (alice → bob between writes uses bob's auth) | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L31) (\"updateDoc re-evaluates auth per call\") | 37 |\n\n## `deleteDoc(ref)`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| deleteDoc(ref) | | Removes the document; subsequent `getDoc` returns `exists()===false` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L26) | 38 |\n| deleteDoc(ref) | | Idempotent — `deleteDoc` on missing doc resolves without throwing (matches JS SDK) | ✓ | `unit:deletedoc-missing.test.ts`, [`playground:firestore-deletedoc-missing`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-deletedoc-missing.json), oracle: `packages/conformance/observations/firestore/firestore-deletedoc-missing.json` | 39 |\n| deleteDoc(ref) | | Rules-denied delete throws `permission-denied` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L26) (rules-reject branch), oracle: [`packages/conformance/observations/firestore/firestore-delete-denied-error-code.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-delete-denied-error-code.json) (prod `deleteDoc` on a denied path throws a `FirebaseError` with `.code === 'permission-denied'`, `.message === '7 PERMISSION_DENIED: Missing or insufficient permissions.'`, `instanceof Error`) | 40 |\n\n## `addDoc(coll, data)` — auto-id write\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| addDoc(coll, data) | | Returns a tagged `DocumentReference` with auto-id | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L27) | 41 |\n| addDoc(coll, data) | | Returned ref is usable in subsequent ops (`getDoc`, `setDoc`, `onSnapshot`) | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L27), oracle: [`packages/conformance/observations/firestore/firestore-row-42-adddoc-returned-ref-usable.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-42-adddoc-returned-ref-usable.json) — `addDoc(coll, {v:1})` returned a ref whose `.id` is a 20-char auto-id; `getDoc(ref)` returned `{v:1}` (round-trip), `setDoc(ref, {v:2})` overwrote without error, follow-up `getDoc` returned `{v:2}`, and `onSnapshot(ref, cb)` registered cleanly and fired once with `{exists:true, v:2}`. All four follow-up ops succeed on the returned ref without re-tagging. | 42 |\n| addDoc(coll, data) | | Sandbox-live: returned ref is a *live* ref (rebuild closure recorded) so follow-ups re-resolve auth | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L32) (\"addDoc result is a tagged live ref\") | 43 |\n| addDoc(coll, data) | | Converter on the parent collection propagates onto the returned ref | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L27) (\"addDoc through a converted collection\") | 44 |\n| addDoc(coll, data) | | Auto-id format — production and sandbox mint 20-character IDs from `[A-Za-z0-9]` | ✓ | Oracle-locked: [`packages/conformance/observations/firestore/firestore-adddoc-autoid-format.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-adddoc-autoid-format.json) records production's 20-character alphanumeric shape; [`packages/pyric/test/firestore/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L185) generates 25 sandbox IDs and checks both invariants against that observation. | 45 |\n\n## `withConverter` — typed refs\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| withConverter | | `withConverter(docRef, converter)` returns a shell that runs `toFirestore` on writes, `fromFirestore` on reads | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L51) | 46 |\n| withConverter | | `withConverter(collRef, converter)` propagates onto `doc(typedColl, id)` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L51) | 47 |\n| withConverter | | `withConverter(collRef, converter)` propagates through `query(typedColl, …)` + `getDocs()` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L51) | 48 |\n| withConverter | | `withConverter(ref, null)` strips the converter, returns the underlying untyped view | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L51) | 49 |\n| withConverter | | Original untyped ref keeps its identity after `withConverter(ref, c)` (two views, one path) | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L51) | 50 |\n| withConverter | | `setDoc` through a converted ref invokes `toFirestore(data)` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L51) | 51 |\n| withConverter | | `getDoc` through a converted ref invokes `fromFirestore(snapshot)`; `.data()` returns the typed model | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L51) | 52 |\n| withConverter | | `updateDoc` through a converted ref does NOT invoke the converter | ✓ | (documented constraint; matches JS SDK) | 53 |\n\n## Query construction — `query` / `where` / `or` / `and` / `orderBy` / `limit`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Query construction | | `query(coll, where(…), orderBy(…), limit(…))` composes constraints in order | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L29), `playground:firestore-query` | 54 |\n| Query construction | | `where(field, op, value)` — all 10 ops: `<`, `<=`, `==`, `>=`, `>`, `!=`, `in`, `not-in`, `array-contains`, `array-contains-any` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L30) (canonical query test); membership ops + OR/`in`/`array-contains` composites: [`unit:upstream-query-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/upstream-query-probes.test.ts#L9) | 55 |\n| Query construction | | **Existence + null filter guards (FS-B7)** — a doc missing the filter field is never returned by `==`/`<`/`<=`/`>`/`>=`/`in`/`!=`/`not-in`; `!=` and `not-in` additionally exclude null-valued docs and require the field to exist; a `null` in a `not-in` operand list matches nothing. Pre-FS-B7, `!=`/`not-in` matched missing-field and null docs. | ✓ | [`unit:admin-compat/inequality-existence-guards.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/admin-compat/inequality-existence-guards.test.ts#L2) (verified failing pre-fix) | 55a |\n| Query construction | | `or(...)` composite — at least one sub-filter matches | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L28) (\"or() matches docs where any sub-filter matches\"), oracle: [`packages/conformance/observations/firestore/firestore-or-composite.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-or-composite.json) (4 seeded docs; `or(where('x','==',1), where('y','==',2))` returned the exact union `{match-both, match-x, match-y}` — no implicit index required against cloud Firestore) | 56 |\n| Query construction | | `and(...)` composite — every sub-filter matches | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L2) (\"and() requires every sub-filter\"), oracle: [`packages/conformance/observations/firestore/firestore-and-composite.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-and-composite.json) (4 seeded docs; `and(where('x','==',1), where('y','==',2))` returned only the intersection `{match-both}`) | 57 |\n| Query construction | | Nested `or` / `and` — full composite tree | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L2) (\"nested or/and — the canonical composite pattern\"), oracle: [`packages/conformance/observations/firestore/firestore-nested-or-and-composite.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-nested-or-and-composite.json) (6 seeded docs; `or(and(where('x','==',1), where('y','==',2)), where('z','==',3))` returned `{inner-and-match, outer-z-match, both-branches}` — exact boolean union as predicted) | 58 |\n| Query construction | | `orderBy(field, 'asc'\\|'desc')` — direction parameter | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L31) | 59 |\n| Query construction | | **Canonical type-order comparison (FS-B3)** — orderBy + range filters compare by Firestore's canonical type order (`null < bool < number < timestamp < string < bytes < ref < geopoint < array < map`), then within-type; numbers sort numerically (not lexicographically), NaN sorts as the smallest number, and range filters (`<`/`<=`/`>`/`>=`) only match same-type values. Pre-FS-B3 the comparator fell back to `String(a).localeCompare(String(b))`. | ✓ | [`unit:firestore/sandbox/query-value-order.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox/query-value-order.test.ts#L2) (cross-type ranking, numeric sort, NaN, timestamps, arrays; verified failing pre-fix) | 59a |\n| Query construction | | **orderBy excludes missing-field docs (FS-B3)** — a doc lacking an orderBy field is omitted from the result (matches prod); pre-fix it was sorted in via `compareValues(undefined, …)`. | ✓ | [`unit:firestore/sandbox/query-value-order.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox/query-value-order.test.ts#L2) (\"excludes the missing-field doc\") | 59b |\n| Query construction | | **Implicit orderBy + `__name__` tiebreak (FS-B8)** — the query's sort is normalized to: explicit orderBy clauses, then an implicit order on each inequality-filtered field, then a final document-key (`__name__`) clause. Equal-valued docs sort deterministically by key; a `where('x','>',v)` with no explicit orderBy returns docs ordered by `x`. Mirrors `clones/.../core/query.ts:queryNormalizedOrderBy`. Pre-FS-B8 equal-valued docs were nondeterministic and inequality results came back in insertion order. | ✓ | [`unit:admin-compat/implicit-order-name.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/admin-compat/implicit-order-name.test.ts#L2) (key tiebreak, snapshot-cursor disambiguation, implicit inequality order; verified failing pre-fix) | 59c |\n| Query construction | | `limit(n)` — caps result count | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L32) | 60 |\n| Query construction | | `limitToLast(n)` returns the trailing n documents in an ordered result; without `orderBy`, it throws a `FirestoreError` with production's `unimplemented` code | ✓ | Oracle-locked by [`packages/conformance/observations/firestore/firestore-limittolast-preconditions.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-limittolast-preconditions.json): production's no-orderBy precondition throws code `unimplemented`, while ordered trailing-window semantics return `[\"b\"]`. `oracle:firestore#61` replays both claims. Cursor composition + descending: [`unit:upstream-query-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/upstream-query-probes.test.ts#L10); cursor/empty-snapshot preconditions: [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L560) + [`unit:admin-compat/cursors.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/admin-compat/cursors.test.ts#L198). (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L331)) | 61 |\n| Query construction | | Composite filters AND with other constraints — `query(coll, or(...), orderBy(...), limit(...))` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L29) | 62 |\n| Query construction | | Passing `orderBy` / `limit` into `or()` / `and()` → `TypeError` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L2) | 63 |\n| Query construction | | Zero-arg `or()` / `and()` → `TypeError` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L2) | 64 |\n| Query construction | | Chained queries re-tag for further constraints (`query(query(coll, where), orderBy)`) | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L29) (\"chained queries are taggable\") | 65 |\n| Query construction | | Index validation against `firestore.indexes.json` — sandbox uses `LocalEnvironment`'s lint pass; prod has its own server-side validation | ⚠ | divergence: sandbox can mis-pass a query that prod would reject at the server with `failed-precondition` if no index exists | 66 |\n\n## Cursor pagination — `startAt` / `startAfter` / `endAt` / `endBefore`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Cursor pagination | | `startAt(...values)` — inclusive value cursor (one positional per `orderBy` clause) | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L577), oracle: [`packages/conformance/observations/firestore/firestore-cursor-startat-inclusive.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-cursor-startat-inclusive.json) (5 seeded docs at pos=[1..5]; `query(c, orderBy('pos'), startAt(3))` returned exactly `[pos-3, pos-4, pos-5]` — the cursor doc IS included) | 67 |\n| Cursor pagination | | `startAfter(...values)` — exclusive value cursor | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L586), oracle: [`packages/conformance/observations/firestore/firestore-cursor-startafter-exclusive.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-cursor-startafter-exclusive.json) (5 seeded docs at pos=[1..5]; `query(c, orderBy('pos'), startAfter(3))` returned exactly `[pos-4, pos-5]` — the cursor doc is EXCLUDED) | 68 |\n| Cursor pagination | | `endAt(...values)` — inclusive end cursor | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L595), oracle: [`packages/conformance/observations/firestore/firestore-cursor-endat-inclusive.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-cursor-endat-inclusive.json) (5 seeded docs at pos=[1..5]; `query(c, orderBy('pos'), endAt(3))` returned exactly `[pos-1, pos-2, pos-3]` — the cursor doc IS included) | 69 |\n| Cursor pagination | | `endBefore(...values)` — exclusive end cursor | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L604), oracle: [`packages/conformance/observations/firestore/firestore-cursor-endbefore-exclusive.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-cursor-endbefore-exclusive.json) (5 seeded docs at pos=[1..5]; `query(c, orderBy('pos'), endBefore(3))` returned exactly `[pos-1, pos-2]` — the cursor doc is EXCLUDED) | 70 |\n| Cursor pagination | | `startAt(snapshot)` overload — extracts orderBy field values from the snapshot, positioning against the NORMALIZED orderBy (implicit `__name__`), so it disambiguates equal-valued docs and is **legal without an explicit orderBy** (FS-B8). A VALUE cursor with more values than explicit orderBy clauses throws `invalid-argument` (\"Too many arguments\"). | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L577), [`unit:admin-compat/implicit-order-name.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/admin-compat/implicit-order-name.test.ts#L15) (snapshot cursor w/o orderBy), [`unit:admin-compat/cursors.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/admin-compat/cursors.test.ts#L6) (value-cursor too-many-args throws with `.code`) (Structured evidence: [`firestore-startat-snapshot-implicit-name`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-startat-snapshot-implicit-name.json)) | 71 |\n| Cursor pagination | | `endAt(snapshot)` overload | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L595) (\"endAt(snapshot) trims to-and-including the anchor\") | 72 |\n| Cursor pagination | | `startAfter + limit` — canonical pagination pattern | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L586) | 73 |\n\n## Aggregates — `getCountFromServer` / `getAggregateFromServer` / `count` / `sum` / `average`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Aggregates | | `getCountFromServer(query)` returns `{ data: () => ({ count: N }) }` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L71); collectionGroup: [`unit:upstream-write-aggregate-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/upstream-write-aggregate-probes.test.ts#L21) | 74 |\n| Aggregates | | `getCountFromServer` honors `where` filters | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L791) | 75 |\n| Aggregates | | `getAggregateFromServer(query, spec)` returns `{ data: () => Record<alias, number\\|null> }` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L807); collectionGroup + nested paths: [`unit:upstream-write-aggregate-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/upstream-write-aggregate-probes.test.ts#L22) | 76 |\n| Aggregates | | `count()` / `sum(field)` / `average(field)` compose under one spec — `field` may be a dotted nested path | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L7); nested `sum('metadata.pages')`: [`unit:upstream-write-aggregate-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/upstream-write-aggregate-probes.test.ts#L23) | 77 |\n| Aggregates | | `average` returns `null` on empty input (matches JS SDK) | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L807) | 78 |\n| Aggregates | | Aggregates count documents server-side without paying read cost per doc in prod; sandbox computes locally (no cost model) | ⚠ | divergence: cost behavior differs, observable shape identical. Oracle-locked: [`packages/conformance/observations/firestore/firestore-count-aggregate-shape.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-count-aggregate-shape.json) — `getCountFromServer().data()` returns `{ count: <number> }` (single key, no other fields). Empty query returns `count: 0` (not `null`/`undefined`); seeded 3 docs returns `count: 3`; filtered query honors the `where` constraint (`count: 2`). | 79 |\n\n## `onSnapshot(refOrQuery, …)` — listeners\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| onSnapshot(refOrQuery, …) | | `onSnapshot(docRef, cb)` fires the initial snapshot **asynchronously** — never synchronously during the registering call. Prod empirically lands after a `setTimeout(0)` macrotask (the fire travels the network listener channel); the sandbox defers through its delivery scheduler (microtask). The matrix contract is \"asynchronous, never during register\", not \"exactly the next microtask\" | ✓ | Aligned via the listener delivery scheduler (`src/sandbox/firestore/local-environment.ts`): the initial fire is enqueued and delivered on a microtask, never during register — closing the divergence this row previously documented (the sandbox used to fire synchronously during registration; the sync-body tests were migrated to the flush/await idiom). Machine-checked against [`packages/conformance/observations/firestore/firestore-row-80-onsnapshot-fires-initial.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-80-onsnapshot-fires-initial.json) (`firstFireSyncDuringRegister: false`, fire count + contents) in `oracle-conformance.test.ts`; also [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L330), `playground:firestore-onsnapshot` (bundled) + `playground:firestore-row-80-onsnapshot-fires-initial` (one-claim). | 80 |\n| onSnapshot(refOrQuery, …) | | `onSnapshot(query, cb)` fires on collection writes; `QuerySnapshot.docChanges()` reports `added` / `modified` / `removed` with `oldIndex` / `newIndex` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L34), oracle: [`packages/conformance/observations/firestore/firestore-row-81-onsnapshot-query-fires-on-write.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-81-onsnapshot-query-fires-on-write.json) — listener on `query(coll)` saw 1 initial fire (empty, `size:0`), then one fire per write: `addDoc` → `size:1`, `setDoc(coll, 'known-id')` → `size:2`, `deleteDoc(addedRef)` → `size:1`. Total 4 fires, each reflecting the current collection state. Every collection-level write produces a distinct fire. (Note: this oracle used a *filterless* `query(coll)`, which masked FS-B2 — see row 81a.) Modular `docChanges` indexes: [`unit:upstream-transform-txn-listener-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/upstream-transform-txn-listener-probes.test.ts#L12) | 81 |\n| onSnapshot(refOrQuery, …) | | **Filtered listeners honor `where` / `orderBy` / `limit` (FS-B2)** — `onSnapshot(query(coll, where(…), orderBy(…), limit(…)), cb)` delivers the same membership as `getDocs(sameQuery)`: non-matching docs are excluded on the initial fire and on writes; ordering + limit are applied. Pre-FS-B2 the `SnapshotTarget` dropped all constraints and delivered the whole collection. | ✓ | [`unit:onsnapshot-query-constraints.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/onsnapshot-query-constraints.test.ts#L2) (filtered/ordered/limited listeners; verified failing pre-fix) | 81a |\n| onSnapshot(refOrQuery, …) | | **Listener `.data()` matches `getDoc` shape (FS-B10)** — the `onSnapshot` doc + query snapshot path runs the same read-path translation as `getDoc`/`getDocs`, so `snap.data().createdAt` is a compat `Timestamp` (`{seconds, nanoseconds}`), not the rules-internal wrapper (`{seconds, nanos}` + `typeName`, no `nanoseconds`). Pre-FS-B10 a listener leaked the internal shape while the single-doc read returned the compat shape. | ✓ | [`unit:simulator/listener-read-translation.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/simulator/listener-read-translation.test.ts#L2) (doc + query listener Timestamp shape; verified failing pre-fix) | 81b |\n| onSnapshot(refOrQuery, …) | | Initial fire for a missing doc has `exists() === false` and `data() === undefined` | ✓ | `playground:firestore-onsnapshot` (bundled) + [`playground:firestore-row-82-onsnapshot-missing-initial`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-82-onsnapshot-missing-initial.json) (one-claim), oracle: `packages/conformance/observations/firestore/firestore-row-82-onsnapshot-missing-initial.json` — single initial fire with `snap.exists() === false`, `snap.data() === undefined`, `hasPendingWrites: false`, `fromCache: false`. The missing-doc fire is server-confirmed, not a cache speculation. | 82 |\n| onSnapshot(refOrQuery, …) | | Returned `Unsubscribe` stops further fires | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L34), oracle: [`packages/conformance/observations/firestore/firestore-row-83-unsubscribe-stops-fires.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-83-unsubscribe-stops-fires.json) — pre-unsubscribe write fired the listener (initial fire + write fire = 2 fires); after `unsub()`, a subsequent `setDoc` produced 0 additional fires (`postUnsubFireCount: 0`). Unsubscribe is durable; no fires arrive on the released callback after a 1.5s settle window. | 83 |\n| onSnapshot(refOrQuery, …) | | Observer object form `{next, error, complete}` works alongside the function form. **Partial observers are accepted — `{ error: fn }` with no `next` registers and routes denials to `error` (FS-B14, `isPartialObserver` semantics from upstream `api/observer.ts`); pre-fix it was misrouted as `SnapshotListenOptions` and threw \"missing next handler\".** | ✓ | oracle: [`packages/conformance/observations/firestore/firestore-row-84-observer-object-form.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-84-observer-object-form.json) — registered two listeners on the same doc: one as a bare function `(snap) => …`, one as `{next, error, complete}`. Both fired once on initial (`{v:0}`) and again after a write (`{v:1}`), capturing identical data. `error` never fired (no rule denial), `complete` never fired on `unsub()` (Firebase treats unsubscribe as a teardown, not a \"complete\" signal — the observer's `complete` callback is reserved for terminal stream end, which `onSnapshot` does not produce). The two registration shapes are interchangeable for fire dispatch. [`unit:onsnapshot-observer-discriminator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/onsnapshot-observer-discriminator.test.ts#L2) (error-only observer; verified failing pre-fix) | 84 |\n| onSnapshot(refOrQuery, …) | | `SnapshotListenOptions.includeMetadataChanges` — one write yields the pending-write local echo (`hasPendingWrites: true`) then, for metadata listeners, the settled ack fire: default listener 2 fires, metadata listener 3 | ✓ | Aligned via the listener delivery scheduler (`src/sandbox/firestore/local-environment.ts` + `snapshot-listeners.ts`): the write echo carries `hasPendingWrites: true` and `includeMetadataChanges` listeners receive the settled metadata-only ack, reproducing prod's recorded 2/3-fire sequences exactly. Machine-checked against [`packages/conformance/observations/firestore/firestore-include-metadata-changes.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-include-metadata-changes.json) in `oracle-conformance.test.ts` (fire counts and per-fire `hasPendingWrites` sequence asserted from the capture) | 85 |\n| onSnapshot(refOrQuery, …) | | Snapshot's `.ref` / `.docs[i].ref` are tagged so consumer code can pass them to follow-up ops | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L34) | 86 |\n| onSnapshot(refOrQuery, …) | | Sandbox-live: listener registered as alice keeps emitting alice's view after `setUser → bob` (identity frozen at subscribe) | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L35) (\"listener registered as alice keeps emitting alice's view\") | 87 |\n| onSnapshot(refOrQuery, …) | | Sandbox-live: listener registered as anonymous keeps firing after sign-in (anonymous → signed-in identity persists per listener) | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L35) (\"listener registered as anonymous on /public keeps firing after sign-in\") | 88 |\n| onSnapshot(refOrQuery, …) | | Snapshot's ref is usable in follow-up ops under the new user (the ref is live, the listener identity is frozen — distinct) | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L35) (\"snapshot ref is usable in subsequent ops under the new user\"), oracle: [`packages/conformance/observations/firestore/firestore-row-89-snapshot-ref-usable.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-89-snapshot-ref-usable.json) — captured `snap.ref` from a docRef listener's first fire and `snap.docs[0].ref` from a query listener's first fire; both refs round-trip via `getDoc` (returning the same data) and `setDoc` (writes succeed and a follow-up `getDoc` confirms the new payload). `snap.ref.path` equals the original `doc(coll, id).path`. Both snap-ref shapes are first-class refs in prod, matching sandbox's tagged-ref guarantee. | 89 |\n| onSnapshot(refOrQuery, …) | | Preview tree mounts the user's component exactly once per session load — no observer subscriptions leak across parallel `AppPreview` instances. Root cause: `PlaygroundPage` rendered both `WorkspacePanel`'s and the mobile `AppPanel`'s `AppPreview` unconditionally (the latter `md:hidden` on desktop but still mounted), producing two live preview trees subscribing in parallel. Fixed by gating `AppPanel` on `useIsMobile() && mobileTab === 'app'`. | ✓ | `playground:preview-single-mount` | 90 |\n\n## `runTransaction(db, fn)`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| runTransaction(db, fn) | | Atomic read-write — all reads in `fn` see a consistent snapshot, writes commit together | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L36), `playground:firestore-transaction`; get-missing/deleted + empty txn + nested update: [`unit:upstream-transform-txn-listener-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/upstream-transform-txn-listener-probes.test.ts#L27) | 91 |\n| runTransaction(db, fn) | | Identity is frozen at `runTransaction` start — mid-transaction `setUser` does NOT re-auth in-flight reads | ✓ | [`unit:firestore/sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L36) changes the live session from alice to bob inside the callback; the transaction still reads and commits under alice's captured identity. | 92 |\n| runTransaction(db, fn) | | Retry behavior — a read-document conflict reruns the callback against fresh data; `maxAttempts` bounds persistent contention | ✓ | Oracle: [`packages/conformance/observations/firestore/firestore-transaction-contention-retries.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-transaction-contention-retries.json) — two production Web SDK clients force one retry (`[0, 40]`, final `42`) and persistent contention with `maxAttempts: 2` runs exactly twice then throws `failed-precondition`. [`packages/pyric/test/firestore/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L603) replays both cases through the sandbox. (Structured evidence: [`sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L36)) | 93 |\n| runTransaction(db, fn) | | Throws `FirebaseError('permission-denied')` on rule denial inside the transaction (the inner write's denial — not a generic `aborted`). Sandbox throws `FirestoreCompatError` with the same `code: 'permission-denied'` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L36) (writes-reject branch), oracle: [`packages/conformance/observations/firestore/firestore-transaction-rules-denied-error.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-transaction-rules-denied-error.json) (prod throws `FirebaseError` with `code: 'permission-denied'`, NOT `aborted`; the inner callback ran once and the rules-rejected write surfaces as a regular permission-denied at commit) | 94 |\n\n## `writeBatch(db)`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| writeBatch(db) | | `batch.set` / `batch.update` / `batch.delete` queue mutations | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L37), `playground:firestore-batch` | 95 |\n| writeBatch(db) | | `batch.commit()` applies all queued writes atomically — success path commits all queued mutations together; failure path (one write violating rules) rejects the **whole** batch with no partial application | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L37), oracle: [`packages/conformance/observations/firestore/firestore-row-96-batch-commit-atomic.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-96-batch-commit-atomic.json) — success path: a batch with `set` (fresh doc), `update` (existing doc), and `delete` (existing doc) all land in a single commit (`allApplied: true`). Failure path: a batch with one write targeting a path **outside** `pyric_oracle/*` rejects with `code: 'permission-denied'` and leaves the would-have-set doc absent and the would-have-updated doc at its original value (`noPartialApply: true`) — atomicity verified end-to-end. | 96 |\n| writeBatch(db) | | Batch is tagged on construction and remains bound to the sandbox owner that created it | ✓ | [`unit:firestore/sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L37) constructs a batch from sandbox A with a reference from sandbox B and proves the commit remains isolated to A. | 97 |\n| writeBatch(db) | | Batch identity is frozen at construction (per current implementation) | ✓ | [`unit:firestore/sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L37) constructs under alice, switches the live session to bob, and proves commit still succeeds under alice's captured identity. | 98 |\n\n## Sentinels — `serverTimestamp` / `increment` / `arrayUnion` / `arrayRemove` / `deleteField` / `FieldValue` / `Timestamp`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Sentinels | | `serverTimestamp()` resolves to a `Timestamp` after the write commits | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L40), `playground:firestore-sentinels` (bundled) + `playground:firestore-row-99-servertimestamp-resolves` (one-claim), oracle: [`packages/conformance/observations/firestore/firestore-row-99-servertimestamp-resolves-to-timestamp.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-99-servertimestamp-resolves-to-timestamp.json) — `setDoc({at: serverTimestamp()})` then `getDoc` yields `at instanceof Timestamp === true`, `constructor.name === 'Timestamp'`, with both `.seconds` (number) and `.nanoseconds` (number) present. | 99 |\n| Sentinels | | `increment(n)` atomically bumps a numeric field; `null`/missing field starts from 0 | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L41), `playground:firestore-sentinels` (bundled) + [`playground:firestore-row-100-increment-bumps-numeric`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-100-increment-bumps-numeric.json) (one-claim), oracle: `packages/conformance/observations/firestore/firestore-row-100-increment-bumps-numeric.json` — `setDoc` with no `count` field then `updateDoc({count: increment(5)})` yields `count === 5` (starts from 0). Follow-up `increment(3)` → 8, then `increment(-2)` → 6 (negative deltas apply, increments accumulate). Merge-create + int↔double + batch-across-docs: [`unit:upstream-transform-txn-listener-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/upstream-transform-txn-listener-probes.test.ts#L5) | 100 |\n| Sentinels | | `arrayUnion(...values)` de-dupes against existing members **and** against duplicate args within the same call | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L42), `playground:firestore-sentinels` (bundled) + [`playground:firestore-row-101-arrayunion-dedupes`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-101-arrayunion-dedupes.json) (one-claim), oracle: `packages/conformance/observations/firestore/firestore-row-101-arrayunion-dedupes.json` — `setDoc({tags: ['a','b']})` then `updateDoc({tags: arrayUnion('b','c')})` yields `['a','b','c']` (single `b`, not double). Follow-up `updateDoc({tags: arrayUnion('d','d','a')})` yields `['a','b','c','d']` — both inline duplicate args and existing-member duplicates are de-duped. Merge-path + object members: [`unit:upstream-transform-txn-listener-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/upstream-transform-txn-listener-probes.test.ts#L4) | 101 |\n| Sentinels | | `arrayRemove(...values)` strips matching members; values not present in the array are silent no-ops | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L43), `playground:firestore-sentinels` (bundled) + [`playground:firestore-row-102-arrayremove-strips`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-102-arrayremove-strips.json) (one-claim), oracle: `packages/conformance/observations/firestore/firestore-row-102-arrayremove-strips.json` — `setDoc({tags: ['a','b','c']})` then `updateDoc({tags: arrayRemove('b','d')})` yields `['a','c']`: `'b'` removed, `'d'` (absent) was a silent no-op (no error). Merge-path: [`unit:upstream-transform-txn-listener-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/upstream-transform-txn-listener-probes.test.ts#L30) | 102 |\n| Sentinels | | `deleteField()` removes a field on update — the field is fully absent from the returned data, not merely undefined-valued. Legal at the top level or via a **dot-path** (`{'a.b': deleteField()}` removes the nested leaf — FS-B5). **Nested inside a map literal (`{a: {b: deleteField()}}`) it throws `invalid-argument` (FS-B13)** instead of destroying the sibling map. | ✓ | `playground:firestore-sentinels` (bundled) + [`playground:firestore-row-103-deletefield-removes-field`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-103-deletefield-removes-field.json) (one-claim), oracle: `packages/conformance/observations/firestore/firestore-row-103-deletefield-removes-field.json` — `setDoc({keep:1, remove:2})` then `updateDoc({remove: deleteField()})` yields a doc whose `data()` has keys `['keep']` only, `keep === 1` preserved; [`unit:admin-compat/nested-delete-field.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/admin-compat/nested-delete-field.test.ts#L2) (nested → invalid-argument; dot-path + top-level still valid; verified failing pre-fix) | 103 |\n| Sentinels | | `Timestamp` shape (`{seconds, nanoseconds}`) is identical between prod and sandbox — round-trips cleanly | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L40) | 104 |\n| Sentinels | | **`Timestamp` nanos normalization + value API (FS-B12)** — `fromMillis`/`fromDate`/`now` derive `nanoseconds` as `floor((ms - seconds*1000) * 1e6)` so it is always non-negative; `fromMillis(-500).toMillis()` round-trips to -500 (was -1500). The class ships `isEqual` / `toString` / `toJSON` / `valueOf`, mirroring `clones/.../lite-api/timestamp.ts`. | ✓ | [`unit:admin-compat/timestamp-api.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/admin-compat/timestamp-api.test.ts#L2) (negative-millis round-trip + value API; pre-fix lacked the methods and mis-normalized) | 104b |\n| Sentinels | | **Unified Timestamp storage (FS-B4)** — a `Timestamp` written directly via the modular SDK (`setDoc({createdAt: Timestamp.now()})`) is stored as the same rules-internal `Timestamp` that `serverTimestamp()`/`Date` resolve to. Pre-FS-B4 a user-written `Timestamp` was the compat class only (not a `RulesValue`), so `request.resource.data.createdAt is timestamp` returned **false** for it while a `serverTimestamp()` write passed the same rule, and the two paths stored two different classes. A write-boundary converter now normalizes both. | ✓ | [`unit:firestore/sandbox-converters/user-timestamp.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-converters/user-timestamp.test.ts#L2) (`is timestamp` passes for a user Timestamp; unified storage class; range-filter regression guard — verified failing pre-fix by removing the converter registration) | 104a |\n| Sentinels | | `FieldValue` re-exported from `pyric-admin` (alias of `ChainFieldValue`) | ✓ | type-only smoke | 105 |\n| Sentinels | | **Sentinel overwrite on type mismatch (FS-B11)** — `increment(n)` on a non-numeric (or absent) prior OVERWRITES using a base value of 0 (result `n`); `arrayUnion`/`arrayRemove` on a non-array prior coerce the base to `[]`. Pre-FS-B11 these threw and surfaced as `invalid-argument` denials. Mirrors `clones/.../model/transform_operation.ts` (`computeTransformOperationBaseValue`, `coercedFieldValuesArray`). | ✓ | [`unit:simulator/converters/fieldvalue.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/simulator/converters/fieldvalue.test.ts#L5) (FS-B11 overwrite block + flipped unit/integration/batch cases; verified failing pre-fix) | 105a |\n\n## Scalar types — `Bytes` / `GeoPoint` / `FieldPath` / `documentId`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Scalar types | | The sandbox mirror owns compatible scalar constructors — `Bytes.fromUint8Array(...)`, `new GeoPoint(lat, lng)`, `new FieldPath(...)`, and `documentId()` — without importing `firebase/firestore` | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L46) (constructibility + round trips), [`package-edge:package-dependencies.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/package-dependencies.test.ts#L4) | 106 |\n| Scalar types | | `documentId()` works in `where(documentId(), …)` / `orderBy(documentId())` against the sandbox — string ids, DocumentReference operands, ranges, and id sort | ✓ | [`unit:upstream-query-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/upstream-query-probes.test.ts#L9) (`documentId() filters + orderBy`); modular `where`/`orderBy` accept `FieldPath` | 107 |\n| Scalar types | | `FieldPath` (nested) works in queries against sandbox | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L870) | 108 |\n| Scalar types | | `Bytes` round-trip through the sandbox wire encoder — `Bytes` written via `setDoc` reads back as a `Bytes` instance with the same base64 representation | ✓ | [`unit:packages/pyric/test/sandbox/firestore/wire-encoder-bytes-geopoint.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/wire-encoder-bytes-geopoint.test.ts#L2) + [`unit:packages/pyric/test/firestore/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L46) (\"Bytes + GeoPoint round-trip\"), oracle: [`packages/conformance/observations/firestore/firestore-row-109-bytes-roundtrip.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-109-bytes-roundtrip.json) — `setDoc({payload: Bytes.fromUint8Array([1,2,3,4])})` then `getDoc` yields `payload instanceof Bytes === true`, `payload.constructor.name === 'Bytes'`, `payload.toBase64() === 'AQIDBA=='`, and `payload.toUint8Array()` returns `[1,2,3,4]` against blockingfun. The sandbox converter stores the rules `Bytes` wrapper; `pyric/firestore` finalizes reads into its locally owned `Bytes` class with the same observed methods and values. | 109 |\n| Scalar types | | `GeoPoint` round-trip through the sandbox wire encoder — `GeoPoint` written via `setDoc` reads back as a `GeoPoint` instance with the same latitude / longitude | ✓ | [`unit:packages/pyric/test/sandbox/firestore/wire-encoder-bytes-geopoint.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/wire-encoder-bytes-geopoint.test.ts#L2) + [`unit:packages/pyric/test/firestore/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L47) (\"Bytes + GeoPoint round-trip\"), oracle: [`packages/conformance/observations/firestore/firestore-row-110-geopoint-roundtrip.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-row-110-geopoint-roundtrip.json) — `setDoc({loc: new GeoPoint(37.7749, -122.4194)})` then `getDoc` yields `loc instanceof GeoPoint === true`, `loc.constructor.name === 'GeoPoint'`, `loc.latitude === 37.7749`, `loc.longitude === -122.4194` against blockingfun. Sandbox storage uses the rules `LatLng` wrapper; `pyric/firestore` finalizes reads into its locally owned `GeoPoint` class. | 110 |\n| Scalar types | | Vector value type (`vector()` + `VectorValue`) round-trip: a vector written via `setDoc` reads back as a `VectorValue` with the same components | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L48) (\"Bytes + GeoPoint + VectorValue round-trip\", top-level + nested). The locally owned `vector()` / `VectorValue` preserve Firebase's observable value shape; the sandbox converter stores the rules `Vector` wrapper and `pyric/firestore` finalizes reads back to `VectorValue`. Oracle observation to follow (cf. #109/#110). **CLIENT surface only:** the web SDK exposes `vector()` + `VectorValue` (read/write) but has NO `findNearest` and NO `FieldValue.vector`; vector SEARCH is admin/server-only (`firebase-admin` `Query`/`CollectionReference.findNearest` + `FieldValue.vector()`), out of scope for this client matrix; the admin surface is tracked in the design rationale. | 111 |\n\n## Equality helpers — `refEqual` / `queryEqual` / `snapshotEqual`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Equality helpers | | `refEqual(a, b)` — true when paths match under the same target | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L840) | 112 |\n| Equality helpers | | `refEqual` is `true` for cross-flavor sandbox vs sandbox-live refs at the same path | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L38) (\"refEqual returns true for live and frozen refs at the same path\") | 113 |\n| Equality helpers | | `refEqual` is `false` for refs at different paths | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L38) | 114 |\n| Equality helpers | | `refEqual(sandboxRef, foreignRef)` throws `TypeError` — references not created by this sandbox mirror are unrecognized | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L840) (foreign refs throw unrecognized-reference TypeError) | 115 |\n| Equality helpers | | `queryEqual(a, b)` structurally compares collection and collection-group scope, filter/order/limit/cursor constraint structure, converter identity, and construction-time Firestore value snapshots for both equality and execution without re-observing caller objects. Snapshots cover maps/arrays, scalar objects, references (including raw and converted `addDoc()` results), and vectors; normalize Date to Timestamp; preserve the distinction between -0 and 0; compare snapshot and explicit bounds for all four cursor overloads by value without invoking snapshot converters, including across live-target rebuilds; reject undefined, bigint, nested arrays for ordinary and array-membership operands, and recursively nested cross-database references during query construction; and preserve Firebase's nested-array allowance for `in`/`not-in` candidate lists. | ✓ | Oracle-locked by [`packages/conformance/observations/firestore/firestore-queryequal-structural.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-queryequal-structural.json): collection and collection-group scope, order sequence/direction, limits, composite filters, cursor values/inclusivity, converters, and Firestore operands all distinguish equal from changed queries as production does. The capture executes mutable maps, Timestamp, Bytes, GeoPoint, DocumentReference (including raw and converted `addDoc()` results), and Vector queries; proves Bytes/Vector source-array mutation cannot change existing or new queries; proves all four snapshot cursor overloads compare with explicit bounds and execute twice without invoking a consumer converter; and locks recursive foreign-reference rejection, Date/Timestamp normalization, and -0 handling. [`firestore-query-nested-array-validation.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-query-nested-array-validation.json) separately pins the production SDK's client-side rejection of nested arrays without implying a cloud round trip. The replay forces live-target identity rebuilds for every snapshot cursor overload. `oracle:firestore#116` replays every claimed fact. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L1047), [`equality.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/equality.test.ts#L14)) | 116 |\n| Equality helpers | | `snapshotEqual(a, b)` follows production snapshot state. A query's first one-shot read differs from the next identical read, then repeated settled reads (including an independently built equivalent query) compare structurally until query state changes and settles again. Listener snapshots compare query, documents, changes, and metadata. Document snapshots compare path, existence, Firestore-typed data, converter identity, and snapshot kind, so two gets of the same document are equal while a query-child snapshot and a direct get are distinct; scalar-shaped plain maps remain distinct from Timestamp, reference, GeoPoint, and Vector values. | ✓ | Oracle-locked by [`packages/conformance/observations/firestore/firestore-snapshotequal-structural.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-snapshotequal-structural.json): sequential and equivalent-query reads prove the initial-to-settled transition; a changed result restarts that transition; listener sensitivity cases vary query, documents/change history, and metadata-only state; direct document cases vary path, existence, data, converter, and snapshot kind; and four collision probes distinguish scalar-shaped maps from their Timestamp, reference, GeoPoint, and Vector counterparts. `oracle:firestore#117` replays every reachable fact. JSON reconstruction remains raw characterization only because Pyric does not expose that separate API. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L1454), [`equality.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/equality.test.ts#L16)) | 117 |\n| Equality helpers | | Cross-flavor `refEqual` via `QuerySnapshot.docs[i].ref` works | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L38) (\"cross-flavor refEqual via QuerySnapshot doc refs\") | 118 |\n\n## `connectFirestoreEmulator`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| connectFirestoreEmulator | | No-op on sandbox-target handles (the sandbox already IS a local emulator) | ✓ | [`unit:sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-target.test.ts#L861) | 119 |\n| connectFirestoreEmulator | | Production does not enter the mirror: inactive package resolution leaves Firebase's `connectFirestoreEmulator` implementation unchanged | ✓ | [`node-register:register-child.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/register/register-child.test.ts#L1) (inactive canonical Firestore is not rewritten) | 120 |\n| connectFirestoreEmulator | | The sandbox mirror accepts Firebase's `mockUserToken` option shape as an inert compatibility argument; production uses Firebase's untouched implementation | ✓ | type-only smoke | 121 |\n\n## Offline / persistence / network family\n\n## Offline / persistence / network family (continued)\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| enableIndexedDbPersistence | | Resolves before Firestore starts and rejects with `failed-precondition` after any operation has started the service | ✓ | Real Chromium: [`firestore-browser-lifecycle.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-browser-lifecycle.json); replayed through the public modular API by [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L653) and focused lifecycle coverage. (Structured evidence: [`persistence-network.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/persistence-network.test.ts#L7)) | 140 |\n| enableMultiTabIndexedDbPersistence | | Enables multi-client shared persistence before first use, permits two clients to opt in, and rejects initialization after a client has started with `failed-precondition` | ✓ | Real Chromium: [`firestore-browser-lifecycle.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-browser-lifecycle.json) exercises fresh, after-use, and two-client initialization; replayed through public APIs by [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L653) and focused persistence tests. (Structured evidence: [`persistence-network.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/persistence-network.test.ts#L14)) | 141 |\n| clearIndexedDbPersistence | | Maps to `Sandbox.clearPersistence()` — actually wipes the persisted blob (honest, not a no-op); already a no-op when persistence was never enabled | ✓ | [`unit:firestore/persistence-network.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/persistence-network.test.ts#L14) | 142 |\n| enableNetwork / disableNetwork | | Disabling network exposes local mutations with cache/pending-write metadata while holding write acknowledgement until network is re-enabled | ✓ | Real Chromium: [`firestore-browser-lifecycle.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-browser-lifecycle.json); replayed through the public modular API by [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L653) and focused network coverage. (Structured evidence: [`persistence-network.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/persistence-network.test.ts#L9)) | 143 |\n| waitForPendingWrites | | Waits for outstanding offline write acknowledgements and resolves when network re-enable drains them | ✓ | Real Chromium: [`firestore-browser-lifecycle.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-browser-lifecycle.json); replayed through the public modular API by [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L653) and focused pending-write coverage. (Structured evidence: [`persistence-network.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/persistence-network.test.ts#L11)) | 144 |\n| terminate | | Terminates only the selected Firestore service instance: held refs reject with `failed-precondition`, owned listeners stop, and sibling Firestore or Auth services remain usable | ✓ | Real Chromium: [`packages/conformance/observations/firestore/firestore-browser-lifecycle.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-browser-lifecycle.json) records `failed-precondition` on the terminated instance while Auth and a sibling Firestore instance continue. Replayed by [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L653); focused ownership coverage in [`terminate.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/terminate.test.ts#L2). | 152 |\n\n## Tier-1 cache-init + get-from-* family\n\n## Tier-1 cache-init + get-from-* family (continued)\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| initializeFirestore | | Delegates to sandbox `getFirestore(app)` and returns the same handle. Accepts the `settings` argument (so the explicit-init pattern doesn't crash at import) but no-ops the cache/network settings — persistence is always on | ⚠ settings accepted but cache/network settings are no-ops | [`unit:firestore/tier1-cache-init-align.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/tier1-cache-init-align.test.ts#L7) | 145 |\n| persistentLocalCache / memoryLocalCache / persistentSingleTabManager / persistentMultipleTabManager / memoryEagerGarbageCollector / memoryLruGarbageCollector | | Config token accepted, inert — each returns a small tagged object so identity/usage doesn't crash. Persistence is the sandbox default; there is no cache tier left to configure | ⚠ inert config tokens; no cache tier to configure | [`unit:firestore/tier1-cache-init-align.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/tier1-cache-init-align.test.ts#L8) | 146 |\n| getDocFromServer / getDocsFromServer | | Delegates to `getDoc` / `getDocs` in the sandbox mirror — the sandbox store IS the authoritative source, so there is no separate server round-trip to force and no observable divergence from the default read | ✓ | [`unit:firestore/tier1-cache-init-align.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/tier1-cache-init-align.test.ts#L12) | 147 |\n| getDocFromCache / getDocsFromCache | | Explicit cache reads distinguish cold and warm state: cold document reads reject with `unavailable`, cold queries return empty, and server/default reads populate subsequent cache reads | ✓ | Real Chromium: [`firestore-browser-lifecycle.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-browser-lifecycle.json) captures cold and warm document and query cache behavior; [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L653) replays it through public APIs. (Structured evidence: [`tier1-cache-init-align.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/tier1-cache-init-align.test.ts#L13)) | 148 |\n| setLogLevel | | Accepted no-op — the sandbox has no modular-SDK-style logger to wire a level into; it uses host-level `console` logging directly, gated by `pyric dev`'s own flags, not this call | ⚠ accepted no-op; no sandbox logger wired | [`unit:firestore/tier1-cache-init-align.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/tier1-cache-init-align.test.ts#L13) | 149 |\n| onSnapshotsInSync | | Emits an initial in-sync signal, batches another signal after snapshot listener delivery, and stops after unsubscribe | ✓ | Real Chromium: [`firestore-browser-lifecycle.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-browser-lifecycle.json) records initial sync, write snapshot, then sync; replayed by [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/oracle-conformance.test.ts#L653) and focused listener tests. (Structured evidence: [`tier1-cache-init-align.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/tier1-cache-init-align.test.ts#L14)) | 150 |\n| Runtime ES Classes / Constructor Tokens | | Exports ES classes and constructor tokens matching structural type handles for instanceof checks and prototype inheritance | ✓ | `unit:firestore/tier1-cache-init-align.test.ts` (Structured evidence: [`runtime-classes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/runtime-classes.test.ts#L5)) | 151 |\n| PersistentCacheIndexManager / Index auto-creation | | Config tokens and index manager controller accepted as honest inert runtime tokens without failing execution | ✓ | `unit:firestore/tier1-cache-init-align.test.ts` (Structured evidence: [`persistence-tokens.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/persistence-tokens.test.ts#L5)) | 157 |\n| loadBundle / namedQuery | | Decodes string/ArrayBuffer bundle payloads into local sandbox store and registers named queries | ✓ | `unit:firestore/tier1-cache-init-align.test.ts` (Structured evidence: [`bundles.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/bundles.test.ts#L5)) | 153 |\n| documentSnapshotFromJSON / querySnapshotFromJSON / onSnapshotResume | | Deserializes JSON snapshot objects into active Pyric snapshot wrappers and attaches resume listener | ✓ | `unit:firestore/tier1-cache-init-align.test.ts` (Structured evidence: [`ssr-snapshots.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/ssr-snapshots.test.ts#L5)) | 154 |\n| aggregateFieldEqual / aggregateQuerySnapshotEqual | | Compares aggregate descriptors and snapshot data for value equality | ✓ | `unit:firestore/tier1-cache-init-align.test.ts` (Structured evidence: [`runtime-classes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/runtime-classes.test.ts#L50)) | 155 |\n| ensureFirestoreConfigured / executeWrite | | Validates sandbox initialization and executes write callback within mutation evaluation pipeline | ✓ | `unit:firestore/tier1-cache-init-align.test.ts` (Structured evidence: [`writes-plumbing.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/writes-plumbing.test.ts#L5)) | 156 |\n\n## Rules engine (via `setRules` from `pyric/sandbox/firestore`)\n\nRules-engine behavior is technically `pyric-admin`'s `LocalEnvironment`,\nbut it's the most-tested surface for divergence — `request.auth`,\ncross-doc reads via `get()`, data validation. These rows pin the\nshape consumer code depends on.\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | `request.auth.uid` reads through to `sandbox.currentUser?.uid` on sandbox-live | ✓ | `playground:auth-anonymous`, `playground:rules-cross-doc-get` | 127 |\n| | | `request.auth == null` when sandbox.currentUser is null (anonymous path) | ✓ | [`unit:sandbox-live-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/sandbox-live-identity.test.ts#L23) (\"anonymous fallback\") | 128 |\n| | | Cross-doc `get(/databases/$(database)/documents/...)` in rules works under sandbox; `get()` of a **missing** doc ERRORS (guard with `exists()`), and `get(p).id` / `get(p).__name__` expose the doc identity (RULES-B8) | ✓ | `playground:rules-cross-doc-get`, [`unit:rules/simulator/evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/evaluator.test.ts#L140) (RULES-B8 block) | 129 |\n| | | `request.resource.data.<field>` field validation in rules works under sandbox; an **undefined** field read ERRORS (deny), it does NOT read as null (RULES-B2) — guard with `'f' in data` / `data.get('f', d)` | ✓ | `playground:rules-data-validation`, [`unit:rules/simulator/evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/evaluator.test.ts#L140) (RULES-B2 block) | 130 |\n| | | `resource.data.<field>` (existing doc on writes) works under sandbox; undefined-field reads ERROR (RULES-B2) | ✓ | `playground:rules-resource-data-field`, [`unit:rules/simulator/evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/evaluator.test.ts#L140) (RULES-B2 block) | 131 |\n| | | Custom claims in `request.auth.token.<claim>` | ✓ | `playground:rules-custom-claims` | 132 |\n| | | Tri-state error semantics: DOTTED field access of a missing key (`resource.data.typo`), access on null/undefined, undefined variables, and `get()`-of-missing ERROR → deny; `&&`/`\\|\\|` absorb operand errors **commutatively** (CEL: `error \\|\\| true` → true, `error && false` → false). NOTE: DYNAMIC index access `data[expr]` stays null-on-miss (the documented may-be-absent-lookup idiom; only dotted access is doc-confirmed to error). (RULES-B2/B3/B8) | ✓ | [`unit:rules/simulator/evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/evaluator.test.ts#L140) (RULES-B2 / RULES-B3 / RULES-B8 blocks) | 133 |\n| | | `matches()` is a **full-string** anchored RE2 test; `replace()`/`split()` take regexes (`replace` = all occurrences) (RULES-B4) | ✓ | [`unit:rules/simulator/evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/evaluator.test.ts#L140) (RULES-B4 block) | 134 |\n| | | No JS prototype-chain leakage: `'toString' in data` → false, `data.constructor` errors; `in`/`hasAll`/`get` use own keys only (RULES-B7) | ✓ | [`unit:rules/simulator/evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/evaluator.test.ts#L140) (RULES-B7 block) | 135 |\n| | | Type-strict operators: `+` requires matching operand types (`'a' + 1` errors; `[1]+[2]` concatenates); ordered compares (`< > <= >=`) error across types; list membership uses value equality; `is map` excludes MapDiff/Set (RULES-B6 partial / B9 / B12 partial) | ✓ | [`unit:rules/simulator/evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/evaluator.test.ts#L140) (RULES-B6 / B9 / B12 blocks) | 136 |\n| | | `FirestoreSet` VALUE equality: `diff.addedKeys() == [uid].toSet()` compares set contents (order-insensitive); `set == list` is false, not an error (RULES-B13). Pre-fix, ANY two sets compared EQUAL (generic-object deep-equals saw no enumerable keys) — a false-PERMISSIVE divergence found by joining validation | ✓ | [`unit:rules/simulator/set-equality.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/set-equality.test.ts#L6); live validation: 10/10 both engines | 136b |\n| | | `update` exposes `request.resource.data` / `getAfter()` as the existing doc **merged** with the payload via the `writeMode: { kind: 'update' }` path (the agent-facing `simulate()` opt-in); a sparse no-writeMode payload that drops a field now ERRORS on that field (RULES-B2) rather than silently reading null (RULES-B10) | ✓ | [`unit:rules/simulator/handler.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/handler.test.ts#L2) (RULES-B10 block) | 137 |\n| | | Int/float distinction (`1.5 is int`→false, `1 is float`→false, `1.0 is float`→true) + integer division (`10 / 4 == 2`) + int div/mod-by-zero ERRORS (RULES-B5); strict `int('12abc')`/`float('abc')`/`bool('false')`/`bool('yes')` parsing (RULES-B6 rest); `string(1.0)`→\"1.0\" (RULES-B12 rest) | ✓ | [`unit:rules/simulator/evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/evaluator.test.ts#L140) (RULES-B5 + \"RULES-B6 remainder\" blocks); [`unit:rules/simulator/handler.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/handler.test.ts#L1089) (\"RULES-B5 end-to-end\" block) | 138 |\n| | | Strict boolean control flow matches production: non-booleans in `&&`, `\\|\\|`, or a ternary condition error and deny. Firestore double payloads retain float identity in the simulator (fractional JSON numbers are revived directly; `{ __type:'float', value }` preserves an explicitly tagged double). On create, `resource == null` denies while `request.resource` exposes the incoming document. | ✓ | Production behavior is recorded by Firestore Rules rows `firestore-rules#188`, `firestore-rules#167`, and `firestore-rules#166`. Direct evaluator/handler tests guard strict operands, fractional-number revival, the explicit float tag, and create-time resource verdicts on the SDK-facing simulator path. (Structured evidence: [`evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/evaluator.test.ts#L140), [`handler.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/handler.test.ts#L1089)) | 138a |\n| | | Query-proof EVALUATION — the rules-side decision (\"rules are not filters\"): given a `list` rule + query constraints, decide provable-or-reject (a doc-dependent rule like `resource.data.visibility == 'public'` is provable ONLY with a matching `where('visibility','==','public')`; otherwise the whole query is rejected) (RULES-B11 rules-side) | ✓ | [`unit:rules/simulator/query-proof.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/query-proof.test.ts#L9) | 139 |\n| | | Query-proof ENFORCEMENT wiring — `silentReadCollection` + `readQueryCandidates` call `evaluateQueryProof` (via `sandbox/firestore/list-query-proof.ts`) instead of the per-doc silent-omission filter; structured `where`/`limit`/`orderBy` constraints are threaded from `QueryImpl.structuredConstraints()` through both the one-shot (`getDocs`/aggregate) and listener (`SnapshotTarget` applier `.structured`) paths, and `request.query.{limit,offset,orderBy}` is populated on list test cases (RULES-B11 cross-file) | ✓ | [`unit:firestore/query-proof-enforcement.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/query-proof-enforcement.test.ts#L29) (both paths; verified failing pre-fix); prover scope caveat: row 24c | 139a |\n\n## Current gaps\n\n### Documented divergences\n\nKnown differences between Pyric and production Firebase. Each remains tracked as a non-conforming row.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">getDocs(query)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Query-proof **prover scope is conservative, with full accounting and a fail-closed classifier** — user functions are inlined (nested helper-calling-helper and multi-parameter helpers included, matching production's rules-function inlining), and a <code>list</code> rule is provable only when its **entire** doc-dependence reduces to top-level AND-conjunct <code>resource.data.<field> == <literal></code> equalities (with <code>request.auth.uid</code> pinned to the caller) each discharged by a <code>where(field, '==', value)</code> filter. Doc-dependence is decided by a fail-closed classifier: an expression counts as doc-independent only when every node is positively recognized as such — any touch of the <code>resource</code> root in any syntactic form (<code>resource.data</code>, <code>resource['data']</code>, slices, <code>resource.id</code>, path-literal segments feeding <code>exists</code>/<code>get</code> lookups) and any unrecognized node shape classifies doc-dependent. Any doc-dependent conjunct that is not a discharged equality — disjunctions over doc data, inequality/range predicates (<code>resource.data.score > 10</code> + <code>where('score','>',10)</code>), <code>in</code> / negated-<code>in</code> membership checks, <code>get(key, default)</code>, <code>keys().hasOnly(...)</code>, <code>is</code> type checks, nested-path predicates, data-keyed document lookups — makes the whole query conservatively DENY, even when every equality conjunct is discharged; production's prover may allow some of these. Divergence is deny-only: mixed equality + non-equality shapes are rejected up front rather than trusted to a residual evaluation that absent-tolerant predicates could pass vacuously.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/simulator/query-proof.test.ts#L7\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:rules/simulator/query-proof.test.ts</code></a> (function-inlining provable cases; full-accounting rejections for mixed equality + <code>in</code>/<code>get</code>/<code>hasOnly</code>/range/<code>is</code> shapes in inline and helper form; fail-closed classifier probes: bracket-access <code>resource['data']</code>, <code>exists()</code> keyed by <code>resource.data</code> through path-literal segments, slice access, <code>resource.id</code>, and an unrecognized node shape), <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/query-proof-enforcement.test.ts#L19\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:firestore/query-proof-enforcement.test.ts</code></a> (seeded end-to-end denials of the demonstrated attacks: forbidden-field absence check in member and bracket form, banned-owner <code>exists()</code> lookup, slice conjunct — each returning zero documents)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">Query construction</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Index validation against <code>firestore.indexes.json</code> — sandbox uses <code>LocalEnvironment</code>'s lint pass; prod has its own server-side validation</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence: sandbox can mis-pass a query that prod would reject at the server with <code>failed-precondition</code> if no index exists</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">Aggregates</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Aggregates count documents server-side without paying read cost per doc in prod; sandbox computes locally (no cost model)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence: cost behavior differs, observable shape identical. Oracle-locked: <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore/firestore-count-aggregate-shape.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>packages/conformance/observations/firestore/firestore-count-aggregate-shape.json</code></a> — <code>getCountFromServer().data()</code> returns <code>{ count: <number> }</code> (single key, no other fields). Empty query returns <code>count: 0</code> (not <code>null</code>/<code>undefined</code>); seeded 3 docs returns <code>count: 3</code>; filtered query honors the <code>where</code> constraint (<code>count: 2</code>).</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">initializeFirestore</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Delegates to sandbox <code>getFirestore(app)</code> and returns the same handle. Accepts the <code>settings</code> argument (so the explicit-init pattern doesn't crash at import) but no-ops the cache/network settings — persistence is always on</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/tier1-cache-init-align.test.ts#L7\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:firestore/tier1-cache-init-align.test.ts</code></a></div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">persistentLocalCache / memoryLocalCache / persistentSingleTabManager / persistentMultipleTabManager / memoryEagerGarbageCollector / memoryLruGarbageCollector</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Config token accepted, inert — each returns a small tagged object so identity/usage doesn't crash. Persistence is the sandbox default; there is no cache tier left to configure</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/tier1-cache-init-align.test.ts#L8\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:firestore/tier1-cache-init-align.test.ts</code></a></div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">setLogLevel</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Accepted no-op — the sandbox has no modular-SDK-style logger to wire a level into; it uses host-level <code>console</code> logging directly, gated by <code>pyric dev</code>'s own flags, not this call</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/firestore/tier1-cache-init-align.test.ts#L13\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:firestore/tier1-cache-init-align.test.ts</code></a></div></div></details>\n</div>\n" }, { "slug": "database-compat", "label": "Realtime Database", "title": "pyric/database compatibility matrix", "markdown": "<!-- Generated from the conformance model (registry rows + surface contracts). Do not edit by hand; run bun run compat:generate. -->\n\n# `pyric/database` compatibility matrix\n\n<div class=\"compat-stat\">\n<p class=\"compat-stat-figure\">\n<span class=\"compat-stat-pct\">100%</span>\n<span class=\"compat-stat-label\">of the public API supported</span>\n</p>\n<div class=\"compat-meters\">\n<span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 100%\"></span></span>\n</div>\n<p class=\"compat-stat-denom\">59 of 59 public API</p>\n</div>\n\n\n## Status legend\n\n| Status | Meaning |\n|---|---|\n| ✓ | **Conforming** — observable behavior matches Firebase, locked by a passing probe |\n| ⚠ | **Diverged (documented)** — intentional difference with a written reason |\n\n## Public API\n\n`pyric/database` mirrors the public `firebase/database` API in the Pyric sandbox. The rows below compare its observable behaviour with Firebase.\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | `getDatabase(ctx)` builds a sandbox-target `Database`; frozen `ctx.auth` baked in | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L11) (\"getDatabase(ctx) returns a tagged Database handle\") | M1 |\n| | | `getDatabase(sandbox)` builds a sandbox-live target; reads `sandbox.currentUser` per op | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L11) (\"reads sandbox.currentUser at op time, not at getDatabase time\") | M2 |\n| | | `ref(db, path?)` returns a path-tagged `DatabaseReference`; default is root | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L12) (\"ref(db) returns a root ref\" + \"ref(db, ...) returns a path ref\") | M4 |\n| | | `child(ref, 'sub/path')` composes paths; result inherits the parent's target | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L12) (\"child(ref, 'sub') composes paths\") | M5 |\n| | | `ref.parent` returns the parent ref; `root.parent === null` | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L12) (\"ref.parent returns the parent ref; root.parent is null\") | M6 |\n| | | `ref.root` returns the root ref of the same target | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L12) (\"ref.root returns the root ref\") | M7 |\n| | | `get(ref)` returns a `DataSnapshot`-shaped object with `val()`, `exists()`, `key`, `child()`, `hasChildren()`, `size`, and `toJSON()`; the modular Web surface does not expose `numChildren()`. | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L2) (snapshot shape tests) | M8 |\n| | | `get` on an absent path resolves to `{ val: null, exists: false }` (matches `DataSnapshot.val()` contract) | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L2) (\"reads return null for an absent path\") | M9 |\n| | | `set(ref, value)` replaces the value at the path | ✓ | Sandbox aligned: [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L119) (\"round-trips a primitive value\" + \"round-trips nested objects\"); matches oracle observation [`packages/conformance/observations/rtdb/rtdb-set-then-get-roundtrip.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-set-then-get-roundtrip.json) (prod observation blocked on rules; sandbox locks the contract directly) | M10 |\n| | | `set(ref, null)` deletes the subtree at the path | ✓ | Sandbox aligned: [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L154) (\"set(ref, null) deletes the path\"); matches oracle observation [`packages/conformance/observations/rtdb/rtdb-remove-vs-set-null.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-remove-vs-set-null.json) | M11 |\n| | | `remove(ref)` is equivalent to `set(ref, null)` (same end state) | ✓ | Sandbox aligned: [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L154) (\"remove and set(null) produce identical end-state\"); matches oracle observation [`packages/conformance/observations/rtdb/rtdb-remove-vs-set-null.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-remove-vs-set-null.json) | M12 |\n| | | `update(ref, patch)` shallow-merges top-level keys at the ref's path | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L12) (\"shallow-merges top-level keys\") | M13 |\n| | | `null` value in a shallow update deletes that key | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L16) (\"null values in a shallow update delete the key\") | M14 |\n| | | `update(rootRef, { '/a/x': v1, '/b/y': v2 })` is a multi-path atomic write — all paths land or none do | ✓ | [`unit:upstream-rtdb-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/upstream-rtdb-probes.test.ts#L10) (\"one update nulls, mutates, and displaces within a limitToFirst window\") + [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L16) (atomic multipath + rules denial); matches the matrix #23 prod contract | M15 |\n| | | Overlapping multi-path updates (one path is a descendant of another) reject before any path is written | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L16) (\"rejects overlapping paths\") | M16 |\n| | | `push(ref)` mints a 20-char auto-id key starting with `-`, lexicographically sortable | ✓ | Sandbox aligned: [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L340) (\"mints 20-char keys starting with \\\"-\\\"\" + \"sequential push keys are lex-sortable\"); matches oracle observation [`packages/conformance/observations/rtdb/rtdb-push-autoid-format.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-push-autoid-format.json) | M17 |\n| | | `push(ref, value)` writes `value` at the new child path | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L12) (\"push(ref, value) writes the value at the new child path\") | M18 |\n| | | `pushKey()` mints a fresh push-shaped key without writing — used by callers building multi-path updates that need the key first | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L19) (\"pushKey() mints a fresh key without writing\") | M19 |\n| | | `serverTimestamp()` returns the `{ \".sv\": \"timestamp\" }` sentinel marker the wire encoder recognises | ✓ | Sandbox aligned: [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L21) (\"serverTimestamp() returns the documented shape\"); matches the prod wire contract | M20 |\n| | | `serverTimestamp()` resolves to a number (epoch ms) on read-back | ✓ | Sandbox aligned: [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L376) (\"resolves to a number on read-back\"); matches oracle observation [`packages/conformance/observations/rtdb/rtdb-servertimestamp-resolves.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-servertimestamp-resolves.json) (prod observation blocked on rules; sandbox locks the contract directly) | M21 |\n| | | `serverTimestamp()` sentinels resolve when nested inside multi-path update payloads | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L16) (\"resolves sentinels nested deep inside an update payload\") | M22 |\n| | | Rules-denied write throws a plain `Error` (NOT a `FirebaseError`) with `.code === 'PERMISSION_DENIED'` (uppercase snake-case) and `.message === 'PERMISSION_DENIED: Permission denied'` | ✓ | Sandbox aligned: [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L407) (\"rules-denied set throws a plain Error with PERMISSION_DENIED code\"); matches oracle observation [`packages/conformance/observations/rtdb/rtdb-rules-denied-error-code.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-rules-denied-error-code.json) (against blockingfun, fb-js-sdk 12.13.0) | M23 |\n| | | Rules-denied read throws the same plain-`Error` `PERMISSION_DENIED` shape as a denied write | ✓ | Sandbox aligned: [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L407) (\"rules-denied get throws the same plain Error shape\"); matches oracle observation [`packages/conformance/observations/rtdb/rtdb-rules-denied-error-code.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-rules-denied-error-code.json) | M24 |\n| | | Rules-denied remove throws the same plain-`Error` `PERMISSION_DENIED` shape | ✓ | Sandbox aligned: [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L18) (\"rules-denied remove throws the same plain Error shape\") | M25 |\n| | | `onValue(ref, cb)` fires immediately on subscribe with the current value at the path | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L12) (\"fires on subscribe with the current value\") | M26 |\n| | | `onValue` fires again after every write that CHANGES the value at the watched path; a write that leaves the watched subtree byte-identical (a no-change re-write, or an ancestor/descendant write that doesn't alter this path) is suppressed (DB-B8) | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L22) (\"fires after every write that touches the watched path\") + [`unit:modular/no-change-suppression.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/no-change-suppression.test.ts#L17) (\"re-writing the same value does NOT re-fire\" + \"ancestor write leaving the subtree unchanged does NOT fire\") | M27 |\n| | | `onValue` fires after a descendant write (the listener sees subtree changes) | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L22) (\"fires on a descendant write\") | M28 |\n| | | `onValue` initial-fire for an absent path delivers `val=null, exists=false` (matches matrix expectation locked by oracle for sentinel/listener shape) | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L22) (\"absent path: initial fire delivers val=null, exists=false\") | M29 |\n| | | The `onValue` return value is an unsubscribe function; calling it stops further fires | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L22) (\"fires after every write that touches the watched path\" — checks unsubscribed listener doesn't fire on subsequent write) | M30 |\n| | | `onChildAdded` / `onChildChanged` / `onChildRemoved` / `onChildMoved` — plain-ref subscription surface | ✓ | [`unit:modular/sandbox-child-events.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-child-events.test.ts#L14) exercises all four plain-reference child listener registrars; M41–M48 own their per-event oracle claims. | M31 |\n| | | `onChildAdded` replays each existing direct child of the parent ref on subscribe (one fire per existing key) | ✓ | Sandbox aligned: [`unit:modular/sandbox-child-events.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-child-events.test.ts#L40) (\"replays existing direct children on subscribe — one fire per key\"); matches oracle observation [`packages/conformance/observations/rtdb-modular/rtdb-modular-onchildadded-initial-replay.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onchildadded-initial-replay.json) (seeded `{k1,k2,k3}`, observed `firedKeys: ['k1','k2','k3']`). | M41 |\n| | | After subscribe, `onChildAdded` fires exactly once per new direct child write; snapshot carries `{key, val}` of the new child | ✓ | Sandbox aligned: [`unit:modular/sandbox-child-events.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-child-events.test.ts#L66) (\"fires exactly once per NEW direct child after subscribe\"); matches oracle observation [`packages/conformance/observations/rtdb-modular/rtdb-modular-onchildadded-post-subscribe.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onchildadded-post-subscribe.json) (`postSubscribeFires: 1`, `lastFire: {key:'k3', val:{v:3}}`). | M42 |\n| | | `onChildChanged` has NO initial replay; fires once when an existing direct child's value transitions; snapshot carries the NEW value | ✓ | Sandbox aligned: [`unit:modular/sandbox-child-events.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-child-events.test.ts#L99) (\"does NOT fire on subscribe (no initial replay)\" + \"fires once when an existing child transitions to a new value; snapshot carries NEW val\"); matches oracle observation [`packages/conformance/observations/rtdb-modular/rtdb-modular-onchildchanged-fires-on-update.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onchildchanged-fires-on-update.json) (`firedOnInitial: 0, firedOnUpdate: 1, lastFire: {key:'k1', val:{v:2}}`). | M43 |\n| | | `onChildChanged` does NOT fire for added or removed children — those go to the other event listeners | ✓ | Sandbox aligned: [`unit:modular/sandbox-child-events.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-child-events.test.ts#L19) (\"does NOT fire when a child is added\" + \"does NOT fire when a child is removed\"). | M44 |\n| | | `onChildRemoved` has NO initial replay; fires once when a direct child is deleted (via `remove(child)` or `set(child, null)`); snapshot carries the PRIOR (now-removed) value | ✓ | [`unit:upstream-rtdb-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/upstream-rtdb-probes.test.ts#L8) (parent wipe fan-out via remove(parent) / set(parent, scalar)) + [`unit:modular/sandbox-child-events.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-child-events.test.ts#L143) (single-child delete carries PRIOR val); matches oracle [`rtdb-modular-onchildremoved-fires-on-delete.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onchildremoved-fires-on-delete.json) | M45 |\n| | | A plain-reference `onChildMoved` uses Firebase's default priority index: ordinary value changes that preserve priority do not move, while each changed child's priority emits one event even if its predecessor stays the same | ✓ | Oracle [`rtdb-modular-priority-contract`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-priority-contract.json) captures the same one-event-per-priority-operation sequence on a plain ref and an explicit `orderByPriority()` query, including a priority change that retains the same predecessor; [`unit:modular/sandbox-child-events.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-child-events.test.ts#L14) separately pins no movement for an ordinary value-only change. (Structured evidence: [`priority-metadata-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/priority-metadata-cdd.test.ts#L20)) | M46 |\n| | | `off(ref)` (no event type) removes ALL listeners at that ref — value + every child event variety | ✓ | Sandbox aligned: [`unit:modular/sandbox-child-events.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-child-events.test.ts#L338) (\"off(ref) removes ALL listeners at the ref\" + \"off(ref) also removes value listeners at the same path\"); matches oracle observation [`packages/conformance/observations/rtdb-modular/rtdb-modular-off-stops-child-fires.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-off-stops-child-fires.json) (`postOffFires: 0`). | M47 |\n| | | `off(ref, eventType?, callback?)` variants: `off(ref, 'value')` / `off(ref, 'child_added')` / `off(ref, eventType, cb)` remove the targeted subset; returned-unsubscribe from `onChild*` is equivalent to `off(ref, eventType, cb)` | ✓ | Sandbox aligned: [`unit:modular/sandbox-child-events.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-child-events.test.ts#L14) (\"off(ref, \\\"child_added\\\") removes only that event variety\" + \"off(ref, \\\"value\\\") removes only value listeners\" + \"off(ref, eventType, cb) removes only the matching callback\" + \"returned-unsubscribe from onChildAdded is functionally equivalent to off()\"). | M48 |\n| | | `connectDatabaseEmulator(db, host, port)` is a no-op on sandbox targets (the sandbox IS a local emulator) | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L23) (\"is a no-op on sandbox handles\") | M32 |\n| | | `sandbox.setRules(db, rulesJson)` deploys rules to the in-process simulator; `setRules(db, null)` clears rules (default-allow) | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L35) (\"sandbox.setRules(db, null) clears rules\") | M33 |\n| | | `sandbox.setData(db, { '/path': value })` bulk-loads data, bypassing rules | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L585) (\"sandbox.setData seeds the tree (rule-bypass)\") | M34 |\n| | | `sandbox.snapshotState(db)` dumps the full tree as a plain JSON object | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L51) (\"sandbox.snapshotState dumps the full tree\") | M35 |\n| | | `query(ref, ...constraints)` + ordering/range constraints | ✓ | [`unit:upstream-rtdb-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/upstream-rtdb-probes.test.ts#L4) (\"orderByChild('a/b') + limitToFirst orders by the nested path\") + [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L15) + oracle observations under `packages/conformance/observations/rtdb-modular/`; see M49–M64 for the per-claim breakdown. | M36 |\n| | | `runTransaction(ref, fn, options?)` resolves to `{ committed: boolean, snapshot: DataSnapshot }` for the happy path — the update fn return value is written, committed is `true`, and `snapshot.val()` reflects the committed value | ✓ | Sandbox aligned: [`unit:modular/transaction.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction.test.ts#L37) (\"resolves to { committed: boolean, snapshot } with the committed value\"); matches oracle observations [`packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-success.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-success.json) + `rtdb-modular-runtransaction-returns-committed-snapshot.json` (against blockingfun, fb-js-sdk 12.13.0) (Structured evidence: [`rtdb-modular-runtransaction-warm-client-speculation`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-warm-client-speculation.json)) | M37 |\n| | | Returning `undefined` from the update fn ABORTS the transaction — resolves `{ committed: false, snapshot }`; no write performed, no listener fan-out | ✓ | Sandbox aligned: [`unit:modular/transaction.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction.test.ts#L61) (\"returning undefined aborts — committed: false, no write\" + \"aborted transaction does NOT fan out to listeners\"); matches oracle observation [`packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-abort-undefined.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-abort-undefined.json) — known divergence: prod's `result.snapshot.val()` reflects the CLIENT's pre-fetch (often `null` even when the server has a value because the speculative invocation runs before the server snap arrives); the sandbox returns the actual pre-transaction value at the path (more useful in single-client harness). The agreed-upon contract callers should rely on is `committed === false` and unchanged server-side data, NOT the snapshot's `.val()` on the abort path. | M37a |\n| | | Cold production clients may invoke the update fn first with speculative `null` and then with the current seeded value; the always-warm in-process sandbox invokes it once with the current value | ⚠ | Oracle [`rtdb-modular-runtransaction-current-value-arg`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-current-value-arg.json) captures two seeded-path production invocations (`null`, then current); [`unit:modular/oracle-conformance-transactions.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-transactions.test.ts#L52) pins the sandbox's single current-value invocation. The separate warm-client observation matches the sandbox but does not erase the cold-client contract. (Structured evidence: [`transaction.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction.test.ts#L93)) | M37b |\n| | | The update fn arg is a defensive deep clone — mutating it does NOT corrupt the stored tree (matters for code that does `current.count++; return undefined` and expects abort to preserve state) | ✓ | [`unit:modular/transaction.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction.test.ts#L8) (\"mutating the update-fn arg does NOT corrupt the stored tree\") — no separate oracle row (defensive contract; prod behavior is identical because the SDK clones on the wire boundary) | M37c |\n| | | `options.applyLocally` controls whether the in-flight optimistic value fans out to `onValue` listeners — default `true` (apply locally before commit); `false` suppresses intermediate fires so listeners see only the committed value | ✓ | Sandbox aligned: [`unit:modular/transaction.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction.test.ts#L175) (\"applyLocally: true (default) — listener sees initial + committed value\" + \"applyLocally: false — listener sees only the committed value\"); matches oracle observation [`packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-options-applylocally.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-options-applylocally.json) (single-client harness: both branches produce 2 fires (initial + commit) — divergence vs prod's documented multi-client suppression would surface under contention, which the sandbox doesn't model) | M37d |\n| | | Rules-denied transaction rejects with a plain `Error` whose `message === 'permission_denied'` (lowercase) and NO `.code` field — DIFFERENT from `set`/`get`'s `'PERMISSION_DENIED: Permission denied'` shape with uppercase `.code`. | ✓ | Sandbox aligned: [`unit:modular/transaction.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction.test.ts#L13) (\"rejects with a plain Error whose message is \\\"permission_denied\\\"\"); matches oracle observation [`packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-on-rules-denied-path.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-on-rules-denied-path.json) (against blockingfun: `message: 'permission_denied', code: null, constructorName: 'Error'`). | M37e |\n| | | Rules-denied transaction does NOT write — pre-transaction value at the path is preserved through the rejection | ✓ | [`unit:modular/transaction.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction.test.ts#L2) (\"does not write to the path when rules deny\") — locked alongside the M37e shape claim | M37f |\n| | | Committed transaction fans out to `onValue` listeners on the watched path with the new value (default applyLocally behavior) | ✓ | [`unit:modular/transaction.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction.test.ts#L24) (\"committed write fans out to onValue listeners\") | M37g |\n| | | **Divergence:** two ordinary concurrent `runTransaction` calls are serialized by the in-process backend, so their update functions are not retried with Firebase's captured contention counts. A synchronous re-entrant conflicting write does trigger a deterministic retry. | ⚠ | Oracle [`rtdb-modular-concurrent-transforms`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-concurrent-transforms.json) captures invocation counts [2, 3]; [`unit:modular/transaction-contention-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction-contention-cdd.test.ts#L12) pins the sandbox's [1, 1] ordinary-concurrency boundary and separately covers its deterministic re-entrant retry seam. | M37h |\n| | | Identity-aware sandbox-live op routing — sign-in/sign-out via `pyric/auth` is observed by the next RTDB op without re-binding | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L11) (\"reads sandbox.currentUser at op time, not at getDatabase time\") | M38 |\n| | | Backend identity is per-`Sandbox` — two `getDatabase(sandbox)` calls on the same sandbox share data, two on different sandboxes don't | ✓ | [`unit:modular/database-instances-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/database-instances-cdd.test.ts#L3) proves same-sandbox sharing and independent-sandbox isolation. | M39 |\n| | | Sandbox refs carry a stable `key` (last path segment) and `toString()` returning `sandbox://rtdb/<path>` | ✓ | [`unit:modular/oracle-conformance-reference-writes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L1) pins the stable key and local `sandbox://rtdb/<path>` string boundary alongside the production URL observation. | M40 |\n| | | `query(ref, orderByChild(p), startAt(v), endAt(w))` window is BOTH-INCLUSIVE — children whose ordered field === `v` or === `w` are included | ✓ | [`unit:upstream-rtdb-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/upstream-rtdb-probes.test.ts#L4) (deep orderByChild nested path) + [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L65) (\"returns children whose ordered child is within [startAt, endAt] inclusive\"); matches oracle [`rtdb-modular-orderbychild-window.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-orderbychild-window.json) | M49 |\n| | | `orderByKey()` orders children by RTDB `nameCompare` — integer-looking keys sort numerically FIRST (so `['1','2','10']`, not the lexicographic `['1','10','2']`), then non-integer keys lexicographically; `startAt`/`endAt` cursors + the optional `key` tie-breaker use the same order (DB-B4) | ✓ | [`unit:upstream-rtdb-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/upstream-rtdb-probes.test.ts#L22) (INT32 overflow/underflow cursors) + [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L23) + [`unit:modular/name-compare.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/name-compare.test.ts#L19); matches oracle `rtdb-modular-orderbykey-window.json` and upstream `core/util/util.ts:253-276` | M50 |\n| | | Production rejects an unindexed `orderByValue() + limitToFirst(N)` query; the sandbox does not enforce `.indexOn` and returns the N smallest primitive values | ⚠ | Oracle [`rtdb-modular-orderbyvalue-numeric`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-orderbyvalue-numeric.json) captures production's `Index not defined` rejection; [`unit:modular/oracle-conformance-queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L108) pins that rejection beside the sandbox's successful `[10,20,30]` window. (Structured evidence: [`oracle-conformance-queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-queries.test.ts#L51)) | M51 |\n| | | `orderByChild(p) + equalTo(v)` returns ALL children whose field at `p` === `v` — no uniqueness enforced | ✓ | Sandbox aligned: [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L129) (\"returns ALL children whose ordered field === the supplied value\"); matches oracle observation [`packages/conformance/observations/rtdb-modular/rtdb-modular-equalTo-filter.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-equalTo-filter.json) (both 'b'-grouped children returned). | M52 |\n| | | `equalTo` with no matches returns an empty snapshot (`exists() === false`, `size === 0`) | ✓ | [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L32) (\"returns an empty snapshot when nothing matches\") | M53 |\n| | | `limitToFirst(N)` keeps the lowest-ranked N children (post-ordering, pre-filter) | ✓ | Sandbox aligned: [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L158) (\"limitToFirst takes the lowest-ranked window\"); matches oracle observation [`packages/conformance/observations/rtdb-modular/rtdb-modular-limittofirst-vs-limittolast.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-limittofirst-vs-limittolast.json) (firstPositions `[1,2]`). | M54 |\n| | | `limitToLast(N)` keeps the highest-ranked N children | ✓ | Sandbox aligned: [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L158) (\"limitToLast takes the highest-ranked window\"); matches oracle observation [`packages/conformance/observations/rtdb-modular/rtdb-modular-limittofirst-vs-limittolast.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-limittofirst-vs-limittolast.json) (lastPositions `[4,5]`). | M55 |\n| | | `limitToFirst(N)` larger than the result returns the full window (no padding, no throw) | ✓ | [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L33) (\"limitToFirst(N) larger than the result returns the full window\") | M56 |\n| | | `startAfter(v)` and `endBefore(v)` are EXCLUSIVE — the boundary value is dropped from the result | ✓ | Sandbox aligned: [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L197) (\"startAfter + endBefore drop the boundary values\"); matches oracle observation [`packages/conformance/observations/rtdb-modular/rtdb-modular-startafter-endbefore-exclusive.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-startafter-endbefore-exclusive.json) (positions `[3,4]`, cursors 2 + 5 dropped). | M57 |\n| | | `onValue(query, cb)` only fires when the windowed result changes — writes OUTSIDE the window don't re-fire the listener; writes that displace a member DO | ✓ | [`unit:upstream-rtdb-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/upstream-rtdb-probes.test.ts#L4) (\"one update nulls, mutates, and displaces within a limitToFirst window\") + [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L221) (\"fires only when the windowed result changes\"); matches oracle [`rtdb-modular-onvalue-with-query.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onvalue-with-query.json) | M58 |\n| | | `onValue(query)` initial fire delivers an empty window (`size === 0`) when the path is absent | ✓ | [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L20) (\"initial fire on an empty path delivers an empty window\") | M59 |\n| | | `query(query(ref, c1), c2)` composes constraints — chaining folds both into one spec | ✓ | [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L15) (\"query(query(ref, c1), c2) composes constraints\") | M60 |\n| | | Snapshot from a query exposes children via `snap.forEach` in the executor-computed order — NOT necessarily the order `Object.entries(val)` would yield | ✓ | [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L21) (\"forEach visits children in ascending order of the child key\") | M61 |\n| | | `startAt(value, key)` uses `key` as the tie-breaker when multiple children share the same ordered value — children before `key` are dropped, the row at `key` is included (inclusive cursor) | ✓ | [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L28) (\"startAt with key tie-breaker drops earlier same-value children\") | M62 |\n| | | `orderByChild('p')` on children missing the field treats their value as `null` (sorts FIRST per RTDB's type ordering) | ✓ | [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L22) (\"orderByChild on a missing child path treats those children as null\") | M63 |\n| | | `query` on a path holding a primitive (or absent path) returns an empty snapshot — no rows to iterate | ✓ | [`unit:modular/queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L21) (\"query on a path with primitive value returns no rows\") | M64 |\n| | | Write-boundary normalization (`nodeFromJSON`-equivalent): a value written as an array is stored as an integer-keyed object — `child(ref, '1')` returns the element, `forEach` iterates `0,1,2…` (DB-B2) | ✓ | Sandbox aligned: [`unit:modular/normalization.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/normalization.test.ts#L20) (\"array write is addressable by integer-string child key\" + \"forEach over an array iterates its elements\"); upstream `core/snap/nodeFromJSON.ts:118-128`, `core/snap/ChildrenNode.ts:194-230` | M65 |\n| | | Read-side array coercion: a dense integer-keyed object renders back as an array on `snap.val()` (`allIntegerKeys && maxKey < 2 * numKeys`) (DB-B2) | ✓ | Sandbox aligned: [`unit:modular/normalization.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/normalization.test.ts#L19) (\"a dense integer-keyed object reads back as an array\"); upstream `core/snap/ChildrenNode.ts:196-230` | M66 |\n| | | `null` children and empty objects are pruned at the write boundary — `set(ref, {})` is equivalent to `remove(ref)`; nested `null` collapses empty ancestors (\"empty nodes don't exist\") (DB-B3) | ✓ | Sandbox aligned: [`unit:modular/normalization.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/normalization.test.ts#L20) (\"set(ref, {}) is equivalent to remove\" + \"null children are pruned\"); upstream `core/snap/nodeFromJSON.ts:78-88,122-126` | M67 |\n| | | Write validation: an `undefined` payload, a non-finite number (`NaN`/`±Infinity`), or a key containing a forbidden char (`.`, `#`, `$`, `/`, `[`, `]`, control chars) is rejected with a plain `Error` (DB-B1) | ✓ | Sandbox aligned: [`unit:modular/normalization.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/normalization.test.ts#L23) (\"rejects an undefined payload\" + \"rejects an invalid key\" + \"rejects a non-finite number\"); upstream `core/util/validation.ts:45,58,112-199` | M68 |\n| | | Conflicting query constraints throw synchronously at `query(...)` construction (NOT silent last-win): multiple `orderBy*`, a second `limitToFirst`/`limitToLast`, a second start (`startAt`/`startAfter`/`equalTo`) or end (`endAt`/`endBefore`/`equalTo`) (DB-B5) | ✓ | Sandbox aligned: [`unit:modular/constraint-conflicts.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/constraint-conflicts.test.ts#L4) (5 cases); upstream `api/Reference_impl.ts:160-165,1824-1841,1888-1905,1945-1951,2193-2206` | M69 |\n| | | `push(ref, value?)` returns a `ThenableReference` (a `DatabaseReference` with `.then`/`.catch`). The key + ref are minted CLIENT-SIDE and available synchronously even when the optional value write is rules-denied; the write is deferred onto the promise, so a denial REJECTS the awaited push rather than throwing synchronously and discarding the key (DB-B7) | ✓ | Sandbox aligned: [`unit:modular/push-thenable.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/push-thenable.test.ts#L10) (4 cases); matches oracle [`packages/conformance/observations/rtdb/rtdb-push-autoid-format.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-push-autoid-format.json) (\"available immediately even when the subsequent server write is denied by rules\") + upstream `api/Reference_impl.ts:599-630` | M70 |\n| | | `DataSnapshot` shape: `size` (getter), `priority` (`null` when absent), `exportVal()`, `key`, `ref`, `val()`, `exists()`, `child()`, `hasChild()`, `hasChildren()`, `forEach()`, `toJSON()`. It does NOT ship the legacy namespaced `numChildren()` method (DB-B10) | ✓ | Sandbox aligned: [`unit:modular/snapshot-shape.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/snapshot-shape.test.ts#L9) (\"exposes size/priority/exportVal; NOT numChildren()\"); matches oracle [`packages/conformance/observations/rtdb-modular/rtdb-modular-get-snapshot-shape.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-get-snapshot-shape.json) (`hasSize: true, hasNumChildren: false`) + upstream `api/Reference_impl.ts:288-447`. **Flipped masking tests**: `modular/queries.test.ts` + `modular/sandbox-target.test.ts` asserted `snap.numChildren()` — updated to `snap.size`. | M71 |\n| | | Object-valued children are ORDER-EQUAL — the sort/range tie is broken by key (`nameCompare`), NOT by an invented `JSON.stringify` ordering; a query re-write that only reorders object keys is \"no change\" and doesn't re-fire (DB-B11) | ✓ | Sandbox aligned: [`unit:modular/object-order-equality.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/object-order-equality.test.ts#L16); upstream `core/snap/ChildrenNode.ts:386-400` | M72 |\n| | | A primitive at the ROOT is legal (`set(ref(db), 'hello')`); a subsequent child write replaces the primitive root (\"writes win\") (DB-B13) | ✓ | Sandbox aligned: [`unit:modular/root-primitive.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/root-primitive.test.ts#L2) (2 cases) | M73 |\n| | | `onValue(ref, cb, { onlyOnce: true })` fires once then auto-unsubscribes (DB-B12) | ✓ | Sandbox aligned: [`unit:modular/onvalue-onlyonce.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/onvalue-onlyonce.test.ts#L2); upstream `api/Reference_impl.ts:975-980` | M74 |\n| | | Child-listener callbacks receive Firebase's `previousChildName` second argument, including initial replay and ordered-query movement. | ✓ | Oracle [`rtdb-modular-child-previous-name`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-child-previous-name.json) captures initial replay plus add/change/remove/move predecessor values; replayed by [`unit:modular/listener-lifecycle-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/listener-lifecycle-cdd.test.ts#L24). | M75 |\n| | | Value and child listener overloads accept a cancellation callback and deliver Firebase-shaped permission errors when a listen is denied or revoked; a callbackless listener that loses auth access is terminal and does not resurrect after sign-in. | ✓ | Oracle [`rtdb-modular-listener-cancellation`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-listener-cancellation.json) captures all five registrars returning normally, asynchronous initial-denial cancellation, one revocation callback each, and the Error/PERMISSION_DENIED/message shape; replayed in [`unit:modular/listener-lifecycle-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/listener-lifecycle-cdd.test.ts#L26). | M75a |\n| | | All child-listener functions accept `Query` inputs; add/change/remove events respect the active query window. | ✓ | [`unit:modular/listener-lifecycle-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/listener-lifecycle-cdd.test.ts#L267) and [`unit:modular/sandbox-child-events.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-child-events.test.ts#L18) cover initial windows plus entering, leaving, and changing members. | M75b |\n| | | `onChildMoved` fires when an ordered child changes position and co-fires with `child_changed` when Firebase does, preserving the captured `previousChildName` sequence. | ✓ | Oracle [`rtdb-modular-child-previous-name`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-child-previous-name.json) and the modular oracle suite replay ordered movement, co-fire behavior, and predecessor sequencing. (Structured evidence: [`rtdb-modular-childchanged-cofire-with-childmoved`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-childchanged-cofire-with-childmoved.json), [`rtdb-modular-onchildmoved-previouschildname-sequencing`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onchildmoved-previouschildname-sequencing.json), [`listener-lifecycle-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/listener-lifecycle-cdd.test.ts#L24), [`oracle-conformance-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-listeners.test.ts#L178)) | M75c |\n| | | Child listener overloads accept `ListenOptions` directly or after a cancellation callback; `onlyOnce` stops delivery after Firebase's captured initial/event batch. | ✓ | Oracle [`rtdb-modular-child-listener-only-once`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-child-listener-only-once.json) captures all four listener families, both overload shapes, the existing-child replay batch, and one-shot post-registration delivery; replayed by [`unit:modular/listener-lifecycle-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/listener-lifecycle-cdd.test.ts#L25). | M75d |\n| | | `.validate` rules are enforced on modular sandbox writes through `set`, atomic `update`, and `runTransaction`; a descendant validation failure rejects the operation without changing state. | ✓ | [`unit:modular/sandbox-target.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-target.test.ts#L15) executes all three write paths against a required-child `.validate` rule and proves each rejects without committing state. The shared `SimulateHandler` behavior is production-locked by RTDB rules corpus rows #4 and #15. | M76 |\n| | | `onDisconnect(ref)` returns the Firebase-shaped five-method handle; registration methods and `cancel()` return acknowledged Promises | ✓ | Oracle [`rtdb-modular-ondisconnect-registration`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-ondisconnect-registration.json); sandbox [`unit:database/on-disconnect.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/on-disconnect.test.ts#L40). | M77 |\n| | | An acknowledged `onDisconnect(ref).set(value)` registration does not mutate server data before the client disconnects | ✓ | Oracle [`rtdb-modular-ondisconnect-registration`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-ondisconnect-registration.json); sandbox registration/no-mutation test. (Structured evidence: [`on-disconnect.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/on-disconnect.test.ts#L40)) | M78 |\n| | | `goOffline(db)` drains that client's queue once, listeners observe the disconnect write in server order, and `goOnline(db)` does not resurrect executed operations | ✓ | Oracle [`rtdb-modular-ondisconnect-clean-set`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-ondisconnect-clean-set.json); sandbox clean lifecycle test. (Structured evidence: [`on-disconnect.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/on-disconnect.test.ts#L71)) | M79 |\n| | | Disconnect queues support `set`, `update`, `remove`, exact cancellation, parent cancellation of queued descendants, and captured parent-set/child-set/child-cancel coalescing that preserves the existing canceled child | ✓ | Oracle [`rtdb-modular-ondisconnect-operations-cancel`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-ondisconnect-operations-cancel.json); sandbox operation/cancellation test, including the captured parent-set + child-cancel merge result. (Structured evidence: [`on-disconnect.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/on-disconnect.test.ts#L96)) | M80 |\n| | | Rules are evaluated when a disconnect operation is registered and re-evaluated when it executes; an execution-time denial leaves server state unchanged | ✓ | Oracle [`rtdb-modular-ondisconnect-rules`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-ondisconnect-rules.json) with successful normal-write controls in both rule phases; sandbox rules-timing test. (Structured evidence: [`on-disconnect.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/on-disconnect.test.ts#L174)) | M81 |\n| | | **Local lifecycle contract:** disconnect queues are owned per Database client, drain on app deletion, clear without executing on sandbox reset, and are excluded from persisted RTDB snapshots | ✓ | The explicit `rtdb-modular#M82` assertion set in [`unit:database/on-disconnect.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/on-disconnect.test.ts#L252) covers independent clients, app deletion, reset clearing, and snapshot exclusion; these sandbox-owned boundaries have no production oracle equivalent. | M82 |\n| | | `OnDisconnect.setWithPriority(value, priority)` writes both the value and RTDB priority metadata when the disconnect queue drains. | ✓ | Oracle [`rtdb-modular-ondisconnect-operations-cancel`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-ondisconnect-operations-cancel.json) captures export-format priority 7; sandbox [`unit:database/on-disconnect.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/on-disconnect.test.ts#L96) replays the value and priority. | M83 |\n| | | **Divergence (pending fix):** clean `goOffline`, app deletion, playground `pagehide`, and best-effort MessagePort close drain queued operations, but unannounced total renderer/process loss is not guaranteed by the in-memory sandbox | ⚠ | Oracle [`rtdb-modular-ondisconnect-abrupt-exit`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-ondisconnect-abrupt-exit.json) proves Firebase executes an acknowledged registration after forced writer termination. The two-port worker integration exercises `goOffline`, served-app deletion, and non-persisted `pagehide`; browser MessagePort close delivery cannot prove total-process loss without durable host-owned leases. (Structured evidence: [`rtdb-integration.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/serve/worker/rtdb-integration.test.ts#L24)) | M84 |\n| | | `Database` is exported as a runtime constructor value and handles returned by `getDatabase()` satisfy Firebase's observable prototype and `instanceof` contract. | ✓ | Oracle [`rtdb-modular-runtime-class-identity`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtime-class-identity.json); sandbox row assertion `rtdb-modular#M85` replays runtime identity. (Structured evidence: [`oracle-conformance-runtime-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-runtime-identity.test.ts#L28)) | M85 |\n| | | `DataSnapshot` is exported as a runtime constructor value and snapshots returned by reads/listeners satisfy Firebase's observable prototype and `instanceof` contract. | ✓ | Oracle [`rtdb-modular-runtime-class-identity`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtime-class-identity.json); sandbox row assertion `rtdb-modular#M86` replays runtime identity. (Structured evidence: [`oracle-conformance-runtime-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-runtime-identity.test.ts#L29)) | M86 |\n| | | `QueryConstraint` is exported as a runtime constructor value and constraints returned by `orderBy*`, bound, and limit factories satisfy Firebase's observable prototype and `instanceof` contract. | ✓ | Oracle [`rtdb-modular-runtime-class-identity`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtime-class-identity.json); concrete constraint subclasses satisfy `instanceof QueryConstraint` without using the base prototype directly, replayed by row assertion `rtdb-modular#M87`. (Structured evidence: [`oracle-conformance-runtime-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-runtime-identity.test.ts#L29)) | M87 |\n| | | `TransactionResult` is exported as a runtime constructor value and transaction results satisfy Firebase's observable prototype, `instanceof`, and `toJSON()` contract. | ✓ | Oracle [`rtdb-modular-runtime-class-identity`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtime-class-identity.json); sandbox row assertion `rtdb-modular#M88` replays runtime identity and `toJSON()`. (Structured evidence: [`oracle-conformance-runtime-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-runtime-identity.test.ts#L29)) | M88 |\n| | | `setPriority` and `setWithPriority` store, replace, preserve, and clear valid RTDB priority metadata; parent `exportVal()` and `toJSON()` recursively include descendant priority metadata. | ✓ | Oracle [`rtdb-modular-priority-contract`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-priority-contract.json) captures round-trip/export shape, update/transaction preservation, plain-set clearing, and explicit clearing; replayed by [`unit:modular/priority-metadata-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/priority-metadata-cdd.test.ts#L20). | M89 |\n| | | `orderByPriority` orders by Firebase priority with key tie-breaking and composes with bounds, equality, and limits; plain snapshots and unconstrained queries use the same default priority index, and invalid priority bounds throw synchronously. | ✓ | Oracle [`rtdb-modular-priority-contract`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-priority-contract.json) captures priority order, key tie-breaking, bounds, and limits; replayed by [`unit:modular/priority-metadata-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/priority-metadata-cdd.test.ts#L20). | M90 |\n| | | Priority changes participate in plain and explicitly ordered child movement/listener sequencing, transactions, and updates without losing metadata. | ✓ | Oracle [`rtdb-modular-priority-contract`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-priority-contract.json) captures priority movement, update/transaction preservation, and descendant delivery when ancestor replacement clears metadata; replayed by [`unit:modular/priority-metadata-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/priority-metadata-cdd.test.ts#L20) and [`unit:modular/priority-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/priority-listeners.test.ts#L18). Disconnect priority execution is owned separately by M83. | M91 |\n| | | `off(query)` removes listeners only from the equivalent constrained query view, while `off(ref)` removes listeners from every query view at that path. | ✓ | Oracle [`rtdb-modular-off-duplicate-registration`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-off-duplicate-registration.json) captures exact constrained-query removal and all-view reference removal with independent live-listener controls; replayed by [`unit:modular/sandbox-child-events.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/sandbox-child-events.test.ts#L22). | M92 |\n| | | **Divergence:** `DatabaseReference` and `Query` equality includes database target, path, and canonical query parameters like Firebase, but `toJSON()` serializes the local `sandbox://rtdb/...` identity instead of Firebase's HTTPS database URL. | ⚠ | Oracle [`rtdb-modular-reference-shape-url`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-reference-shape-url.json) captures conforming equality plus HTTPS JSON serialization; [`unit:modular/oracle-conformance-reference-writes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L33) pins both the production HTTPS shape and the sandbox's `sandbox://` boundary. | M93 |\n| | | Query constraint construction preserves Firebase's synchronous validation for limits, child paths, cursor keys, undefined/non-finite/object endpoint values, and index-specific endpoint compatibility, including the captured +Infinity and server-value priority acceptances | ✓ | Oracle [`rtdb-modular-query-construction-validation`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-query-construction-validation.json) repeats the complete validation matrix twice per run and matched across two clean production runs; [`unit:modular/query-validation.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/query-validation.test.ts#L23) replays every captured field exactly. | M94 |\n\n### `getDatabase(target)` — initializer\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | `getDatabase(ctx)` returns a tagged sandbox-target handle (frozen identity) | ✓ | [`unit:modular/database-instances-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/database-instances-cdd.test.ts#L14) asserts the returned Database carries a sandbox target. | 94 |\n| | | `getDatabase(sandbox)` returns a tagged sandbox-live handle (per-op identity) | ✓ | [`unit:modular/database-instances-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/database-instances-cdd.test.ts#L21) asserts the returned Database carries a sandbox-live target. | 95 |\n| | | Inactive canonical `firebase/database` imports remain the upstream package; the mirror does not create tagged production targets | ✓ | Package-resolution boundary assertion in [`unit:modular/database-instances-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/database-instances-cdd.test.ts#L45) proves a real Firebase Database remains untagged and is rejected by the mirror. | 96 |\n| | | `getDatabase()` (no argument) — wrapped in the playground preview to supply the sandbox; a raw mirror call rejects with package-resolution guidance | ✓ (wrap, fixture passing) | Phase 3 Tier 5: virtualized in the playground preview scope. Wired at `packages/playground/src/components/AppPreview.tsx` (slot install with bare-call wrap), `packages/playground/src/lib/preview/virtual-imports-plugin.ts` (alias map), and `packages/playground/src/lib/preview/preview-scope.ts` (type-level slot). Mirrors the `getAuth` / `getFirestore` wrap pattern. Demo fixture: `packages/playground/scripts/fixtures/rtdb-set-get-roundtrip.tsx` (bare `getDatabase()` + `set`/`get`/`remove` round-trip with anonymous sign-in) passes end-to-end through the `bun run debug:fixtures` Playwright suite. (Structured evidence: [`lifecycle-and-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/lifecycle-and-identity.test.ts#L58)) | 97 |\n| | | Two `getDatabase(sandbox)` calls share state (same underlying `LocalEnvironment`) | ✓ | [`unit:modular/database-instances-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/database-instances-cdd.test.ts#L28) writes through one handle and reads through another handle owned by the same sandbox. | 98 |\n| | | Handle dispatch by `TARGET_SYMBOL` brand — refs route to their owning target via a `refToTarget` WeakMap (mirror of firestore's pattern) | ✓ | [`unit:modular/database-instances-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/database-instances-cdd.test.ts#L36) proves references from independent sandbox targets route to independent backends. | 99 |\n\n### `ref(db, path)` / `child` / `parent` / `root`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | `ref(db, path)` returns a tagged `DatabaseReference` carrying `key`, `parent`, `root`, and `toString()`, and synchronously rejects paths containing `.`, `#`, `$`, `[`, or `]` with Firebase's captured error shape | ✓ | Oracle [`rtdb-modular-reference-shape-url`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-reference-shape-url.json); sandbox row assertion `rtdb-modular#100` replays navigation and every forbidden path character. (Structured evidence: [`oracle-conformance-reference-writes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L32)) | 100 |\n| | | `ref(db)` with no path returns the root ref (`key === null`, `parent === null`) | ✓ | Oracle [`rtdb-modular-reference-shape-url`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-reference-shape-url.json); sandbox row assertion `rtdb-modular#101`. (Structured evidence: [`oracle-conformance-reference-writes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L33)) | 101 |\n| | | `child(ref, 'a/b')` joins a non-empty relative path, including embedded slashes, and synchronously rejects empty or forbidden-character paths with Firebase's captured error shape | ✓ | Oracle [`rtdb-modular-reference-shape-url`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-reference-shape-url.json); sandbox row assertion `rtdb-modular#102` replays embedded-path joining plus empty/invalid validation. (Structured evidence: [`oracle-conformance-reference-writes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L33)) | 102 |\n| | | `ref.parent` is `null` at root, otherwise the parent ref | ✓ | Oracle [`rtdb-modular-reference-shape-url`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-reference-shape-url.json); sandbox row assertion `rtdb-modular#103`. (Structured evidence: [`oracle-conformance-reference-writes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L33)) | 103 |\n| | | `ref.key` is the final path segment, `null` for root | ✓ | Oracle [`rtdb-modular-reference-shape-url`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-reference-shape-url.json); sandbox row assertion `rtdb-modular#104`. (Structured evidence: [`oracle-conformance-reference-writes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L33)) | 104 |\n| | | Unknown ref (not produced by this package) → `TypeError` in shim ops | ✓ | Oracle [`rtdb-modular-reference-shape-url`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-reference-shape-url.json) captures a synchronous `TypeError`; sandbox row assertion `rtdb-modular#105` matches its timing and constructor. (Structured evidence: [`oracle-conformance-reference-writes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L33)) | 105 |\n\n### `get(ref)` — single read\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | Returns a `DataSnapshot` carrying `.val()`, `.exists()`, `.key`, `.ref`, `.size` (getter, returns child count), `.hasChildren()`, `.hasChild(path)`, `.forEach(cb)`. The legacy namespaced-SDK method `.numChildren()` is **NOT** on the modular DataSnapshot — use `.size` instead. Observed: `hasNumChildren: false`, `size: 3` for a `{a,b,c}` object, `forEachKeys: ['a','b','c']` against blockingfun, fb-js-sdk 12.13.0. | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-get-snapshot-shape.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-get-snapshot-shape.json) (Structured evidence: [`snapshot-shape.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/snapshot-shape.test.ts#L9)) | 106 |\n| | | `snap.val()` returns `null` for a missing path (NOT a thrown error — RTDB diverges from Firestore here; `getDoc` returns `exists()===false` but `get` on RTDB just returns a `null`-val snapshot) | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-get-missing-path.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-get-missing-path.json) — observed `threw: false, val: null, exists: false` on a never-written path against blockingfun. (Structured evidence: [`lifecycle-and-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/lifecycle-and-identity.test.ts#L38)) | 107 |\n| | | `snap.exists()` is `false` when `val() === null`, `true` otherwise | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-get-missing-path.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-get-missing-path.json) — observed `exists: false` for `val: null`. (Structured evidence: [`lifecycle-and-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/lifecycle-and-identity.test.ts#L38)) | 108 |\n| | | Round-trip: `set(ref, payload)` then `get(ref)` returns the payload (lock the basic write→read invariant) | ✓ | oracle: [`packages/conformance/observations/rtdb/rtdb-set-then-get-roundtrip.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-set-then-get-roundtrip.json) — the payload round-trips structurally on both sides (this row's claim holds). NOTE — adjacent divergence pinned in [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/oracle-conformance.test.ts#L95): prod returns object children in LEXICOGRAPHIC key order (the capture's `roundTripEqual: false` — a `JSON.stringify` round-trip against a non-sorted payload fails), while the sandbox preserves insertion order (stringify round-trip succeeds). Key-order-sensitive consumers behave differently. | 109 |\n| | | Rules-denied read throws a plain `Error` (NOT a `FirebaseError`) with `code: 'PERMISSION_DENIED'` (uppercase snake-case) — matches the agent-tool rows #15/#20 | ✓ | oracle: [`packages/conformance/observations/rtdb/rtdb-rules-denied-error-code.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-rules-denied-error-code.json) (Structured evidence: [`lifecycle-and-identity.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/lifecycle-and-identity.test.ts#L38)) | 110 |\n\n### `set(ref, value)` — full write\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | Replaces the value at the path entirely; resolves to `undefined` (unlike `setDoc` which resolves to `void`, RTDB's `set` is documented as `Promise<void>`) | ✓ | Oracle [`rtdb-modular-write-return-validation`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-write-return-validation.json) captures `null` in JSON for the resolved `undefined` value; sandbox row assertion `rtdb-modular#111` matches it. (Structured evidence: [`oracle-conformance-reference-writes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L138)) | 111 |\n| | | `set(ref, null)` removes the path entirely — equivalent to `remove(ref)`, subsequent `get` returns `null`-val snapshot | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-set-null-equals-remove.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-set-null-equals-remove.json) — observed `beforeExists: true → afterExists: false, afterVal: null` after `set(ref, null)`. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L20)) | 112 |\n| | | Nested objects overwrite — `set(ref, {a: 1})` after `set(ref, {a: 1, b: 2})` leaves `{a: 1}` only, NOT a merge (RTDB `set` is replacement, not merge) | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-set-replaces-not-merges.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-set-replaces-not-merges.json) — observed `final: {a: 1}` with `b` absent after the second set. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L20)) | 113 |\n| | | Primitive round-trip — numbers, strings, booleans, arrays all survive a set→get cycle | ✓ | oracle: [`packages/conformance/observations/rtdb/rtdb-set-then-get-roundtrip.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-set-then-get-roundtrip.json) — the payload round-trips structurally on both sides (this row's claim holds). NOTE — adjacent divergence pinned in [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/oracle-conformance.test.ts#L95): prod returns object children in LEXICOGRAPHIC key order (the capture's `roundTripEqual: false` — a `JSON.stringify` round-trip against a non-sorted payload fails), while the sandbox preserves insertion order (stringify round-trip succeeds). Key-order-sensitive consumers behave differently. | 114 |\n| | | Rules-denied write throws plain `Error` with `code: 'PERMISSION_DENIED'` (same shape as #110) | ✓ | oracle: [`packages/conformance/observations/rtdb/rtdb-rules-denied-error-code.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-rules-denied-error-code.json) (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L20)) | 115 |\n\n### `update(ref, values)` — partial / multi-path update\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | `update(ref, {a: 1, b: 2})` merges top-level keys at the ref; unspecified keys preserved (in contrast to `set`'s replacement) | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-update-merges-keys.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-update-merges-keys.json) — after `set({a:1,b:2})` then `update({a:10})`, observed `final: {a:10, b:2}`. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L60)) | 116 |\n| | | Multi-path update — `update(parentRef, { 'a/x': 1, 'b/y': 2 })` lands BOTH writes atomically at distinct subtrees (RTDB's most distinctive feature; this is the \"fan-out\" pattern) | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-update-multipath-atomic.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-update-multipath-atomic.json) — observed `aX: 1, bY: 2` both readable after a single update call. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L60)) | 117 |\n| | | Multi-path update is atomic: if any path is denied by rules, the entire update rejects and no path is written | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-update-multipath-rules-denial.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-update-multipath-rules-denial.json) — observed `threw: true, code: 'PERMISSION_DENIED'` AND `okPathWrittenDespiteDenial: false` (the otherwise-permitted path also rolled back). (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L60)) | 118 |\n| | | Setting a key to `null` inside `update` removes that key — same equivalence as `set(ref, null)` | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-update-null-removes-key.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-update-null-removes-key.json) — after `set({a:1,b:2})` then `update({a:null})`, observed `final: {b:2}` with `a` absent. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L60)) | 119 |\n| | | Update path validation — overlapping paths (e.g. `'/a'` and `'/a/x'` in the same call) throws synchronously before any write | ✓ | Oracle [`rtdb-modular-write-return-validation`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-write-return-validation.json) captures a synchronous ancestor-path error and unchanged terminal state; sandbox row assertion `rtdb-modular#120` matches both. (Structured evidence: [`oracle-conformance-reference-writes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L139)) | 120 |\n\n### `remove(ref)` — delete\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | Removes the value AND all children; subsequent `get` returns `null`-val snapshot | ✓ | oracle: [`packages/conformance/observations/rtdb/rtdb-remove-vs-set-null.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-remove-vs-set-null.json) (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L66)) | 121 |\n| | | Idempotent — `remove` on a path that's already absent resolves successfully (no-throw) | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-remove-idempotent.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-remove-idempotent.json) — `remove` on a never-written path observed `threw: false, afterExists: false`. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L66)) | 122 |\n| | | `remove(ref)` and `set(ref, null)` produce the same end state — locks the documented RTDB invariant | ✓ | oracle: [`packages/conformance/observations/rtdb/rtdb-remove-vs-set-null.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-remove-vs-set-null.json) (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L66)) | 123 |\n\n### `push(ref, value?)` — auto-id append\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | `push(ref).key` is a 20-char string starting with `-`, available **synchronously** (client-side mint, no server round-trip required) | ✓ | oracle: [`packages/conformance/observations/rtdb/rtdb-push-autoid-format.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-push-autoid-format.json) (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L69)) | 124 |\n| | | Sequential `push` calls produce monotonically-sortable keys (timestamp-prefixed for chronological ordering via `orderByKey`) | ✓ | oracle: [`packages/conformance/observations/rtdb/rtdb-push-autoid-format.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-push-autoid-format.json) (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L69)) | 125 |\n| | | `push(ref, value)` writes the value AND returns the new child ref (both behaviors in one call); `push(ref)` mints the ref without writing | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-push-with-value.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-push-with-value.json) — `await push(parent, {hello:'world'})` returned a ref with a 20-char key; subsequent `get(r)` returned `{hello:'world'}`. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L69)) | 126 |\n| | | The returned ref `r = push(parent, value)` is usable in follow-up ops: `get(r)`, `set(r, …)`, `remove(r)` | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-push-with-value.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-push-with-value.json) — observed all 4 follow-up ops succeed through the returned ref (`refIsUsableForFollowupOps: true`). (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L69)) | 127 |\n\n### `onValue(ref, cb)` — value-level listener\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | Subscribing to a path with **existing data** fires the listener once with the current snapshot (the \"initial fire\") | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-onvalue-initial-with-data.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onvalue-initial-with-data.json) — observed exactly 1 initial fire within ~46ms of subscribe, snapshot.val() === the seeded payload. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L75)) | 128 |\n| | | Subscribing to a **nonexistent path** still fires the listener once — with a `null`-val snapshot AND `exists() === false`. Matches Firestore's `onSnapshot`-on-missing-doc semantics: prod RTDB does NOT silently skip the initial fire for empty paths. | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-onvalue-initial-no-data.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onvalue-initial-no-data.json) — observed 1 initial fire on a never-written path with `firstFire.val: null, firstFire.exists: false` (~55ms after subscribe). (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L28)) | 129 |\n| | | Subsequent `set(ref, …)` fires the listener with the new value | ✓ | oracle: [`packages/conformance/observations/rtdb/rtdb-onvalue-fires-on-set.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-onvalue-fires-on-set.json) — observed 1 fire per `set()` (1+1+1 = 3 total: initial-null, after-first-set, after-second-set). (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L75)) | 130 |\n| | | Unsubscribe — the returned unsubscribe function stops further fires; subsequent writes produce 0 additional fires after `unsub()` | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-onvalue-unsubscribe.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onvalue-unsubscribe.json) — observed `preUnsubFires: 2, postUnsubFires: 2` (a write performed after `unsub()` produced 0 additional fires within a 500ms settle window). (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L75)) | 131 |\n| | | The returned value from `onValue(ref, cb)` is the unsubscribe function (NOT an object); calling it removes the listener | ✓ | [`unit:modular/listener-lifecycle-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/listener-lifecycle-cdd.test.ts#L208) asserts the return is a function and that invoking it stops subsequent delivery; production behavior is adjacent to oracle-backed row #131. | 132 |\n\n### `onChildAdded` / `onChildChanged` / `onChildRemoved` / `onChildMoved`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | `onChildAdded` replays the existing children on subscribe — one fire per existing child key, in the default priority index with key tie-breaking (unlike `onValue` which fires once with the parent snapshot) | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-onchildadded-initial-replay.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onchildadded-initial-replay.json) — seeded `{k1, k2, k3}`, observed 3 initial fires with `firedKeys: ['k1', 'k2', 'k3']`; the default priority/key order is separately pinned by `rtdb-modular-priority-contract`. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L106)) | 133 |\n| | | After subscribe, adding a child via `push` or `set(child, …)` fires `onChildAdded` exactly once for that key | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-onchildadded-post-subscribe.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onchildadded-post-subscribe.json) — seeded `{k1,k2}`, observed `postSubscribeFires: 1, lastFire: {key:'k3', val:{v:3}}` after writing the new child. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L106)) | 134 |\n| | | `onChildChanged` fires when an existing child's value changes; does NOT fire for added or removed children | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-onchildchanged-fires-on-update.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onchildchanged-fires-on-update.json) — observed `firedOnInitial: 0, firedOnUpdate: 1, lastFire: {key:'k1', val:{v:2}}` (the NEW value, not the prior). (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L86)) | 135 |\n| | | `onChildRemoved` fires when a child is deleted (via `remove(child)` or `set(child, null)`); snapshot carries the PRIOR value | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-onchildremoved-fires-on-delete.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onchildremoved-fires-on-delete.json) — observed `firedOnDelete: 1, removedSnapCarriesPriorValue: true` (snapshot.val() was the pre-delete value). (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L107)) | 136 |\n| | | `onChildMoved` under an explicit ordered query fires when the ordered field changes, co-fires with `child_changed`, and supplies the captured `previousChildName`. | ✓ | Oracle observations [`rtdb-modular-onchildmoved-with-orderby`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onchildmoved-with-orderby.json), [`rtdb-modular-onchildmoved-previouschildname-sequencing`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onchildmoved-previouschildname-sequencing.json), and [`rtdb-modular-childchanged-cofire-with-childmoved`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-childchanged-cofire-with-childmoved.json) are replayed by the modular oracle suite. (Structured evidence: [`oracle-conformance-listeners.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-listeners.test.ts#L152)) | 137 |\n\n### `off(ref, eventType?, callback?)` — unsubscribe variants\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | `off(ref)` removes ALL listeners at that ref (any event type, any callback) | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-off-stops-child-fires.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-off-stops-child-fires.json) — after `off(ref)` with no eventType, a subsequent write produced `postOffFires: 0` against an `onChildAdded` registration. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L108)) | 138 |\n| | | `off(ref, 'value')` removes only `value` listeners at that ref | ✓ | Sandbox aligned (M48); oracle: [`packages/conformance/observations/rtdb/rtdb-off-eventtype-precision.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-off-eventtype-precision.json) — registered TWO `value` listeners + one `child_added` at the same ref; after `off(ref, 'value')` (no callback), `valueListenersStopped: true` (neither value cb fired on subsequent writes) AND `childListenerStillFiringAfterOffValue: true` (the child listener kept firing). `offValueClearsAllValueListeners: true` confirms the no-callback variant removes ALL value listeners at the ref. (Structured evidence: [`rtdb-modular-off-stops-child-fires`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-off-stops-child-fires.json), [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L108)) | 139 |\n| | | `off(ref, 'value', cb)` removes only the specific callback | ✓ | Sandbox aligned (M48); adjacent to #141 — the upstream `off` with the cb argument removes only the matching callback. Same probe (`rtdb-onvalue-unsub-equivalence.json` Case 2) confirms `off(ref, 'value', cb)` stops only that callback. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/oracle-conformance.test.ts#L52)) | 140 |\n| | | The returned unsubscribe function from `onValue(ref, cb)` is equivalent to `off(ref, 'value', cb)` | ✓ | Sandbox aligned (M48); oracle: [`packages/conformance/observations/rtdb/rtdb-onvalue-unsub-equivalence.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-onvalue-unsub-equivalence.json) — `unsubReturnType: 'function'`, `unsubReturnedFnStopsListener: true` (the captured return value halted fires on write), `offRefValueCbStopsListener: true` (the same effect via `off(ref, 'value', cb)`), `bothFormsEquivalent: true`. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L108)) | 141 |\n| off(ref, eventType, callback) | | When the same callback is registered more than once, each `off(ref, eventType, callback)` removes one registration without orphaning the others | ✓ | Oracle [`rtdb-modular-off-duplicate-registration`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-off-duplicate-registration.json) proves each matching `off` removes one registration; sandbox row assertion `rtdb-modular#183` replays the full sequence. (Structured evidence: [`oracle-conformance-reference-writes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L175)) | 183 |\n\n### `query(ref, ...constraints)` + ordering / bounds / limits\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | `query(ref, orderByChild('field'), limitToFirst(N))` returns a `Query` whose `get()` resolves a snapshot containing N children ordered by `field` | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-query-orderbychild-limit.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-query-orderbychild-limit.json) — seeded 4 children with positions `[3,1,4,2]`, observed `orderedKeys: [{key:'a',pos:1}, {key:'b',pos:2}]` (first 2 in ascending order). Requires `.indexOn` declared in rules. (Structured evidence: [`rtdb-modular-orderbychild-window`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-orderbychild-window.json), [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L19)) | 142 |\n| | | `orderByKey()` orders by the auto-id / numeric key | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-orderbykey-window.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-orderbykey-window.json) — seeded `{a,b,c,d,e}` in shuffled insertion order, observed `matchedKeys: ['b','c','d']` for `orderByKey() + startAt('b') + endAt('d')` (in key order). (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L122)) | 143 |\n| | | Production enforces `.indexOn: '.value'` for `orderByValue()` while the sandbox executes the value ordering without index enforcement | ⚠ | Oracle [`rtdb-modular-orderbyvalue-numeric`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-orderbyvalue-numeric.json) rejects the unindexed production query; [`unit:modular/oracle-conformance-queries.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-queries.test.ts#L51) pins the sandbox's successful ordered result instead of treating that contradiction as conformance. | 144 |\n| | | `equalTo(v)` filters children whose ordered field === v (returns 0, 1, or multiple matches — RTDB does NOT enforce uniqueness) | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-query-equalto.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-query-equalto.json) — seeded `{red, blue, blue, green}`, observed `matchedKeys: ['k2', 'k3']` for `equalTo('blue')` (both blue children, none of the others). Additional probe: [`packages/conformance/observations/rtdb-modular/rtdb-modular-equalTo-filter.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-equalTo-filter.json) (a..b..c groups) confirms `equalTo('b')` returns the two `b` children. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L124)) | 145 |\n| | | `startAt(v)` is **inclusive** (the child whose ordered value === v is included) | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-query-startat-inclusive.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-query-startat-inclusive.json) — seeded positions `[1,2,3,4]`, observed `matched: [2,3,4]` for `startAt(2)` (cursor doc included). (Structured evidence: [`rtdb-modular-orderbychild-window`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-orderbychild-window.json), [`rtdb-modular-orderbykey-window`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-orderbykey-window.json), [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L125)) | 146 |\n| | | `endAt(v)` is **inclusive** | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-orderbychild-window.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-orderbychild-window.json) — `startAt(2) + endAt(4)` matched positions `[2,3,4]` (endAt(4) included its boundary value). (Structured evidence: [`rtdb-modular-orderbykey-window`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-orderbykey-window.json), [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L126)) | 147 |\n| | | `startAfter(v)` is **exclusive** | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-startafter-endbefore-exclusive.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-startafter-endbefore-exclusive.json) — `startAfter(2) + endBefore(5)` matched positions `[3,4]` (cursor `2` dropped). (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L127)) | 148 |\n| | | `endBefore(v)` is **exclusive** | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-startafter-endbefore-exclusive.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-startafter-endbefore-exclusive.json) — same probe; cursor `5` dropped. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L128)) | 149 |\n| | | `limitToFirst(N)` caps the result count from the start of the ordered range | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-query-orderbychild-limit.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-query-orderbychild-limit.json) plus [`packages/conformance/observations/rtdb-modular/rtdb-modular-limittofirst-vs-limittolast.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-limittofirst-vs-limittolast.json) (firstPositions `[1,2]`). (Structured evidence: [`rtdb-modular-orderbyvalue-numeric`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-orderbyvalue-numeric.json), [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L26)) | 150 |\n| | | `limitToLast(N)` caps from the end | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-limittofirst-vs-limittolast.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-limittofirst-vs-limittolast.json) — observed `lastKeys: ['d','e'], lastPositions: [4,5]` for `limitToLast(2)` on a 5-child collection ordered by `pos`. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L130)) | 151 |\n| | | Listeners on a `Query` (`onValue(q, …)`) emit only the windowed snapshot — NOT the parent ref's full data | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-onvalue-with-query.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-onvalue-with-query.json) — seeded 3 children, watched first 2 by `pos`; observed 3 fires total: (1) initial `[a,b]`, (2) OUTSIDE-window write to `c/extra` did NOT fire, (3) INSIDE-window mutation of `a` re-fired, (4) new child `z` displaced `b` and re-fired. Outside-window writes are silent. (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L19)) | 152 |\n\n### Sentinels — `serverTimestamp()` / `increment(n)`\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | `serverTimestamp()` resolves server-side to a **number** (epoch milliseconds) — diverges from Firestore's `Timestamp` instance | ✓ | oracle: [`packages/conformance/observations/rtdb/rtdb-servertimestamp-resolves.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-servertimestamp-resolves.json) — observed `createdAtType: 'number', createdAt: 1779075391118` (i.e. a plain JS number, NOT a `Timestamp` object). (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L1)) | 153 |\n| | | `serverTimestamp()` as a field value in `set` or `update` writes the `{\".sv\": \"timestamp\"}` sentinel; the read-back value is the resolved number | ✓ | oracle: [`packages/conformance/observations/rtdb/rtdb-servertimestamp-resolves.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb/rtdb-servertimestamp-resolves.json) — read-back showed `createdAtSentinelShape: false` (sentinel resolved server-side; client sees the number, not the `.sv` placeholder). (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L1)) | 154 |\n| | | `increment(n)` against a **missing** field starts at 0 (so `increment(5)` lands as `5`) | ✓ | Sandbox aligned (modular `increment` export now present): [`unit:modular/increment.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/increment.test.ts#L7) (\"increment against a missing field starts from 0\"); matches oracle [`packages/conformance/observations/rtdb-modular/rtdb-modular-increment-from-missing.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-increment-from-missing.json) — observed `afterFirst: 5` from `increment(5)` against an absent `count` field. | 155 |\n| | | `increment(n)` against an existing numeric field adds atomically; negative deltas subtract | ✓ | Sandbox aligned: [`unit:modular/increment.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/increment.test.ts#L7) (\"subsequent increments accumulate (positive then negative)\" + \"nested inside an update patch resolves per-field\"); matches oracle [`packages/conformance/observations/rtdb-modular/rtdb-modular-increment-from-missing.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-increment-from-missing.json) — observed `afterSecond: 8` (5+3) then `afterNegative: 6` (8-2). | 156 |\n| | | **Divergence:** production interleaves concurrent `increment` writes from independent clients and preserves both deltas; the synchronous in-process backend applies each call before returning its promise, so a `Promise.all` call site is observably serialized even though its terminal value also reaches 5 | ⚠ | Oracle [`rtdb-modular-concurrent-transforms`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-concurrent-transforms.json) uses two independent clients and captures terminal 5 after concurrent +2/+3 increments; [`unit:modular/transaction-contention-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction-contention-cdd.test.ts#L12) pins the sandbox's synchronous first-write visibility and matching terminal value without claiming that terminal equality proves contention semantics. | 157 |\n\n### `runTransaction(ref, transactionUpdate, options?)` — optimistic concurrency\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | Basic success — `runTransaction(ref, current => (current ?? 0) + 1)` resolves `{ committed: true, snapshot }` where `snapshot.val()` is the new value | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-success.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-success.json) — observed `committed: true, snapVal: 1` after running `current => (current ?? 0) + 1` against an empty ref. (Structured evidence: [`rtdb-modular-runtransaction-on-rules-denied-path`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-on-rules-denied-path.json), [`rtdb-modular-runtransaction-options-applylocally`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-options-applylocally.json), [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L140)) | 158 |\n| | | Returning `undefined` from the update fn **aborts** the transaction — resolves `{ committed: false }`, no write performed (RTDB-specific; distinct from Firestore where the only abort path is throwing) | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-abort-undefined.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-abort-undefined.json) — seeded `100` then transaction returned `undefined`; observed `committed: false, snapVal: null, afterValOnServer: 100` (existing value preserved). (Structured evidence: [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L29)) | 159 |\n| | | A cold production client can call the update fn with speculative `null` before the current seeded value; the always-warm sandbox calls it once with the current value | ⚠ | Oracle [`rtdb-modular-runtransaction-current-value-arg`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-current-value-arg.json) captures the cold two-call sequence and `rtdb-modular-runtransaction-warm-client-speculation` captures the matching warm one-call case; [`unit:modular/oracle-conformance-transactions.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-transactions.test.ts#L20) pins both sides. (Structured evidence: [`rtdb-modular-runtransaction-options-applylocally`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-options-applylocally.json), [`rtdb-modular-runtransaction-success`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-success.json)) | 160 |\n| | | Concurrent contention — if another client writes between the read and write, the update fn is retried with the new current value (typically up to 25 retries by default) | ⚠ | Oracle [`rtdb-modular-concurrent-transforms`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-concurrent-transforms.json) captures callback counts [2, 3] across two clients; the synchronous in-process backend serializes the same ordinary calls with [1, 1], pinned by [`unit:modular/transaction-contention-cdd.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction-contention-cdd.test.ts#L12). | 161 |\n| | | Result snapshot's `.val()` reflects the committed value (or the existing value if aborted) | ✓ | oracle: [`packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-success.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-success.json) — observed `snapVal: 1` matching the committed value. (Structured evidence: [`rtdb-modular-runtransaction-returns-committed-snapshot`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-returns-committed-snapshot.json), [`public-operations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/cdd/public-operations.test.ts#L30)) | 162 |\n\n### `goOnline` / `goOffline` — connection control\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | `goOffline(db)` drains that client's onDisconnect queue once, but does not make the in-memory data plane unreachable; ordinary writes, listeners, and `get()` remain available | ⚠ logical disconnect lifecycle is modeled, but the in-memory data plane itself remains available | [`unit:database/on-disconnect.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/on-disconnect.test.ts#L10) + [`unit:modular/fruit-aliases.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/fruit-aliases.test.ts#L17) | 163 |\n| | | `goOnline(db)` reconnects the logical lifecycle without resurrecting drained operations; it is otherwise a no-op because the in-memory data plane never became unreachable | ⚠ logical disconnect lifecycle is modeled, but the in-memory data plane itself remains available | [`unit:database/on-disconnect.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/on-disconnect.test.ts#L11) + [`unit:modular/fruit-aliases.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/fruit-aliases.test.ts#L18) | 164 |\n\n### Transport, logging, and URL-reference exports\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | `forceLongPolling()` — accepted no-op: transport selection is not applicable to the in-process/worker sandbox (it never opens a real socket). Accepted so init code that calls it compiles + runs | ⚠ transport selection not applicable to the in-process/worker sandbox | [`unit:modular/fruit-aliases.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/fruit-aliases.test.ts#L19) | 171 |\n| | | `forceWebSockets()` — accepted no-op: transport selection is not applicable to the in-process/worker sandbox (see `forceLongPolling`) | ⚠ transport selection not applicable to the in-process/worker sandbox | [`unit:modular/fruit-aliases.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/fruit-aliases.test.ts#L20) | 172 |\n| | | `enableLogging(logger?, persistent?)` — accepted no-op: the sandbox has no modular-SDK-style logger to wire a level/sink into (it uses host-level `console` logging directly, matching `pyric/firestore`'s `setLogLevel`). Accepted so init code that calls it compiles + runs | ⚠ accepted no-op; no sandbox logger to wire into | [`unit:modular/fruit-aliases.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/fruit-aliases.test.ts#L21) | 173 |\n| | | **Divergence:** `refFromURL(db, url)` matches Firebase's path parsing, query ignoring, FTP-to-database normalization, and fragment rejection, but the single-database sandbox does not validate the URL host/namespace against the handle; Firebase rejects a mismatched host while the sandbox uses its path. | ⚠ path and validation behavior conform except URL host/namespace matching (single-database sandbox) | Oracle [`rtdb-modular-reference-shape-url`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-reference-shape-url.json) captures accepted FTP/query URLs and rejected fragments, malformed URLs, and host mismatches; [`unit:modular/oracle-conformance-reference-writes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L33) pins the conforming validations plus both sides of the host divergence. | 174 |\n\n## Current gaps\n\n### Documented divergences\n\nKnown differences between Pyric and production Firebase. Each remains tracked as a non-conforming row.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\">Cold production clients may invoke the update fn first with speculative <code>null</code> and then with the current seeded value; the always-warm in-process sandbox invokes it once with the current value</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Oracle <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-current-value-arg.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rtdb-modular-runtransaction-current-value-arg</code></a> captures two seeded-path production invocations (<code>null</code>, then current); <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-transactions.test.ts#L52\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/oracle-conformance-transactions.test.ts</code></a> pins the sandbox's single current-value invocation. The separate warm-client observation matches the sandbox but does not erase the cold-client contract. (Structured evidence: <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction.test.ts#L93\" target=\"_blank\" rel=\"noopener noreferrer\"><code>transaction.test.ts</code></a>)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\">**Divergence:** two ordinary concurrent <code>runTransaction</code> calls are serialized by the in-process backend, so their update functions are not retried with Firebase's captured contention counts. A synchronous re-entrant conflicting write does trigger a deterministic retry.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Oracle <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-concurrent-transforms.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rtdb-modular-concurrent-transforms</code></a> captures invocation counts [2, 3]; <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction-contention-cdd.test.ts#L12\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/transaction-contention-cdd.test.ts</code></a> pins the sandbox's [1, 1] ordinary-concurrency boundary and separately covers its deterministic re-entrant retry seam.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\">Production rejects an unindexed <code>orderByValue() + limitToFirst(N)</code> query; the sandbox does not enforce <code>.indexOn</code> and returns the N smallest primitive values</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Oracle <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-orderbyvalue-numeric.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rtdb-modular-orderbyvalue-numeric</code></a> captures production's <code>Index not defined</code> rejection; <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/queries.test.ts#L108\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/oracle-conformance-queries.test.ts</code></a> pins that rejection beside the sandbox's successful <code>[10,20,30]</code> window. (Structured evidence: <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-queries.test.ts#L51\" target=\"_blank\" rel=\"noopener noreferrer\"><code>oracle-conformance-queries.test.ts</code></a>)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\">**Divergence (pending fix):** clean <code>goOffline</code>, app deletion, playground <code>pagehide</code>, and best-effort MessagePort close drain queued operations, but unannounced total renderer/process loss is not guaranteed by the in-memory sandbox</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Oracle <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-ondisconnect-abrupt-exit.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rtdb-modular-ondisconnect-abrupt-exit</code></a> proves Firebase executes an acknowledged registration after forced writer termination. The two-port worker integration exercises <code>goOffline</code>, served-app deletion, and non-persisted <code>pagehide</code>; browser MessagePort close delivery cannot prove total-process loss without durable host-owned leases. (Structured evidence: <a href=\"https://github.com/davideast/pyric/blob/main/packages/cli/test/serve/worker/rtdb-integration.test.ts#L24\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rtdb-integration.test.ts</code></a>)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\">**Divergence:** <code>DatabaseReference</code> and <code>Query</code> equality includes database target, path, and canonical query parameters like Firebase, but <code>toJSON()</code> serializes the local <code>sandbox://rtdb/...</code> identity instead of Firebase's HTTPS database URL.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Oracle <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-reference-shape-url.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rtdb-modular-reference-shape-url</code></a> captures conforming equality plus HTTPS JSON serialization; <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L33\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/oracle-conformance-reference-writes.test.ts</code></a> pins both the production HTTPS shape and the sandbox's <code>sandbox://</code> boundary.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\">Production enforces <code>.indexOn: '.value'</code> for <code>orderByValue()</code> while the sandbox executes the value ordering without index enforcement</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Oracle <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-orderbyvalue-numeric.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rtdb-modular-orderbyvalue-numeric</code></a> rejects the unindexed production query; <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-queries.test.ts#L51\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/oracle-conformance-queries.test.ts</code></a> pins the sandbox's successful ordered result instead of treating that contradiction as conformance.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\">**Divergence:** production interleaves concurrent <code>increment</code> writes from independent clients and preserves both deltas; the synchronous in-process backend applies each call before returning its promise, so a <code>Promise.all</code> call site is observably serialized even though its terminal value also reaches 5</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Oracle <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-concurrent-transforms.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rtdb-modular-concurrent-transforms</code></a> uses two independent clients and captures terminal 5 after concurrent +2/+3 increments; <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction-contention-cdd.test.ts#L12\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/transaction-contention-cdd.test.ts</code></a> pins the sandbox's synchronous first-write visibility and matching terminal value without claiming that terminal equality proves contention semantics.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\">A cold production client can call the update fn with speculative <code>null</code> before the current seeded value; the always-warm sandbox calls it once with the current value</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Oracle <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-current-value-arg.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rtdb-modular-runtransaction-current-value-arg</code></a> captures the cold two-call sequence and <code>rtdb-modular-runtransaction-warm-client-speculation</code> captures the matching warm one-call case; <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-transactions.test.ts#L20\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/oracle-conformance-transactions.test.ts</code></a> pins both sides. (Structured evidence: <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-options-applylocally.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rtdb-modular-runtransaction-options-applylocally</code></a>, <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-success.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rtdb-modular-runtransaction-success</code></a>)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\">Concurrent contention — if another client writes between the read and write, the update fn is retried with the new current value (typically up to 25 retries by default)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Oracle <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-concurrent-transforms.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rtdb-modular-concurrent-transforms</code></a> captures callback counts [2, 3] across two clients; the synchronous in-process backend serializes the same ordinary calls with [1, 1], pinned by <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/transaction-contention-cdd.test.ts#L12\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/transaction-contention-cdd.test.ts</code></a>.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>goOffline(db)</code> drains that client's onDisconnect queue once, but does not make the in-memory data plane unreachable; ordinary writes, listeners, and <code>get()</code> remain available</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/on-disconnect.test.ts#L10\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:database/on-disconnect.test.ts</code></a> + <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/fruit-aliases.test.ts#L17\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/fruit-aliases.test.ts</code></a></div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>goOnline(db)</code> reconnects the logical lifecycle without resurrecting drained operations; it is otherwise a no-op because the in-memory data plane never became unreachable</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/on-disconnect.test.ts#L11\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:database/on-disconnect.test.ts</code></a> + <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/fruit-aliases.test.ts#L18\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/fruit-aliases.test.ts</code></a></div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>forceLongPolling()</code> — accepted no-op: transport selection is not applicable to the in-process/worker sandbox (it never opens a real socket). Accepted so init code that calls it compiles + runs</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/fruit-aliases.test.ts#L19\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/fruit-aliases.test.ts</code></a></div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>forceWebSockets()</code> — accepted no-op: transport selection is not applicable to the in-process/worker sandbox (see <code>forceLongPolling</code>)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/fruit-aliases.test.ts#L20\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/fruit-aliases.test.ts</code></a></div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>enableLogging(logger?, persistent?)</code> — accepted no-op: the sandbox has no modular-SDK-style logger to wire a level/sink into (it uses host-level <code>console</code> logging directly, matching <code>pyric/firestore</code>'s <code>setLogLevel</code>). Accepted so init code that calls it compiles + runs</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/fruit-aliases.test.ts#L21\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/fruit-aliases.test.ts</code></a></div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><span class=\"compat-sub\"><span class=\"compat-behavior\">**Divergence:** <code>refFromURL(db, url)</code> matches Firebase's path parsing, query ignoring, FTP-to-database normalization, and fragment rejection, but the single-database sandbox does not validate the URL host/namespace against the handle; Firebase rejects a mismatched host while the sandbox uses its path.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Oracle <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-modular/rtdb-modular-reference-shape-url.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rtdb-modular-reference-shape-url</code></a> captures accepted FTP/query URLs and rejected fragments, malformed URLs, and host mismatches; <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/database/modular/oracle-conformance-reference-writes.test.ts#L33\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:modular/oracle-conformance-reference-writes.test.ts</code></a> pins the conforming validations plus both sides of the host divergence.</div></div></details>\n</div>\n" }, { "slug": "storage-compat", "label": "Storage", "title": "pyric/storage compatibility matrix", "markdown": "<!-- Generated from the conformance model (registry rows + surface contracts). Do not edit by hand; run bun run compat:generate. -->\n\n# `pyric/storage` compatibility matrix\n\n<div class=\"compat-stat\">\n<p class=\"compat-stat-figure\">\n<span class=\"compat-stat-pct\">77.1%</span>\n<span class=\"compat-stat-label\">of the public API supported</span>\n</p>\n<div class=\"compat-meters\">\n<span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 77.1%\"></span></span>\n</div>\n<p class=\"compat-stat-denom\">27 of 35 public API</p>\n</div>\n\n\n## Status legend\n\n| Status | Meaning |\n|---|---|\n| ✓ | **Conforming** — sandbox matches prod, locked by a passing probe |\n| ⚠ | **Diverged (documented)** — intentional difference with a written reason |\n| — | **Not implemented yet** — deliberately or pending |\n\n## `getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?)` — initializer\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?) | | `getStorageSandbox(ctx)` returns a tagged sandbox-target handle (frozen identity) | ✓ | [`unit:service.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/service.test.ts#L15) | 1 |\n| getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?) | | `getStorageSandbox(sandbox)` wraps a bare Sandbox with an anonymous context (`auth: null`) | ✓ | [`unit:service.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/service.test.ts#L15) | 2 |\n| getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?) | | `getStorage(app)` returns the sandbox handle selected by package resolution | ✓ | `entry-path:storage` runs the canonical `initializeApp` → `getStorage(app)` → `ref` → `uploadBytes` flow (Structured evidence: [`storage.ts`](https://github.com/davideast/pyric/blob/main/packages/conformance/entry-path/storage.ts#L6)) | 3 |\n| getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?) | | Two `getStorageSandbox(ctx)` calls on the same context return the SAME wrapper (identity-stable) | ✓ | [`unit:service.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/service.test.ts#L15) (\"returns the same handle for repeated calls on the same context\") | 4 |\n| getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?) | | Two `getStorageSandbox(sandbox)` calls on a bare `Sandbox` return the SAME wrapper (identity-stable) | ✓ | ST-B3 fixed: `withAuth(null)` mints a fresh context per call, so the per-context cache missed and bare-Sandbox calls returned different handles. A `Sandbox`-keyed cache makes the convenience path stable, matching the docstring. Probe: [`unit:service.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/service.test.ts#L15) (\"ST-B3: returns the same handle for repeated bare-Sandbox calls\"). | 4a |\n| getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?) | | Two different `SandboxContext`s on the same `Sandbox` get DIFFERENT handles but share the underlying `StorageService` (IDB) | ✓ | [`unit:service.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/service.test.ts#L4) (\"shares the underlying StorageService across contexts on the same sandbox\") | 5 |\n| getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?) | | `options.bucket` round-trips on metadata records; v1 has a single implicit bucket but the field is preserved | ✓ | [`unit:service.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/service.test.ts#L4) (\"records the bucket value on the handle\") | 6 |\n| getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?) | | `options.dbName` honored on the FIRST call per `Sandbox`; second-call overrides ignored | ✓ | [`unit:service.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/service.test.ts#L4) (\"dbName only takes effect on the sandbox's first getStorage call\") | 7 |\n| getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?) | | `options.rules` parsed eagerly — malformed rules throw `SyntaxError` at config time | ✓ | [`unit:storage/sandbox/rules.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/sandbox/rules.test.ts#L1) (parse errors propagate from `parseStorageRules`) | 8 |\n| getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?) | | The `TARGET_SYMBOL` brand keeps each handle bound to its owning sandbox service and identity | ✓ | [`unit:service.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/service.test.ts#L4) (distinct contexts share one service while retaining distinct handles) | 9 |\n| getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?) | | Unrecognized handle (not produced by a factory) → `TypeError` \"not a FirebaseStorage handle\" | ✓ | [`unit:service.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/service.test.ts#L4) (\"rejects an object that was not produced by a factory\") | 10 |\n| getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?) | | `getStorage(app, bucketUrl?)` accepts Firebase's bucket argument; the sandbox remains bound to its configured single bucket | ⚠ | Package resolution owns production selection. The sandbox accepts the canonical argument but does not model production multi-bucket routing; [`unit:service.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/service.test.ts#L4) pins the configured `pyric-default` bucket. | 11 |\n| getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?) | | The served `firebase/storage` entry accepts bare `getStorage()` and returns the page's shared sandbox handle | ✓ | The canonical served entry supplies the page sandbox when the app argument is omitted; the entry-path and bundler suites execute the public package shape. (Structured evidence: [`storage.ts`](https://github.com/davideast/pyric/blob/main/packages/conformance/entry-path/storage.ts#L6), [`bundler.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/serve/bundler.test.ts#L448)) | 12 |\n\n## `ref(storage[, path])` / `ref(parent, path)` — reference constructor\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| ref(storage[, path])` / `ref(parent, path) | | `ref(storage)` returns the root ref — `fullPath === ''`, `name === ''`, `parent === null`, `root === self` | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L2) (\"root reference has empty fullPath, null parent, and equal root\") | 13 |\n| ref(storage[, path])` / `ref(parent, path) | | `ref(storage, 'sessions/s1.json')` populates `fullPath`, `name` (last segment), `parent` (path without last segment) | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L2) (\"ref(storage, path) populates fullPath and name from the last segment\") | 14 |\n| ref(storage[, path])` / `ref(parent, path) | | Path normalization: leading slashes stripped (`/sessions/s1` → `sessions/s1`) | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L2) (\"normalizes leading/trailing/double slashes\") | 15 |\n| ref(storage[, path])` / `ref(parent, path) | | Path normalization: trailing slashes stripped | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L2) | 16 |\n| ref(storage[, path])` / `ref(parent, path) | | Path normalization: repeated internal slashes collapsed (`a//b` → `a/b`) | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L2) | 17 |\n| ref(storage[, path])` / `ref(parent, path) | | `ref(parent, child)` joins relative to parent's `fullPath` | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L2) (\"ref(parent, child) joins relative to the parent\") | 18 |\n| ref(storage[, path])` / `ref(parent, path) | | `parent` chain walks back to root (each `.parent` strips one segment until empty, then `null`) | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L2) (\"parent traversal walks back to root\") | 19 |\n| ref(storage[, path])` / `ref(parent, path) | | `root` accessor returns the bucket-root ref regardless of starting depth | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L2) | 20 |\n| ref(storage[, path])` / `ref(parent, path) | | `toString()` returns `gs://<bucket>/<fullPath>` | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L2) (\"toString returns gs://bucket/path\") | 21 |\n| ref(storage[, path])` / `ref(parent, path) | | Reference identity: two `ref(s, 'a/b')` calls are equal-by-`toString` but NOT `===` (value objects, not interned) | ✓ | (implicit in [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L2) parent-chain test — each `.parent` returns a fresh object) | 22 |\n| ref(storage[, path])` / `ref(parent, path) | | References are mirror-owned value objects; `parent` and `root` preserve the same storage handle and path semantics | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L2) pins parent traversal, root identity, bucket, and path behavior through the public reference interface. | 23 |\n\n## `uploadBytes(ref, data, metadata?)` — write blob\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| uploadBytes(ref, data, metadata?) | | Accepts `Blob` payload; returns `UploadResult` with populated `metadata` | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L9) (\"accepts a Blob and round-trips through getBlob\") | 24 |\n| uploadBytes(ref, data, metadata?) | | Accepts `Uint8Array` payload | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L9) (\"accepts a Uint8Array\") | 25 |\n| uploadBytes(ref, data, metadata?) | | Accepts `ArrayBuffer` payload | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L9) (\"accepts an ArrayBuffer\") | 26 |\n| uploadBytes(ref, data, metadata?) | | ContentType precedence: caller's `metadata.contentType` > `Blob.type` > `application/octet-stream` | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L9) (\"metadata.contentType overrides the Blob's intrinsic type\" + \"falls back to application/octet-stream when no type is supplied\") | 27 |\n| uploadBytes(ref, data, metadata?) | | `Blob.type === ''` (no intrinsic type) falls through to `application/octet-stream`, NOT to `''` | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L9) (\"falls back to application/octet-stream when no type is supplied\") | 28 |\n| uploadBytes(ref, data, metadata?) | | `customMetadata` round-trips through the upload pipeline | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L9) (\"round-trips customMetadata\") + [`unit:metadata.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/metadata.test.ts#L5) | 29 |\n| uploadBytes(ref, data, metadata?) | | Empty `Blob.type` rewrap: when caller hint differs from `Blob.type`, the blob is re-wrapped with the caller's type (same bytes) | ✓ | implicit in [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L9) (\"metadata.contentType overrides the Blob's intrinsic type\") | 30 |\n| uploadBytes(ref, data, metadata?) | | Throws `storage/invalid-root-operation` when called on the root reference | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L9) (\"throws on root reference\") | 31 |\n| uploadBytes(ref, data, metadata?) | | Returned `metadata.fullPath` matches the ref's `fullPath` | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L9) | 32 |\n| uploadBytes(ref, data, metadata?) | | Returned `metadata.size` matches the input blob's byte length | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L9) | 33 |\n| uploadBytes(ref, data, metadata?) | | Returned `metadata.bucket` matches the storage handle's bucket | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L9) | 34 |\n| uploadBytes(ref, data, metadata?) | | Replaces any existing object at the path (overwrite, not append) | ✓ | [`unit:upstream-storage-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/upstream-storage-probes.test.ts#L19) (\"second uploadBytes at the same path replaces bytes and metadata\") | 35 |\n| uploadBytes(ref, data, metadata?) | | Prod: round-trips uploaded bytes through `getDownloadURL` + fetch (byte-for-byte equality) | ✓ (prod-only) | oracle: [`packages/conformance/observations/storage/storage-upload-bytes-roundtrip.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-upload-bytes-roundtrip.json) (against blockingfun, fb-js-sdk 12.13.0: 6-byte payload → uploadBytes → getDownloadURL → HTTPS fetch → `bytesMatch: true`, `urlIsHttps: true`, `bodyLen === payloadLen === 6`). This row records the production answer key; row #51 compares the sandbox's page-local URL behavior against it. | 36 |\n| uploadBytes(ref, data, metadata?) | | Returned `metadata.contentType` matches what the caller hinted (when set) | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L9) + oracle: [`packages/conformance/observations/storage/storage-upload-then-getmetadata.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-upload-then-getmetadata.json) (`contentType: 'application/octet-stream'` round-trip against blockingfun, fb-js-sdk 12.13.0; `contentTypeMatches: true`) | 37 |\n| uploadBytes(ref, data, metadata?) | | Returned `metadata.generation` / `metageneration` are stringified counters (`'1'` after fresh upload) | ✓ | [`unit:metadata.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/metadata.test.ts#L5) | 38 |\n\n## `uploadString(ref, value, format?, metadata?)` — write string-form\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| uploadString(ref, value, format?, metadata?) | | `format='raw'` (default): UTF-8 encodes the string; `contentType` defaults to `text/plain;charset=utf-8` | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L11) (\"raw format encodes UTF-8 and defaults contentType to text/plain\") | 39 |\n| uploadString(ref, value, format?, metadata?) | | `format='base64'`: decodes payload bytes from standard base64 | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L11) (\"base64 format decodes payload bytes\") | 40 |\n| uploadString(ref, value, format?, metadata?) | | Sandbox: `format='base64url'` (or any unknown format) rejected with `storage/invalid-format` naming the bad format. Prod: `base64url` is ACCEPTED (upload succeeds); a genuinely-unrecognized format throws `storage/unknown` | ⚠ | divergence, both halves oracle-locked by [`packages/conformance/observations/storage/storage-uploadstring-unknown-format.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-uploadstring-unknown-format.json): prod accepts `base64url` (`base64urlOk: true`) and throws `storage/unknown` for an unrecognized format — not `storage/invalid-format`. The v1 sandbox ships only `raw`/`base64`/`data_url` (matches `StringFormat`) and throws `storage/invalid-format` for anything else (ST-B3 replaced the old mis-parse-as-data_url behavior). Both sides pinned in [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/oracle-conformance.test.ts#L129); sandbox code path documented in `upload.ts`'s `decodeString`. Implementing base64url decoding is still one line in `decodeString`. (Structured evidence: [`error-codes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/error-codes.test.ts#L25)) | 41 |\n| uploadString(ref, value, format?, metadata?) | | `format='data_url'`: parses `data:<mime>;base64,<payload>`, infers `contentType` from prefix | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L11) (\"data_url format infers contentType from the prefix\") | 42 |\n| uploadString(ref, value, format?, metadata?) | | `format='data_url'` with non-base64 payload: percent-decodes the body | ✓ | [`unit:upstream-storage-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/upstream-storage-probes.test.ts#L20) (\"non-base64 data_url percent-decodes the body\"; malformed `%%0` → `storage/invalid-format`) | 43 |\n| uploadString(ref, value, format?, metadata?) | | Caller's `metadata.contentType` beats data_url inference | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L11) (\"caller metadata.contentType beats data_url inference\") | 44 |\n| uploadString(ref, value, format?, metadata?) | | Malformed `data_url` (no comma / doesn't start with `data:`) throws `TypeError` with \"data_url format\" message | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L11) (\"throws on malformed data_url\") | 45 |\n| uploadString(ref, value, format?, metadata?) | | `uploadString(ref, value, 'base64')` round-trips via `getDownloadURL` + fetch across in-page sandbox and SharedWorker mode | ✓ | oracle: [`packages/conformance/observations/storage/storage-uploadstring-base64-roundtrip.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-uploadstring-base64-roundtrip.json) (`'aGVsbG8='` → `'hello'` against blockingfun, fb-js-sdk 12.13.0; `textMatches: true`). Sandbox oracle replay matches production behavior in-page, and client↔host integration proves SharedWorker mode decodes and transfers string payloads faithfully over the worker port. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/oracle-conformance.test.ts#L192), [`integration.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/serve/worker/integration.test.ts#L252)) | 46 |\n\n## `uploadBytesResumable(ref, data, metadata?)` — resumable upload + task observers\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| uploadBytesResumable(ref, data, metadata?) | | Exported by `firebase/storage`; returns an `UploadTask` with `pause()` / `resume()` / `cancel()` | ⚠ | [`unit:resumable-upload.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/resumable-upload.test.ts#L14) confirms `uploadBytesResumable(ref, data)` returns an `UploadTask` supporting `pause()`, `resume()`, and `cancel()` over synthetic microtask steps. | 47 |\n| uploadBytesResumable(ref, data, metadata?) | | `task.on('state_changed', next, error, complete)` fires `next` with `{bytesTransferred, totalBytes, state}` snapshots | ⚠ | [`unit:resumable-upload.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/resumable-upload.test.ts#L14) confirms synthetic progress snapshots are emitted during task execution without requiring network transfer. | 48 |\n| uploadBytesResumable(ref, data, metadata?) | | `task.pause()` flips `state` to `'paused'`; `task.resume()` continues | ⚠ | [`unit:resumable-upload.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/resumable-upload.test.ts#L14) confirms calling `task.pause()` and `task.resume()` transitions through `'paused'` and `'running'` states deterministically. | 49 |\n| uploadBytesResumable(ref, data, metadata?) | | `task.cancel()` rejects the upload with `storage/canceled` | ⚠ | [`unit:resumable-upload.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/resumable-upload.test.ts#L14) confirms cancellation rejects with `storage/canceled` and leaves no falsely completed object in storage. | 50 |\n\n## `getDownloadURL(ref)` — read URL\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getDownloadURL(ref) | | Exported by `firebase/storage`; returns a token-signed HTTPS URL that fetches the blob | ⚠ | Implemented with a two-sided pin. Production observation [`storage-upload-bytes-roundtrip`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-upload-bytes-roundtrip.json) records `urlIsHttps: true` and a byte-identical fetch. The sandbox oracle replay now calls the same public `getDownloadURL` + `fetch` path and proves byte-identical content, while explicitly asserting its URL starts with [`blob:`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/oracle-conformance.test.ts#L170). The client↔host integration proves SharedWorker mode creates that URL in the calling page after the rules-checked Blob crosses the port. The remaining divergence is URL identity and lifetime: the sandbox URL is a page-local snapshot, not token-signed HTTPS and not shareable outside that page. (Structured evidence: [`integration.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/serve/worker/integration.test.ts#L237)) | 51 |\n| getDownloadURL(ref) | | Throws `storage/object-not-found` for missing objects | ✓ | Production observation [`storage-delete-then-get-throws`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-delete-then-get-throws.json) records `getDownloadURL` throwing `storage/object-not-found` after deletion. The sandbox oracle replay now invokes `getDownloadURL` itself and matches that code; the public error-code suite also pins the never-existing-object case. (Structured evidence: [`error-codes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/error-codes.test.ts#L28), [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/oracle-conformance.test.ts#L110)) | 52 |\n\n## `getBytes(ref, maxDownloadSize?)` — read as ArrayBuffer\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getBytes(ref, maxDownloadSize?) | | Returns the blob's contents as an `ArrayBuffer` | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L12) (\"accepts a Uint8Array\" round-trip via `getBytes`) | 53 |\n| getBytes(ref, maxDownloadSize?) | | Throws `storage/object-not-found` when no object exists at the path | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L12) (\"throws storage/object-not-found for missing paths\") + oracle: [`packages/conformance/observations/storage/storage-delete-then-get-throws.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-delete-then-get-throws.json) (against blockingfun, fb-js-sdk 12.13.0: upload → delete → `getDownloadURL` on the deleted ref throws `FirebaseError` with `code: 'storage/object-not-found'`) | 54 |\n| getBytes(ref, maxDownloadSize?) | | When the object exceeds `maxDownloadSize`, returns a truncated prefix of that byte length (does not throw) | ✓ | [`unit:upstream-storage-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/upstream-storage-probes.test.ts#L15) (\"getBytes / getBlob return a truncated prefix when the object exceeds the cap\"). Matches upstream `getBytesInternal` / `getBlobInternal` post-fetch slice (GCS may ignore Range on small files). Prior COMPAT claim that the cap throws was wrong. | 55 |\n| getBytes(ref, maxDownloadSize?) | | Just-under-cap reads succeed and return the full byte length | ✓ | [`unit:upstream-storage-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/upstream-storage-probes.test.ts#L15) (\"just-under-cap reads return the full object\") + [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L12) (\"honors maxDownloadSizeBytes when the blob is too large\") | 56 |\n| getBytes(ref, maxDownloadSize?) | | Throws `storage/invalid-root-operation` when called on the root reference | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L12) (\"throws invalid-root-operation on root reads\") | 57 |\n\n## `getBlob(ref, maxDownloadSize?)` — read as Blob\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getBlob(ref, maxDownloadSize?) | | Returns the stored bytes wrapped as a `Blob` (with `.type` from metadata) | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L12) (\"accepts a Blob and round-trips through getBlob\") | 58 |\n| getBlob(ref, maxDownloadSize?) | | Throws `storage/object-not-found` for missing paths | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L12) (\"throws storage/object-not-found for missing paths\") | 59 |\n| getBlob(ref, maxDownloadSize?) | | Honors `maxDownloadSize` same as `getBytes` | ✓ | [`unit:upstream-storage-probes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/upstream-storage-probes.test.ts#L14) (\"getBytes / getBlob return a truncated prefix when the object exceeds the cap\"; shared `fetchBlob` helper in `download.ts`) | 60 |\n| getBlob(ref, maxDownloadSize?) | | Root-ref read throws `storage/invalid-root-operation` | ✓ | shared via `guardNonRoot` in `download.ts` | 61 |\n\n## `getStream(ref, maxDownloadSize?)` — Node-specific\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getStream(ref, maxDownloadSize?) | | Exported by `firebase/storage` (Node entry only); returns a Node `Readable` | — | No `pyric/storage` implementation or behavioral probe exists; availability is classified by the owning surface contract. | 62 |\n\n## `deleteObject(ref)` — delete\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| deleteObject(ref) | | Removes both the blob AND the metadata atomically (post-delete `getBlob` throws `object-not-found`) | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L14) (\"removes both blob and metadata\") | 63 |\n| deleteObject(ref) | | Sandbox: no-op on missing path (does NOT throw) | ⚠ | divergence: sandbox is no-op via `persistence.ts`'s `delete`. Prod's `deleteObject` on a missing path throws `storage/object-not-found`. Oracle-locked: [`packages/conformance/observations/storage/storage-delete-missing-throws.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-delete-missing-throws.json) (`code: 'storage/object-not-found'`, `name: 'FirebaseError'` against blockingfun, fb-js-sdk 12.13.0). Both sides pinned in [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/oracle-conformance.test.ts#L91); documented in `download.ts`. | 64 |\n| deleteObject(ref) | | Throws `storage/invalid-root-operation` on the root reference | ✓ | [`unit:reference.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/reference.test.ts#L14) (\"throws invalid-root-operation on root\") | 65 |\n| deleteObject(ref) | | Prod: a successful `deleteObject` followed by `getDownloadURL` on the same ref throws `storage/object-not-found` | ✓ (prod-only) | oracle: [`packages/conformance/observations/storage/storage-delete-then-get-throws.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-delete-then-get-throws.json) (against blockingfun, fb-js-sdk 12.13.0: upload + delete succeed, then `getDownloadURL` throws `code: 'storage/object-not-found'`, message `\"Firebase Storage: Object '…' does not exist.\"`, `isFirebaseError: true`) | 66 |\n| deleteObject(ref) | | Sandbox: writes-then-delete leaves no metadata (post-delete `getMetadata` throws `object-not-found`) | ✓ | follows from #63 + `getMetadata` | 67 |\n\n## `listAll(ref)` — list all children under a ref\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| listAll(ref) | | Returns `ListResult` with `items` (direct child files) + `prefixes` (sub-folder refs) + `nextPageToken: undefined` | ✓ | [`unit:list.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/list.test.ts#L2) | 68 |\n| listAll(ref) | | Empty bucket → both arrays empty, `nextPageToken: undefined` | ✓ | [`unit:list.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/list.test.ts#L2) (\"returns empty arrays on an empty bucket\") | 69 |\n| listAll(ref) | | Direct children only — does NOT recurse into grandchildren as items | ✓ | [`unit:list.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/list.test.ts#L2) (\"does not recurse into grandchildren as items\") | 70 |\n| listAll(ref) | | Sub-folders surface as `prefixes` and are deduplicated (many files under one folder → ONE prefix entry) | ✓ | [`unit:list.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/list.test.ts#L2) (\"promotes sub-folders into prefixes (deduplicated)\") | 71 |\n| listAll(ref) | | `items` sorted by path (IDB key order, lexicographic) | ✓ | [`unit:list.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/list.test.ts#L2) (\"lists direct children of a folder\") | 72 |\n| listAll(ref) | | `prefixes` sorted lexicographically by `fullPath` (for determinism) | ✓ | [`unit:list.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/list.test.ts#L2) (root-scan example asserts `configs` < `sessions`) | 73 |\n| listAll(ref) | | The scanned ref itself is NEVER included in `items` (even when an object exists at the exact prefix path) | ✓ | [`unit:list.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/list.test.ts#L2) (\"does not include the scanned ref itself\") | 74 |\n| listAll(ref) | | `listAll(ref(storage))` (root) scans the entire bucket | ✓ | [`unit:list.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/list.test.ts#L2) (\"listAll on the root scans the entire bucket\") | 75 |\n| listAll(ref) | | Items expose the full `StorageReference` shape (storage, bucket, name, parent) | ✓ | [`unit:list.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/list.test.ts#L2) (\"items expose the StorageReference shape\") | 76 |\n| listAll(ref) | | Prod: items + prefixes shape matches sandbox after `N` uploads under a directory | ✓ | oracle: [`packages/conformance/observations/storage/storage-listall-shape.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-listall-shape.json) (against blockingfun, fb-js-sdk 12.13.0: 3 direct children + 1 grandchild → `items` has all 3 direct children sorted, `prefixes` has the single sub-folder, `itemCount: 3`, `prefixCount: 1`, `threeDirectChildren: true`, `oneSubPrefix: true`) | 77 |\n| listAll(ref) | | `listAll` enforces rules: `read` permission on the scanned prefix path governs list (Firebase: `read` covers download AND list), denied prefix → `storage/unauthorized` | ✓ | ST-B2 fixed: `list.ts` now calls `enforceRules` with `method: 'read'` on the listed prefix (was a silent bypass — a denied tree was still fully enumerable). With no rules configured the check is a no-op. Probe: [`unit:list-rules.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/list-rules.test.ts#L2) (\"denies an anonymous listAll of a tree the rules protect\" / \"allows an authed listAll\"). Note: a `read` rule scoped to `match /sessions/{id}` does NOT grant list on `/sessions` — the folder needs its own read rule, matching prod; the session-archive demo ruleset adds `match /sessions { allow read }`. | 77a |\n\n## `list(ref, options?)` — paginated list\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| list(ref, options?) | | Exported by `firebase/storage`; accepts `{ maxResults, pageToken }`, returns a `ListResult` with `nextPageToken` set when more pages remain | — | No paginated `list` implementation or behavioral probe exists; availability is classified by the owning surface contract. `ListResult.nextPageToken` remains optional for forward-compatible consumer code. | 78 |\n\n## `getMetadata(ref)` / `updateMetadata(ref, metadata)` — metadata ops\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | `getMetadata(ref)` returns the same `FullMetadata` shape `uploadBytes` produced | ✓ | [`unit:metadata.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/metadata.test.ts#L2) (\"returns the FullMetadata uploadBytes wrote\") | 79 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | `getMetadata(ref)` throws `storage/object-not-found` for missing paths | ✓ | [`unit:metadata.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/metadata.test.ts#L2) (\"throws object-not-found for missing paths\") | 80 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | `getMetadata(ref)` throws `storage/invalid-root-operation` on the root | ✓ | [`unit:metadata.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/metadata.test.ts#L2) (\"throws invalid-root-operation on the root reference\") | 81 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | `updateMetadata(ref, patch)` replaces the listed client-settable fields wholesale (per Firebase semantics) | ✓ | [`unit:metadata.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/metadata.test.ts#L2) (\"replaces settable fields, bumps metageneration, refreshes updated\") | 82 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | `updateMetadata` bumps `metageneration` by 1 on each call | ✓ | [`unit:metadata.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/metadata.test.ts#L2) | 83 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | `updateMetadata` refreshes `updated` to the call moment; `timeCreated` and `generation` stay pinned | ✓ | [`unit:metadata.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/metadata.test.ts#L2) | 84 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | `updateMetadata` preserves the blob bytes (only metadata changes) | ✓ | [`unit:metadata.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/metadata.test.ts#L2) (\"leaves the blob content untouched\") | 85 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | `updateMetadata` with `undefined` field values preserves the prior value (does NOT clear it) | ⚠ | divergence: prod accepts `null` to explicitly clear a field. Sandbox doesn't model `null`-clear (per `metadata.ts` doc comment). Documented; not probe-locked. | 86 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | `updateMetadata` throws `storage/object-not-found` for missing paths | ✓ | [`unit:metadata.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/metadata.test.ts#L2) (\"throws object-not-found when the path is missing\") | 87 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | `updateMetadata` throws `storage/invalid-root-operation` on the root | ✓ | [`unit:metadata.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/metadata.test.ts#L2) (\"throws invalid-root-operation on the root reference\") | 88 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | Prod: `getMetadata` after `uploadBytes` returns `contentType` and `size` matching what was uploaded | ✓ (prod-only) | oracle: [`packages/conformance/observations/storage/storage-upload-then-getmetadata.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-upload-then-getmetadata.json) (against blockingfun, fb-js-sdk 12.13.0: upload 128-byte payload with `contentType: 'application/octet-stream'`, getMetadata returns `metadataSize: 128`, `metadataContentType: 'application/octet-stream'`, `metadataBucket: 'blockingfun.firebasestorage.app'`, `metadataMetageneration: '1'`, `fullPathMatches: true`) | 89 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | Prod: `updateMetadata({customMetadata: {...}})` round-trips through a follow-up `getMetadata` | ✓ (prod-only) | oracle: [`packages/conformance/observations/storage/storage-update-metadata-roundtrip.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-update-metadata-roundtrip.json) (against blockingfun, fb-js-sdk 12.13.0: post-update `getMetadata` returns the exact `customMetadata` object, `metageneration` bumps `'1'` → `'2'`, `customSurvived: true`, `metagenerationBumped: true`) | 90 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | `FullMetadata.md5Hash` populated on uploads | ⚠ | divergence: sandbox does NOT compute `md5Hash`. Oracle-locked: [`packages/conformance/observations/storage/storage-upload-then-getmetadata.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-upload-then-getmetadata.json) confirms prod sets `md5Hash` (`hasMd5Hash: true` after a vanilla `uploadBytes`). Both sides pinned in [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/oracle-conformance.test.ts#L207). Aligning the sandbox is a one-spot fix in `upload.ts`'s `buildStoredMetadata`. | 91 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | `FullMetadata.ref` lazy population (prod populates lazily) | — | not modeled in `pyric/storage` — `metadata.ts` explicitly omits `ref` from `FullMetadata` | 92 |\n\n## `connectStorageEmulator(storage, host, port)` — emulator hook\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| connectStorageEmulator(storage, host, port) | | Exported by `firebase/storage`; reroutes a `FirebaseStorage` handle to a local emulator | — | not implemented in `pyric/storage` — the sandbox IS the local-target alternative; emulator parity is out of scope per `index.ts` | 93 |\n\n## Op-level rules enforcement — a denied op throws `storage/unauthorized`\n\nThese are Storage SDK behaviors: how an upload / read / metadata / delete op\nsurfaces a rules deny verdict. The rules-engine fidelity rows\n(`parseStorageRules` / `evaluateStorageRules` vs the production Rules Test\nAPI) moved to the native `storage-rules` surface (`docs/rules/COMPAT.md`).\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Rules enforcement | | Op-level enforcement: `uploadBytes` against a denied path throws `storage/unauthorized` on sandbox / `storage/unauthorized` on prod, `.code` exposed on both | ✓ | ST-B1 fixed: sandbox now throws a `StorageError` (see `src/storage/errors.ts`) whose `.code === 'storage/unauthorized'` — matching prod's `FirebaseError.code`. Probe: [`unit:error-codes.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/error-codes.test.ts#L15) (\"unauthorized when rules deny the operation\"). Residual divergence (documented, not a `.code` gap): the sandbox `StorageError.name` is `'StorageError'` (plain `Error` subclass, same shape as Firestore's `SandboxError`) where prod reports `name: 'FirebaseError'` / `isFirebaseError: true`, and the message wording differs (sandbox embeds the matched-rule reason chain). Oracle-locked: [`packages/conformance/observations/storage/storage-rules-denied-error-code.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-rules-denied-error-code.json) (against blockingfun, fb-js-sdk 12.13.0: `code: 'storage/unauthorized'`, message `\"Firebase Storage: User does not have permission to access '<path>'.\"`, `name: 'FirebaseError'`, `isFirebaseError: true`). | 105 |\n| Rules enforcement | | `getMetadata` against a denied path throws `storage/unauthorized` | ✓ | [`unit:storage/enforce.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/enforce.test.ts#L4) (operation-integration section) | 106 |\n| Rules enforcement | | `updateMetadata` against a denied path throws `storage/unauthorized` | ✓ | [`unit:storage/enforce.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/enforce.test.ts#L4) | 107 |\n| Rules enforcement | | `deleteObject` against a denied path throws `storage/unauthorized` | ✓ | [`unit:storage/enforce.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/enforce.test.ts#L4) | 108 |\n| Rules enforcement | | Ordinary SDK object paths are evaluated under Firebase Storage's canonical `/b/{bucket}/o/{object}` rules namespace while metadata preserves the ordinary object path | ✓ | [`unit:storage/enforce.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/enforce.test.ts#L4) (\"maps ordinary SDK object paths into the canonical bucket rules namespace\"). Production's canonical `/b/{bucket}/o/...` namespace is independently captured on the `storage-rules` surface; this adapter mapping seam is unit-backed rather than presented as a production observation. | 109 |\n\n## Current gaps\n\n### Documented divergences\n\nKnown differences between Pyric and production Firebase. Each remains tracked as a non-conforming row.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?)</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>getStorage(app, bucketUrl?)</code> accepts Firebase's bucket argument; the sandbox remains bound to its configured single bucket</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Package resolution owns production selection. The sandbox accepts the canonical argument but does not model production multi-bucket routing; <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/service.test.ts#L4\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:service.test.ts</code></a> pins the configured <code>pyric-default</code> bucket.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">uploadString(ref, value, format?, metadata?)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Sandbox: <code>format='base64url'</code> (or any unknown format) rejected with <code>storage/invalid-format</code> naming the bad format. Prod: <code>base64url</code> is ACCEPTED (upload succeeds); a genuinely-unrecognized format throws <code>storage/unknown</code></span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence, both halves oracle-locked by <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-uploadstring-unknown-format.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>packages/conformance/observations/storage/storage-uploadstring-unknown-format.json</code></a>: prod accepts <code>base64url</code> (<code>base64urlOk: true</code>) and throws <code>storage/unknown</code> for an unrecognized format — not <code>storage/invalid-format</code>. The v1 sandbox ships only <code>raw</code>/<code>base64</code>/<code>data_url</code> (matches <code>StringFormat</code>) and throws <code>storage/invalid-format</code> for anything else (ST-B3 replaced the old mis-parse-as-data_url behavior). Both sides pinned in <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/oracle-conformance.test.ts#L129\" target=\"_blank\" rel=\"noopener noreferrer\"><code>oracle-conformance.test.ts</code></a>; sandbox code path documented in <code>upload.ts</code>'s <code>decodeString</code>. Implementing base64url decoding is still one line in <code>decodeString</code>. (Structured evidence: <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/error-codes.test.ts#L25\" target=\"_blank\" rel=\"noopener noreferrer\"><code>error-codes.test.ts</code></a>)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">uploadBytesResumable(ref, data, metadata?)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Exported by <code>firebase/storage</code>; returns an <code>UploadTask</code> with <code>pause()</code> / <code>resume()</code> / <code>cancel()</code></span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/resumable-upload.test.ts#L14\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:resumable-upload.test.ts</code></a> confirms <code>uploadBytesResumable(ref, data)</code> returns an <code>UploadTask</code> supporting <code>pause()</code>, <code>resume()</code>, and <code>cancel()</code> over synthetic microtask steps.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">uploadBytesResumable(ref, data, metadata?)</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>task.on('state_changed', next, error, complete)</code> fires <code>next</code> with <code>{bytesTransferred, totalBytes, state}</code> snapshots</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/resumable-upload.test.ts#L14\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:resumable-upload.test.ts</code></a> confirms synthetic progress snapshots are emitted during task execution without requiring network transfer.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">uploadBytesResumable(ref, data, metadata?)</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>task.pause()</code> flips <code>state</code> to <code>'paused'</code>; <code>task.resume()</code> continues</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/resumable-upload.test.ts#L14\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:resumable-upload.test.ts</code></a> confirms calling <code>task.pause()</code> and <code>task.resume()</code> transitions through <code>'paused'</code> and <code>'running'</code> states deterministically.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">uploadBytesResumable(ref, data, metadata?)</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>task.cancel()</code> rejects the upload with <code>storage/canceled</code></span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/resumable-upload.test.ts#L14\" target=\"_blank\" rel=\"noopener noreferrer\"><code>unit:resumable-upload.test.ts</code></a> confirms cancellation rejects with <code>storage/canceled</code> and leaves no falsely completed object in storage.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">getDownloadURL(ref)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Exported by <code>firebase/storage</code>; returns a token-signed HTTPS URL that fetches the blob</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Implemented with a two-sided pin. Production observation <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-upload-bytes-roundtrip.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>storage-upload-bytes-roundtrip</code></a> records <code>urlIsHttps: true</code> and a byte-identical fetch. The sandbox oracle replay now calls the same public <code>getDownloadURL</code> + <code>fetch</code> path and proves byte-identical content, while explicitly asserting its URL starts with <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/oracle-conformance.test.ts#L170\" target=\"_blank\" rel=\"noopener noreferrer\"><code>blob:</code></a>. The client↔host integration proves SharedWorker mode creates that URL in the calling page after the rules-checked Blob crosses the port. The remaining divergence is URL identity and lifetime: the sandbox URL is a page-local snapshot, not token-signed HTTPS and not shareable outside that page. (Structured evidence: <a href=\"https://github.com/davideast/pyric/blob/main/packages/cli/test/serve/worker/integration.test.ts#L237\" target=\"_blank\" rel=\"noopener noreferrer\"><code>integration.test.ts</code></a>)</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">deleteObject(ref)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Sandbox: no-op on missing path (does NOT throw)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence: sandbox is no-op via <code>persistence.ts</code>'s <code>delete</code>. Prod's <code>deleteObject</code> on a missing path throws <code>storage/object-not-found</code>. Oracle-locked: <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-delete-missing-throws.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>packages/conformance/observations/storage/storage-delete-missing-throws.json</code></a> (<code>code: 'storage/object-not-found'</code>, <code>name: 'FirebaseError'</code> against blockingfun, fb-js-sdk 12.13.0). Both sides pinned in <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/oracle-conformance.test.ts#L91\" target=\"_blank\" rel=\"noopener noreferrer\"><code>oracle-conformance.test.ts</code></a>; documented in <code>download.ts</code>.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">getMetadata(ref)` / `updateMetadata(ref, metadata)</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>updateMetadata</code> with <code>undefined</code> field values preserves the prior value (does NOT clear it)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence: prod accepts <code>null</code> to explicitly clear a field. Sandbox doesn't model <code>null</code>-clear (per <code>metadata.ts</code> doc comment). Documented; not probe-locked.</div></div></details>\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">getMetadata(ref)` / `updateMetadata(ref, metadata)</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>FullMetadata.md5Hash</code> populated on uploads</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence: sandbox does NOT compute <code>md5Hash</code>. Oracle-locked: <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage/storage-upload-then-getmetadata.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>packages/conformance/observations/storage/storage-upload-then-getmetadata.json</code></a> confirms prod sets <code>md5Hash</code> (<code>hasMd5Hash: true</code> after a vanilla <code>uploadBytes</code>). Both sides pinned in <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/oracle-conformance.test.ts#L207\" target=\"_blank\" rel=\"noopener noreferrer\"><code>oracle-conformance.test.ts</code></a>. Aligning the sandbox is a one-spot fix in <code>upload.ts</code>'s <code>buildStoredMetadata</code>.</div></div></details>\n</div>\n\n### Not implemented yet\n\nTracked behavior that is not implemented in the current contract.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"unsupported\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">getStream(ref, maxDownloadSize?)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Exported by <code>firebase/storage</code> (Node entry only); returns a Node <code>Readable</code></span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">No <code>pyric/storage</code> implementation or behavioral probe exists; availability is classified by the owning surface contract.</div></div></details>\n<details class=\"compat-row\" data-status=\"unsupported\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">list(ref, options?)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Exported by <code>firebase/storage</code>; accepts <code>{ maxResults, pageToken }</code>, returns a <code>ListResult</code> with <code>nextPageToken</code> set when more pages remain</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">No paginated <code>list</code> implementation or behavioral probe exists; availability is classified by the owning surface contract. <code>ListResult.nextPageToken</code> remains optional for forward-compatible consumer code.</div></div></details>\n<details class=\"compat-row\" data-status=\"unsupported\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">getMetadata(ref)` / `updateMetadata(ref, metadata)</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>FullMetadata.ref</code> lazy population (prod populates lazily)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">not modeled in <code>pyric/storage</code> — <code>metadata.ts</code> explicitly omits <code>ref</code> from <code>FullMetadata</code></div></div></details>\n<details class=\"compat-row\" data-status=\"unsupported\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">connectStorageEmulator(storage, host, port)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Exported by <code>firebase/storage</code>; reroutes a <code>FirebaseStorage</code> handle to a local emulator</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">not implemented in <code>pyric/storage</code> — the sandbox IS the local-target alternative; emulator parity is out of scope per <code>index.ts</code></div></div></details>\n</div>\n\n## Reviewed public-runtime gaps\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">storage.runtime-enum-values</code><span class=\"compat-sub\"><span class=\"compat-behavior\">StorageErrorCode and StringFormat are mirrored as TypeScript types but not as Firebase-compatible runtime enum objects, so value imports remain unavailable.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>StorageErrorCode</code> <code>StringFormat</code></div>\n<div class=\"compat-note\">upstream:firebase/storage</div></div>\n</details>\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">storage.node-stream</code><span class=\"compat-sub\"><span class=\"compat-behavior\">The Node-stream variant is deferred — not part of the browser-shaped v1 scope yet, but not genuinely un-modelable.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>getStream</code></div>\n<div class=\"compat-note\">registry:storage#62</div></div>\n</details>\n<details class=\"compat-row\" data-status=\"unsupported\">\n<summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">storage.paginated-listing</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Paginated listing is deferred — listAll covers the v1 scope; pagination needs a stable pageToken shape, which is unbuilt design work rather than a sandbox limitation.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>list</code></div>\n<div class=\"compat-note\">registry:storage#78</div></div>\n</details>\n</div>\n" }, { "slug": "messaging-compat", "label": "Messaging", "title": "pyric messaging compatibility matrix", "markdown": "<!-- Generated from the conformance model (registry rows + surface contracts). Do not edit by hand; run bun run compat:generate. -->\n\n# `pyric` messaging compatibility matrix\n\n<div class=\"compat-stat\">\n<p class=\"compat-stat-figure\">\n<span class=\"compat-stat-pct\">100%</span>\n<span class=\"compat-stat-label\">of the public API supported</span>\n</p>\n<div class=\"compat-meters\">\n<span class=\"compat-meter-track\"><span class=\"compat-meter-fill\" style=\"width: 100%\"></span></span>\n</div>\n<p class=\"compat-stat-denom\">13 of 13 public API</p>\n</div>\n\n\n## Status legend\n\n| Status | Meaning |\n|---|---|\n| ✓ | **Conforming** — sandbox matches prod, locked by a passing probe |\n| — | **Not implemented yet** — deliberately or pending |\n| ? | **Unverified** — a target with a derived failing test, not a guarantee |\n\n## `firebase/messaging` (client)\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getMessaging(app?): Messaging | | Returns the FCM `Messaging` instance associated with the given (or default) `FirebaseApp`. Bound to the client component registered under the name `messaging`. | ✓ | Upstream typings/JSDoc (firebase 12.13.0, `@firebase/messaging` 0.12.26); in-process mirror suite plus canonical-import SharedWorker replay [`messaging-app-boundary.pw.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/e2e/messaging-app-boundary.pw.ts#L3). (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L63)) | 1 |\n| getToken(messaging, options?): Promise<string> | | Subscribes the instance to push and resolves with an FCM registration token; requests notification permission if not already granted and rejects if denied. Production tokens are colon-separated, URL-safe, ~142 chars, with the suffix after the colon beginning `APA91b`, and are stable across repeated `getToken` calls on the same service-worker registration (no per-call rotation). | ✓ | oracle: [`messaging-web-token-shape.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging/messaging-web-token-shape.json) (minted, length 142, colon-separated, suffix starts `APA91b`, URL-safe) + [`messaging-web-token-stability.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging/messaging-web-token-stability.json) (second `getToken` on the same registration returns the same token). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L125)) | 2 |\n| deleteToken(messaging): Promise<boolean> | | Deletes the registration token and unsubscribes the instance from its push subscription; resolves truthy. After deletion no message reaches the client on either route, and a server send to the now-dead token eventually surfaces the UNREGISTERED / 404-class error on the send plane (propagation is asynchronous — the first send after delete may still be accepted while delivery has already stopped). | ✓ | oracle: [`messaging-web-deletetoken-unregistered.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging/messaging-web-deletetoken-unregistered.json) (deleteToken resolved truthy; no delivery to client; send plane eventually UNREGISTERED). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L145)) | 3 |\n| onMessage(messaging, nextOrObserver): Unsubscribe | | Dispatched with the push payload when a message arrives while a window client is visible; the returned function stops listening. Routing keys on page VISIBILITY, not focus: a `visibilityState: \"visible\"` page receives `onMessage` even when unfocused, and when no window client is visible the message routes to the service-worker `onBackgroundMessage` instead. | ✓ | oracle: [`messaging-web-onmessage-foreground.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging/messaging-web-onmessage-foreground.json) (focused page → onMessage) + [`messaging-web-visibility-routing.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging/messaging-web-visibility-routing.json) (visible → onMessage, no visible client → onBackgroundMessage). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L192)) | 4 |\n| isSupported(): Promise<boolean> | | Resolves whether every API required by FCM exists in the current browser window context (bound to `isWindowSupported`). | ✓ | Upstream typings/JSDoc (`@firebase/messaging` 0.12.26); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L231)) | 5 |\n| interface Messaging { app } | | Public interface of the FCM client SDK; exposes the bound `FirebaseApp` as `app`. | ✓ | Upstream typings (`@firebase/messaging` 0.12.26 `public-types`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L238)) | 6 |\n| interface GetTokenOptions { vapidKey?; serviceWorkerRegistration? } | | Options for `getToken`: an optional `vapidKey` (Web Push certificate public key) and an optional `serviceWorkerRegistration`. | ✓ | Upstream typings (`@firebase/messaging` 0.12.26 `public-types`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L245)) | 7 |\n| interface MessagePayload { notification?; data?; fcmOptions?; from; collapseKey; messageId } | | Received message envelope delivered to `onMessage` / `onBackgroundMessage`. Production deliveries carry top-level keys `data`, `from`, `messageId`, and `notification`; `from` equals the project messaging sender id and `messageId` is present. (`from`, `collapseKey`, `messageId` are typed as required.) | ✓ | oracle: [`messaging-web-onmessage-foreground.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging/messaging-web-onmessage-foreground.json) + [`messaging-web-onbackgroundmessage.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging/messaging-web-onbackgroundmessage.json) (top-level keys data/from/messageId/notification; from = sender id; messageId present). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L197)) | 8 |\n| interface NotificationPayload { title?; body?; image?; icon? } | | Display-notification block inside a `MessagePayload`. Production foreground deliveries carry a `notification` object whose keys include `title` and `body`. | ✓ | oracle: [`messaging-web-onmessage-foreground.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging/messaging-web-onmessage-foreground.json) (notificationKeys body, title). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L197)) | 9 |\n| interface FcmOptions { link?; analyticsLabel? } | | WebpushFcmOptions-style options carried on a client `MessagePayload` (`link`, `analyticsLabel`). | ✓ | Upstream typings (`@firebase/messaging` 0.12.26 `public-types`); no observation yet — type-only shape closed by the assignability census (resolved decision #5). | 10 |\n| NextFn, Observer, Unsubscribe (re-exported from @firebase/util) | | The callback, observer, and teardown types consumed by `onMessage` / `onBackgroundMessage` are re-exported from `@firebase/util`. | ✓ | Upstream typings (`@firebase/messaging` 0.12.26 re-exports); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L340)) | 11 |\n| const enum ErrorCode (client) | | Client failures surface as thrown `FirebaseError`s carrying one of the 18 documented `ErrorCode` values (`missing-app-config-values`, `only-available-in-window`, `only-available-in-sw`, `permission-default`, `permission-blocked`, `unsupported-browser`, `indexed-db-unsupported`, `failed-service-worker-registration`, `token-subscribe-failed`, `token-subscribe-no-token`, `token-unsubscribe-failed`, `token-update-failed`, `token-update-no-token`, `invalid-bg-handler`, `use-sw-after-get-token`, `invalid-sw-registration`, `use-vapid-key-after-get-token`, `invalid-vapid-key`). The enum itself is `@internal`, not a public export. | ✓ | Upstream typings (`@firebase/messaging` 0.12.26 `src/util/errors`); no client-error observation among the committed set. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L372)) | 12 |\n\n## `firebase/messaging/sw` (service worker)\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getMessaging(app?): Messaging (sw) | | Returns the FCM instance within a service-worker context (bound to `getMessagingInSw`); registers under the component name `messaging-sw`. | ✓ | Upstream typings (`@firebase/messaging` 0.12.26 `sw/index-public`) plus real module-ServiceWorker served-entry replay [`messaging-app-boundary.pw.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/e2e/messaging-app-boundary.pw.ts#L3). (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L380)) | 13 |\n| onBackgroundMessage(messaging, nextOrObserver): Unsubscribe | | Called when a message arrives while the app has no visible window client. Production routes background deliveries here rather than to `onMessage`; the delivered payload carries `data` / `from` / `messageId` and, for notification messages, a `notification` block. A DATA-ONLY message still fires `onBackgroundMessage` with no `notification` key, and a registered handler suppresses the SDK auto-display. | ✓ | oracle: [`messaging-web-onbackgroundmessage.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging/messaging-web-onbackgroundmessage.json) (no visible client → onBackgroundMessage) + [`messaging-web-visibility-routing.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging/messaging-web-visibility-routing.json) + [`messaging-web-data-only-background.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging/messaging-web-data-only-background.json) (data-only fires, no notification key). Replayed by the conformance suite and by a real module Service Worker connected to the canonical SharedWorker broker in [`messaging-app-boundary.pw.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/e2e/messaging-app-boundary.pw.ts#L73). (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L198)) | 14 |\n| experimentalSetDeliveryMetricsExportedToBigQueryEnabled(messaging, enable): void | | Enables or disables delivery-metrics export to BigQuery at runtime; default off. | ✓ | Upstream typings (`@firebase/messaging` 0.12.26 `sw/index-public`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L416)) | 15 |\n| isSupported(): Promise<boolean> (sw) | | Resolves whether every API required by FCM exists within the service-worker context (bound to `isSwSupported`). | ✓ | Upstream typings (`@firebase/messaging` 0.12.26 `sw/index-public`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L422)) | 16 |\n| firebase/messaging/sw module boundary + shared type parity | | The sw entry exports `onBackgroundMessage`, `getMessaging`, `experimentalSetDeliveryMetricsExportedToBigQueryEnabled`, and `isSupported`, but NOT `getToken` / `deleteToken` / `onMessage`; the client entry exports the latter but NOT `onBackgroundMessage` / the metrics toggle. The two modules register under different component names (`messaging` vs `messaging-sw`) and re-export identical `Messaging` / `GetTokenOptions` / `MessagePayload` / `NotificationPayload` / `FcmOptions` type declarations. | ✓ | Upstream typings (`@firebase/messaging` 0.12.26 `index.d.ts` / `index.sw.d.ts`) plus Window and real module-ServiceWorker boundary replay [`messaging-app-boundary.pw.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/e2e/messaging-app-boundary.pw.ts#L3). (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/messaging/oracle-conformance.test.ts#L429)) | 17 |\n\n## Behavior classes over time & across clients (client receive plane)\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Multi-tab delivery routing (several window clients, one shared service worker) | | When several window clients (browser tabs) of one origin share a single service-worker registration, a foreground delivery reaches every visible tab through `onMessage`, and when no tab is visible the one shared service worker receives the message once through `onBackgroundMessage` (the worker is shared across tabs, not instantiated per tab). A notification click focuses a single existing client rather than opening a duplicate. Stated from the FCM web SDK and the Service Worker client model; no committed observation replays the multi-tab fan-out yet. | ? | Upstream documentation (`@firebase/messaging` 0.12.26 + W3C Service Worker `Clients` model); no observation yet. Candidate probe: open two tabs on one registration and deliver with one tab visible, then with both hidden — assert `onMessage` fires once per visible tab and `onBackgroundMessage` fires exactly once on the shared worker. | 18 |\n| Multi-device delivery (one app instance holding several tokens; per-token fan-out) | | A single user or app can hold several registration tokens at once — one per browser, profile, or device. A token-targeted send reaches exactly one token; a topic, condition, or multicast send fans out independently to every subscribed token, with no server-side de-duplication across a user’s devices, so each device receives its own copy. Stated from the FCM targeting model; no committed observation replays cross-device fan-out yet. | ? | Upstream documentation (FCM targeting model); no observation yet. Candidate probe: mint two distinct tokens, subscribe both to one topic, send once, and assert each token receives the message independently. | 19 |\n| Registration-token rotation and refresh over the app lifecycle | | A registration token is stable within a session (row messaging#2 pins that repeated `getToken` calls on one service-worker registration return the same token) but is not permanent across the app lifecycle: the SDK rotates it on events such as app reinstall, SDK upgrade, a restored backup, or a notification-permission change, and Google may retire a token server-side. The modular web SDK has no token-refresh callback, so an app observes a new token only by calling `getToken` again; a rotated-away old token eventually stops delivering and the send plane reports the dead token (the UNREGISTERED path pinned by messaging#3). This lifecycle rotation is deliberately outside the within-session stability observation. Stated from FCM token-management documentation; no committed observation replays rotation across lifecycle events yet. | ? | Upstream documentation (FCM token-management guidance); the committed `messaging-web-token-stability` observation covers only within-session stability, not lifecycle rotation. Candidate probe (long-horizon): force a service-worker unregister/re-register or an app-instance reset, re-mint via `getToken`, and record whether the token changed and whether the prior token then reports UNREGISTERED on send. | 20 |\n| Registration-token inactivity expiry (server-side staleness window) | | FCM treats a registration token that has gone unused for an extended period (Google documents roughly 270 days of app-instance inactivity) as stale and drops it from delivery targeting, independent of any explicit `deleteToken`. The expiry clock is Google server-side inactivity accounting, and the recommended mitigation is periodic token refresh. Stated from FCM best-practices documentation; the sandbox has no server-side inactivity clock to model. | — | Upstream documentation (FCM registration-token best practices); no observation. This time-based server-side behavior is out of sandbox scope (see the row disposition); the explicit-deletion path to a dead token is already tracked by messaging#3. | 21 |\n\n## `firebase-admin/messaging` — entry + `Messaging` class\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| getMessaging(app?): Messaging | | Returns the `Messaging` service for the default or given admin `App`. | ✓ | Upstream typings/JSDoc (firebase-admin 13.10.0 `lib/messaging/index`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L63)) | 1 |\n| messaging(app?): messaging.Messaging | | Namespaced / legacy accessor equivalent of `getMessaging`, exposed by the compat entry alongside the `namespace messaging` type aliases. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-namespace`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L181)) | 2 |\n| Messaging.get app(): App | | The admin `App` this `Messaging` instance is bound to. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L156)) | 3 |\n| Messaging.send(message, dryRun?): Promise<string> | | Sends one message via FCM v1 and resolves with the resource name `projects/<projectId>/messages/<numeric id>`. `dryRun=true` returns the SAME shape (fake id), so callers cannot distinguish validation from acceptance by shape. Topic, condition, token, notification-only, data-only, and webpush-config sends are all accepted. Malformed sends fail server-side validation with HTTP 4xx `google.rpc` error envelopes carrying both a `google.rpc.BadRequest` (fieldViolations) and a `google.firebase.fcm.v1.FcmError` (errorCode); detail ordering is not contractual. The documented data-payload cap is 4096 bytes. | ✓ | oracle: 10 send-plane observations — accept paths [`messaging-send-topic-accepted`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-topic-accepted.json), [`messaging-send-condition-accepted`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-condition-accepted.json), [`messaging-send-notification-only-vs-data-only-accepted`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-notification-only-vs-data-only-accepted.json), [`messaging-send-webpush-config-accepted`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-webpush-config-accepted.json); error envelopes [`messaging-send-no-target-error-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-no-target-error-envelope.json), [`messaging-send-invalid-token-error-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-invalid-token-error-envelope.json), [`messaging-send-invalid-condition-error-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-invalid-condition-error-envelope.json), [`messaging-send-invalid-topic-name-error-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-invalid-topic-name-error-envelope.json), [`messaging-send-oversized-payload-error-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-oversized-payload-error-envelope.json), [`messaging-send-webpush-invalid-ttl-error-envelope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-webpush-invalid-ttl-error-envelope.json). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L192)) | 4 |\n| Messaging.sendEach(messages, dryRun?): Promise<BatchResponse> | | Sends an array of up to 500 messages, one RPC per message; resolves a `BatchResponse` whose `responses` are ordered to match the input. Total failure is signalled by a throw or an all-failure `BatchResponse`. | ✓ | Upstream typings/JSDoc (firebase-admin 13.10.0 `messaging`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L237)) | 5 |\n| Messaging.sendEachForMulticast(message, dryRun?): Promise<BatchResponse> | | Fans a `MulticastMessage` (up to 500 tokens) out through `sendEach`. | ✓ | Upstream typings/JSDoc (firebase-admin 13.10.0 `messaging`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L241)) | 6 |\n| Messaging.subscribeToTopic(tokenOrTokens, topic): Promise<MessagingTopicManagementResponse> | | Subscribes one or many registration tokens to a topic; resolves a `MessagingTopicManagementResponse`. | ✓ | Upstream typings/JSDoc (firebase-admin 13.10.0 `messaging`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L245)) | 7 |\n| Messaging.unsubscribeFromTopic(tokenOrTokens, topic): Promise<MessagingTopicManagementResponse> | | Unsubscribes one or many registration tokens from a topic; resolves a `MessagingTopicManagementResponse`. | ✓ | Upstream typings/JSDoc (firebase-admin 13.10.0 `messaging`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L249)) | 8 |\n| Messaging.enableLegacyHttpTransport(): void | | Forces HTTP/1.1 transport for `sendEach` / `sendEachForMulticast`; deprecated. | ✓ | Upstream typings/JSDoc (firebase-admin 13.10.0 `messaging`, `@deprecated`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L253)) | 9 |\n\n## `Message` union + targets\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| type Message = TokenMessage \\| TopicMessage \\| ConditionMessage | | A send payload carrying exactly one of token, topic, or condition. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L63)) | 10 |\n| interface BaseMessage { data?; notification?; android?; webpush?; apns?; fcmOptions? } | | Common message fields shared by every target variant. Production accepts a message carrying ONLY a `notification` block and, separately, ONLY a `data` block — neither is individually required. | ✓ | oracle: [`messaging-send-notification-only-vs-data-only-accepted.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-notification-only-vs-data-only-accepted.json) (both accepted). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L214)) | 11 |\n| interface TokenMessage extends BaseMessage { token: string } | | A device-token target. A syntactically invalid token is rejected with HTTP 400 INVALID_ARGUMENT whose fieldViolations name `message.token`. | ✓ | oracle: [`messaging-send-invalid-token-error-envelope.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-invalid-token-error-envelope.json) (fieldViolations names message.token). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L225)) | 12 |\n| interface TopicMessage extends BaseMessage { topic: string } | | A topic target. A well-formed topic send is accepted and returns the standard resource name (no subscribers required); a topic name containing characters outside the documented `[a-zA-Z0-9-_.~%]` set is rejected with an INVALID_ARGUMENT error envelope. | ✓ | oracle: [`messaging-send-topic-accepted.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-topic-accepted.json) (accepted) + [`messaging-send-invalid-topic-name-error-envelope.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-invalid-topic-name-error-envelope.json) (bad name rejected). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L208)) | 13 |\n| interface ConditionMessage extends BaseMessage { condition: string } | | A condition target. A well-formed condition of the form `\"'a' in topics && 'b' in topics\"` is accepted with the standard resource-name shape (no subscribers required); a malformed condition (dangling operator) is rejected with an error envelope. | ✓ | oracle: [`messaging-send-condition-accepted.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-condition-accepted.json) (accepted) + [`messaging-send-invalid-condition-error-envelope.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-invalid-condition-error-envelope.json) (malformed rejected). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L209)) | 14 |\n| interface MulticastMessage extends BaseMessage { tokens: string[] } | | A multicast target of up to 500 tokens, fanned out by `sendEachForMulticast`. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L298)) | 15 |\n\n## Payload / config option shapes\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| interface Notification { title?; body?; imageUrl? } | | Top-level, platform-independent notification block. Production accepts a notification-only message (no data block). | ✓ | oracle: [`messaging-send-notification-only-vs-data-only-accepted.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-notification-only-vs-data-only-accepted.json). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L214)) | 16 |\n| interface FcmOptions { analyticsLabel? } | | Platform-independent FCM options (`analyticsLabel`). | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L309)) | 17 |\n| interface WebpushConfig { headers?; data?; notification?; fcmOptions? } | | Webpush overrides. Production accepts a webpush config carrying `headers.TTL` and `fcmOptions.link`; a non-numeric `headers.TTL` is rejected with an error envelope. | ✓ | oracle: [`messaging-send-webpush-config-accepted.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-webpush-config-accepted.json) (accepted) + [`messaging-send-webpush-invalid-ttl-error-envelope.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-webpush-invalid-ttl-error-envelope.json) (bad TTL rejected). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L210)) | 18 |\n| interface WebpushFcmOptions { link? } | | Webpush FCM options (`link`, HTTPS required). Production accepts `fcmOptions.link` on a webpush send. | ✓ | oracle: [`messaging-send-webpush-config-accepted.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-webpush-config-accepted.json) (link accepted). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L210)) | 19 |\n| interface WebpushNotification { title?; actions?; badge?; body?; dir?; icon?; image?; renotify?; requireInteraction?; silent?; tag?; vibrate?; [key] } | | Web Notification API-shaped options, including an open-ended index signature. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L333)) | 20 |\n| interface ApnsConfig { liveActivityToken?; headers?; payload?; fcmOptions? } | | APNs overrides. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L334)) | 21 |\n| interface ApnsPayload { aps; [customData] } | | APNs payload wrapper carrying the required `aps` dictionary plus arbitrary custom keys. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L335)) | 22 |\n| interface Aps { alert?; badge?; sound?; contentAvailable?; mutableContent?; category?; threadId?; [customData] } | | APNs `aps` dictionary; `alert` is a string or an `ApsAlert`, `sound` a string or a `CriticalSound`. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L336)) | 23 |\n| interface ApsAlert { title?; subtitle?; body?; locKey?; locArgs?; ...; launchImage? } | | APNs alert object with title/subtitle/body and localization keys. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L337)) | 24 |\n| interface CriticalSound { critical?; name; volume? } | | APNs critical sound — `name` required; `volume` in the range 0.0–1.0. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L338)) | 25 |\n| interface ApnsFcmOptions { analyticsLabel?; imageUrl? } | | APNs FCM options (`analyticsLabel`, `imageUrl`). | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L339)) | 26 |\n| interface AndroidConfig { collapseKey?; priority?; ttl?; restrictedPackageName?; data?; notification?; fcmOptions?; ... } | | Android overrides; `ttl` is in milliseconds and `priority` is `high` \\| `normal`. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L340)) | 27 |\n| interface AndroidNotification { title?; body?; icon?; color?; sound?; tag?; imageUrl?; channelId?; priority?; visibility?; lightSettings?; ... } | | Android notification options, including localization keys, LED light settings, and delivery-proxy controls. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L341)) | 28 |\n| interface LightSettings { color; lightOnDurationMillis; lightOffDurationMillis } | | Android LED light settings — all three fields required. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L342)) | 29 |\n| interface AndroidFcmOptions { analyticsLabel? } | | Android FCM options (`analyticsLabel`). | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L343)) | 30 |\n\n## Legacy payload shapes\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| interface DataMessagePayload { [key]: string } | | Legacy data payload — up to 4KB; the keys `from` and `google.*` are reserved. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L346)) | 31 |\n| interface NotificationMessagePayload { tag?; body?; icon?; badge?; color?; sound?; title?; ...; [key] } | | Legacy notification payload with localization keys, a `clickAction`, and arbitrary string keys. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L347)) | 32 |\n| interface MessagingPayload { data?; notification? } | | Legacy combined payload — one or both of `data` / `notification` required. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L348)) | 33 |\n| interface MessagingOptions { dryRun?; priority?; timeToLive?; collapseKey?; mutableContent?; contentAvailable?; restrictedPackageName?; [key] } | | Legacy send options; documented defaults are dryRun false, ttl 2419200s (4 weeks), priority high for notifications / normal for data. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L349)) | 34 |\n\n## Response shapes\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| interface MessagingTopicManagementResponse { failureCount; successCount; errors } | | Topic subscribe / unsubscribe result carrying per-index errors as `FirebaseArrayIndexError[]`. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L352)) | 35 |\n| interface BatchResponse { responses; successCount; failureCount } | | Batch send result; `responses` is a `SendResponse[]`. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L353)) | 36 |\n| interface SendResponse { success; messageId?; error? } | | Per-message batch entry — on success `messageId` is set, on failure `error` is set. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L354)) | 37 |\n\n## Errors\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| class FirebaseMessagingError extends PrefixedFirebaseError | | The exported admin messaging error type. | ✓ | Upstream typings (firebase-admin 13.10.0 `lib/messaging/index`); no observation yet. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L357)) | 38 |\n| class MessagingClientErrorCode | | Exported static `{ code, message }` members (`INVALID_ARGUMENT`, `INVALID_RECIPIENT`, `INVALID_PAYLOAD`, … `UNKNOWN_ERROR`). The wire-level `INVALID_ARGUMENT` FcmError returned by malformed sends maps to `MessagingClientErrorCode.INVALID_ARGUMENT`. | ✓ | oracle: [`messaging-send-no-target-error-envelope.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-no-target-error-envelope.json) + [`messaging-send-invalid-token-error-envelope.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/messaging-admin/messaging-send-invalid-token-error-envelope.json) (both carry the INVALID_ARGUMENT FcmError). Replayed by the conformance suite. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric-admin/test/messaging/oracle-conformance.test.ts#L156)) | 39 |\n\n## Behavior classes — quota, retry, offline delivery (send plane)\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| FCM send quota and rate-limit throttling | | FCM enforces project-level send quotas and per-target rate limits (per-device and per-topic message rates) accounted on Google servers over rolling windows; exceeding them yields quota / rate-limit rejections. The admin error taxonomy names these as `MessagingClientErrorCode.MESSAGE_RATE_EXCEEDED`, `DEVICE_MESSAGE_RATE_EXCEEDED`, and `TOPICS_MESSAGE_RATE_EXCEEDED` (wire `QUOTA_EXCEEDED` / `RESOURCE_EXHAUSTED`, HTTP 429). Stated from FCM quota documentation; the sandbox has no global quota ledger to model. | — | Upstream documentation (FCM quotas and `MessagingClientErrorCode` rate-limit members, firebase-admin 13.10.0); no observation. This behavior is out of sandbox scope (see the row disposition). | 40 |\n| Send retry and backoff on transient transport failures | | The admin SDK retries transient send-transport failures — connection errors and HTTP 5xx / `SERVER_UNAVAILABLE` / `INTERNAL` responses — with backoff before surfacing the error to the caller, so a caller sees a resolved send or a terminal error rather than every intermediate failure. `enableLegacyHttpTransport` switches `sendEach` / `sendEachForMulticast` from HTTP/2 to HTTP/1.1, changing multiplexing and the retry/transport path. Stated from the admin SDK and google-auth transport documentation; no committed observation replays the retry count or backoff schedule yet. | ? | Upstream documentation (firebase-admin 13.10.0 send transport + google-auth retry defaults); no observation yet. Candidate probe: point the send transport at a fault-injecting stub that returns 503 then 200 and record the retry count and backoff intervals before the resolved send. | 41 |\n| Offline store-and-forward delivery | TTL and collapse-key semantics | When a target device is offline FCM stores the message and delivers it on reconnect, up to its time-to-live (`android.ttl`, APNs expiration, webpush `headers.TTL`; the legacy default is 2419200 seconds / four weeks). A collapse key (`android.collapseKey`, `collapseKey`) marks a message as collapsible so a newer collapsible message replaces an older still-undelivered one, and only the last is delivered on reconnect; a message whose TTL elapses before reconnect is dropped rather than delivered. Stated from FCM message-lifecycle documentation; no committed observation replays store-and-forward, collapse, or TTL expiry yet. | ? | Upstream documentation (FCM message lifecycle: TTL, collapse keys, store-and-forward); no observation yet. Candidate probe: send several collapsible messages to a token whose web client is offline, bring it online within TTL, and assert only the last collapsible message is delivered; separately, let a short-TTL message expire before reconnect and assert it is dropped. | 42 |\n\n## Current gaps\n\n### Not implemented yet\n\nTracked behavior that is not implemented in the current contract.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"unsupported\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">Registration-token inactivity expiry (server-side staleness window)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">FCM treats a registration token that has gone unused for an extended period (Google documents roughly 270 days of app-instance inactivity) as stale and drops it from delivery targeting, independent of any explicit <code>deleteToken</code>. The expiry clock is Google server-side inactivity accounting, and the recommended mitigation is periodic token refresh. Stated from FCM best-practices documentation; the sandbox has no server-side inactivity clock to model.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Upstream documentation (FCM registration-token best practices); no observation. This time-based server-side behavior is out of sandbox scope (see the row disposition); the explicit-deletion path to a dead token is already tracked by messaging#3.</div></div></details>\n<details class=\"compat-row\" data-status=\"unsupported\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unsupported\" role=\"img\" aria-label=\"Not implemented yet\" title=\"Not implemented yet\"></span><span class=\"compat-main\"><code class=\"compat-api\">FCM send quota and rate-limit throttling</code><span class=\"compat-sub\"><span class=\"compat-behavior\">FCM enforces project-level send quotas and per-target rate limits (per-device and per-topic message rates) accounted on Google servers over rolling windows; exceeding them yields quota / rate-limit rejections. The admin error taxonomy names these as <code>MessagingClientErrorCode.MESSAGE_RATE_EXCEEDED</code>, <code>DEVICE_MESSAGE_RATE_EXCEEDED</code>, and <code>TOPICS_MESSAGE_RATE_EXCEEDED</code> (wire <code>QUOTA_EXCEEDED</code> / <code>RESOURCE_EXHAUSTED</code>, HTTP 429). Stated from FCM quota documentation; the sandbox has no global quota ledger to model.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Upstream documentation (FCM quotas and <code>MessagingClientErrorCode</code> rate-limit members, firebase-admin 13.10.0); no observation. This behavior is out of sandbox scope (see the row disposition).</div></div></details>\n</div>\n\n### Unverified\n\nTracked behavior whose available evidence does not yet establish the production result.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"unverified\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unverified\" role=\"img\" aria-label=\"Unverified\" title=\"Unverified\"></span><span class=\"compat-main\"><code class=\"compat-api\">Multi-tab delivery routing (several window clients, one shared service worker)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">When several window clients (browser tabs) of one origin share a single service-worker registration, a foreground delivery reaches every visible tab through <code>onMessage</code>, and when no tab is visible the one shared service worker receives the message once through <code>onBackgroundMessage</code> (the worker is shared across tabs, not instantiated per tab). A notification click focuses a single existing client rather than opening a duplicate. Stated from the FCM web SDK and the Service Worker client model; no committed observation replays the multi-tab fan-out yet.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Upstream documentation (<code>@firebase/messaging</code> 0.12.26 + W3C Service Worker <code>Clients</code> model); no observation yet. Candidate probe: open two tabs on one registration and deliver with one tab visible, then with both hidden — assert <code>onMessage</code> fires once per visible tab and <code>onBackgroundMessage</code> fires exactly once on the shared worker.</div></div></details>\n<details class=\"compat-row\" data-status=\"unverified\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unverified\" role=\"img\" aria-label=\"Unverified\" title=\"Unverified\"></span><span class=\"compat-main\"><code class=\"compat-api\">Multi-device delivery (one app instance holding several tokens; per-token fan-out)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">A single user or app can hold several registration tokens at once — one per browser, profile, or device. A token-targeted send reaches exactly one token; a topic, condition, or multicast send fans out independently to every subscribed token, with no server-side de-duplication across a user’s devices, so each device receives its own copy. Stated from the FCM targeting model; no committed observation replays cross-device fan-out yet.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Upstream documentation (FCM targeting model); no observation yet. Candidate probe: mint two distinct tokens, subscribe both to one topic, send once, and assert each token receives the message independently.</div></div></details>\n<details class=\"compat-row\" data-status=\"unverified\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unverified\" role=\"img\" aria-label=\"Unverified\" title=\"Unverified\"></span><span class=\"compat-main\"><code class=\"compat-api\">Registration-token rotation and refresh over the app lifecycle</code><span class=\"compat-sub\"><span class=\"compat-behavior\">A registration token is stable within a session (row messaging#2 pins that repeated <code>getToken</code> calls on one service-worker registration return the same token) but is not permanent across the app lifecycle: the SDK rotates it on events such as app reinstall, SDK upgrade, a restored backup, or a notification-permission change, and Google may retire a token server-side. The modular web SDK has no token-refresh callback, so an app observes a new token only by calling <code>getToken</code> again; a rotated-away old token eventually stops delivering and the send plane reports the dead token (the UNREGISTERED path pinned by messaging#3). This lifecycle rotation is deliberately outside the within-session stability observation. Stated from FCM token-management documentation; no committed observation replays rotation across lifecycle events yet.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Upstream documentation (FCM token-management guidance); the committed <code>messaging-web-token-stability</code> observation covers only within-session stability, not lifecycle rotation. Candidate probe (long-horizon): force a service-worker unregister/re-register or an app-instance reset, re-mint via <code>getToken</code>, and record whether the token changed and whether the prior token then reports UNREGISTERED on send.</div></div></details>\n<details class=\"compat-row\" data-status=\"unverified\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unverified\" role=\"img\" aria-label=\"Unverified\" title=\"Unverified\"></span><span class=\"compat-main\"><code class=\"compat-api\">Send retry and backoff on transient transport failures</code><span class=\"compat-sub\"><span class=\"compat-behavior\">The admin SDK retries transient send-transport failures — connection errors and HTTP 5xx / <code>SERVER_UNAVAILABLE</code> / <code>INTERNAL</code> responses — with backoff before surfacing the error to the caller, so a caller sees a resolved send or a terminal error rather than every intermediate failure. <code>enableLegacyHttpTransport</code> switches <code>sendEach</code> / <code>sendEachForMulticast</code> from HTTP/2 to HTTP/1.1, changing multiplexing and the retry/transport path. Stated from the admin SDK and google-auth transport documentation; no committed observation replays the retry count or backoff schedule yet.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Upstream documentation (firebase-admin 13.10.0 send transport + google-auth retry defaults); no observation yet. Candidate probe: point the send transport at a fault-injecting stub that returns 503 then 200 and record the retry count and backoff intervals before the resolved send.</div></div></details>\n<details class=\"compat-row\" data-status=\"unverified\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unverified\" role=\"img\" aria-label=\"Unverified\" title=\"Unverified\"></span><span class=\"compat-main\"><code class=\"compat-api\">Offline store-and-forward delivery</code><span class=\"compat-sub\"><span class=\"compat-behavior\">When a target device is offline FCM stores the message and delivers it on reconnect, up to its time-to-live (<code>android.ttl</code>, APNs expiration, webpush <code>headers.TTL</code>; the legacy default is 2419200 seconds / four weeks). A collapse key (<code>android.collapseKey</code>, <code>collapseKey</code>) marks a message as collapsible so a newer collapsible message replaces an older still-undelivered one, and only the last is delivered on reconnect; a message whose TTL elapses before reconnect is dropped rather than delivered. Stated from FCM message-lifecycle documentation; no committed observation replays store-and-forward, collapse, or TTL expiry yet.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Upstream documentation (FCM message lifecycle: TTL, collapse keys, store-and-forward); no observation yet. Candidate probe: send several collapsible messages to a token whose web client is offline, bring it online within TTL, and assert only the last collapsible message is delivered; separately, let a short-TTL message expire before reconnect and assert it is dropped.</div></div></details>\n</div>\n" }, { "slug": "functions-rtdb-compat", "label": "Functions · RTDB", "title": "Firebase Functions RTDB integration compatibility", "markdown": "<!-- Generated from the conformance model (registry rows + surface contracts). Do not edit by hand; run bun run compat:generate. -->\n\n# Firebase Functions RTDB integration compatibility\n\n<div class=\"compat-stat\">\n<p class=\"compat-stat-surface\">This page tracks an integration contract: unchanged Firebase code run through a Pyric runtime seam, so there is no separate Firebase public API to measure. The rows below are its signed behavior inventory.</p>\n</div>\n\n\n## Status legend\n\n| Status | Meaning |\n|---|---|\n| ✓ | **Conforming** — unchanged source matches production under replay |\n| ? | **Unverified** — production target or local replay is incomplete |\n\n## `onValueCreated` delivery and event contract\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| onValueCreated(ref, handler) | | A write that changes an exact matched RTDB location from absent to present invokes the handler once with a create CloudEvent and a DataSnapshot containing the created value. | ✓ | oracle: [`functions-rtdb-onvaluecreated-exact-create.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-exact-create.json) (firebase-functions 7.2.5); production exact-create behavior. Local replay: [`packages/cli/test/functions-rtdb/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/functions-rtdb/oracle-conformance.test.ts#L322) assertion set `functions-rtdb#1`. | 1 |\n| onValueCreated(ref, handler) | | After the initial create delivery, changing or deleting that same matched value does not invoke an onValueCreated handler. | ✓ | oracle: [`functions-rtdb-onvaluecreated-exact-create.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-exact-create.json); update/delete produced no additional delivery in the bounded production scenario. Local replay: [`packages/cli/test/functions-rtdb/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/functions-rtdb/oracle-conformance.test.ts#L322) assertion set `functions-rtdb#2`. | 2 |\n| onValueCreated(ref, handler) | | A value that already exists when the trigger is deployed does not produce a historical create delivery. | ✓ | oracle: [`functions-rtdb-onvaluecreated-startup-existing.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-startup-existing.json); pre-seeded value, zero delivery in the observation window. Local replay: [`packages/cli/test/functions-rtdb/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/functions-rtdb/oracle-conformance.test.ts#L323) assertion set `functions-rtdb#3`. | 3 |\n| onValueCreated(ref, handler) | | A named single-segment wildcard matches a created child and exposes the matched segment through event.params. | ✓ | oracle: [`functions-rtdb-onvaluecreated-wildcard-batches.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-wildcard-batches.json); production populated caseId and itemId. Local replay: [`packages/cli/test/functions-rtdb/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/functions-rtdb/oracle-conformance.test.ts#L324) assertion set `functions-rtdb#4`. | 4 |\n| onValueCreated(ref, handler) | | Creating an ancestor object invokes the wildcard handler once for each newly-present matching descendant. | ✓ | oracle: [`functions-rtdb-onvaluecreated-wildcard-batches.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-wildcard-batches.json); one ancestor set delivered alpha and beta. Local replay: [`packages/cli/test/functions-rtdb/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/functions-rtdb/oracle-conformance.test.ts#L324) assertion set `functions-rtdb#5`. | 5 |\n| onValueCreated(ref, handler) | | When an ancestor write creates an exact matched descendant, the event snapshot is projected to that descendant rather than the ancestor object. | ✓ | oracle: [`functions-rtdb-onvaluecreated-descendant-projection.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-descendant-projection.json); leaf snapshot excluded its sibling. Local replay: [`packages/cli/test/functions-rtdb/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/functions-rtdb/oracle-conformance.test.ts#L325) assertion set `functions-rtdb#6`. | 6 |\n| onValueCreated(ref, handler) | | One multi-location update that creates multiple wildcard-matched children produces one create delivery for each child. | ✓ | oracle: [`functions-rtdb-onvaluecreated-wildcard-batches.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-wildcard-batches.json); one update delivered delta and gamma. Local replay: [`packages/cli/test/functions-rtdb/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/functions-rtdb/oracle-conformance.test.ts#L324) assertion set `functions-rtdb#7`. | 7 |\n| DatabaseEvent.data | | The delivered DataSnapshot exposes the created value, key, existence, JSON projection, child lookup, child count, and child enumeration. | ✓ | oracle: [`functions-rtdb-onvaluecreated-exact-create.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-exact-create.json); frozen val/key/exists/toJSON/child enumeration shape. Local replay: [`packages/cli/test/functions-rtdb/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/functions-rtdb/oracle-conformance.test.ts#L322) assertion set `functions-rtdb#8`. | 8 |\n| DatabaseEvent.data.ref | | The snapshot ref is an Admin DatabaseReference rooted at the matched path and can perform an awaited write from the handler. | ✓ | oracle: [`functions-rtdb-onvaluecreated-exact-create.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-exact-create.json); matched Admin ref completed the awaited sibling write. Local replay: [`packages/cli/test/functions-rtdb/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/functions-rtdb/oracle-conformance.test.ts#L322) assertion set `functions-rtdb#9`. | 9 |\n| DatabaseEvent.authType / authId | | For the production Admin SDK write, the event exposes authType `unknown` and authId `null`. | ✓ | oracle: [`functions-rtdb-onvaluecreated-exact-create.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-exact-create.json). Local replay: [`packages/cli/test/functions-rtdb/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/functions-rtdb/oracle-conformance.test.ts#L322) assertion set `functions-rtdb#10`. | 10 |\n| onValueCreated(ref, async handler) | | A Promise returned by the handler keeps the execution open through delayed asynchronous work and its awaited Admin write. | ✓ | oracle: [`functions-rtdb-onvaluecreated-exact-create.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-exact-create.json); capture logged only after the delayed Admin write completed. Local replay: [`packages/cli/test/functions-rtdb/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/functions-rtdb/oracle-conformance.test.ts#L322) assertion set `functions-rtdb#11`. | 11 |\n| onValueCreated(ref, handler) | | A handler that throws or returns a rejected Promise is reported by the managed runtime; with retry disabled, the Eventarc request can still be acknowledged with HTTP 200. | ? | oracle: [`functions-rtdb-onvaluecreated-failed-execution.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-failed-execution.json); Pyric observes the rejected handler and marker, but has no Eventarc HTTP request seam with which to replay the captured 200 acknowledgement. | 12 |\n| onValueCreated(ref, handler) | | Sequential creates are all delivered; their observed arrival order is evidence, not an ordering guarantee. | ✓ | oracle: [`functions-rtdb-onvaluecreated-wildcard-batches.json`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-wildcard-batches.json); all three arrived in observed order 2, 1, 3. Local replay: [`packages/cli/test/functions-rtdb/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/cli/test/functions-rtdb/oracle-conformance.test.ts#L324) assertion set `functions-rtdb#13`. | 13 |\n\n## Current gaps\n\n### Unverified\n\nTracked behavior whose available evidence does not yet establish the production result.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"unverified\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"unverified\" role=\"img\" aria-label=\"Unverified\" title=\"Unverified\"></span><span class=\"compat-main\"><code class=\"compat-api\">onValueCreated(ref, handler)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">A handler that throws or returns a rejected Promise is reported by the managed runtime; with retry disabled, the Eventarc request can still be acknowledged with HTTP 200.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">oracle: <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/functions-rtdb/functions-rtdb-onvaluecreated-failed-execution.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>functions-rtdb-onvaluecreated-failed-execution.json</code></a>; Pyric observes the rejected handler and marker, but has no Eventarc HTTP request seam with which to replay the captured 200 acknowledgement.</div></div></details>\n</div>\n" }, { "slug": "rules-compat", "label": "Rules", "title": "pyric/rules compatibility matrix", "markdown": "<!-- Generated from the conformance model (registry rows + surface contracts). Do not edit by hand; run bun run compat:generate. -->\n\n# `pyric/rules` compatibility matrix\n\n<div class=\"compat-stat\">\n<p class=\"compat-stat-surface\">This is a Pyric-native API with no Firebase counterpart, so coverage is measured against its own public exports rather than a Firebase surface.</p>\n</div>\n\n\n## Status legend\n\n| Status | Meaning |\n|---|---|\n| ✓ | **Conforming** — the simulator matches the production verdict, locked by a replayed observation |\n| ⚠ | **Diverged (documented)** — a known simulator divergence from production with a written reason |\n\n## Firestore rules engine — production simulator conformance (rules-firestore corpus)\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Rules simulator | | CEL builtins `math.*`/`timestamp.*`/`duration.*` (FM3) — arithmetic, date, and duration comparisons in rules | ✓ | [`oracle:rules-firestore-builtins-time-and-math`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-builtins-time-and-math.json) — production Firestore Rules Test API verdicts for corpus scenario \"builtins-time-and-math\", replayed verdict-for-verdict against the local rules simulator by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L55); all cases match production. | 160 |\n| Rules simulator | | `Bytes`, `String.toUtf8()`, and `hashing.{md5,sha256,crc32,crc32c}()` (Item 5.3) in rules | ✓ | [`oracle:rules-firestore-bytes-toutf8-and-hashing`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-bytes-toutf8-and-hashing.json) — a 2026-07-21 production Firestore Rules Test API recapture supplies 20 verdicts, including paired negative and positive witnesses for padded base64url, the URL-safe alphabet, uppercase hexadecimal MD5/SHA-256, and production's little-endian CRC32/CRC32C byte serialization. [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7) replays all verdicts against the local simulator; `unit:rules/simulator/wrappers/bytes.test.ts` pins the exact representations and reference digests. | 161 |\n| Rules simulator | | Typed cross-type operator overloads for `Timestamp`/`Duration` (Item 2) in rules — no silent numeric coercion / type-identity loss | ✓ | [`oracle:rules-firestore-cross-type-operator-overloads`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-cross-type-operator-overloads.json) — production Firestore Rules Test API verdicts for corpus scenario \"cross-type-operator-overloads\", replayed verdict-for-verdict against the local rules simulator by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7); all cases match production. | 162 |\n| Rules simulator | | CEL tri-state error absorption in `\\|\\|`/`&&` (RULES-B3) — `error \\|\\| true` → ALLOW, `error && false` → DENY, commutative absorption (not JS left-to-right short-circuit) | ✓ | [`oracle:rules-firestore-error-absorption-and-or`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-error-absorption-and-or.json) — production Firestore Rules Test API verdicts for corpus scenario \"error-absorption-and-or\", replayed verdict-for-verdict against the local rules simulator by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7); all cases match production. | 163 |\n| Rules simulator | | `getAfter()`/`existsAfter()` expose the projected post-write target and cross-document atomic batch state; create exists, delete does not, and an unrelated unchanged document falls back to its committed existence | ✓ | [`oracle:rules-firestore-get-after-and-exists-after`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-get-after-and-exists-after.json) records authenticated Web SDK verdicts against temporarily deployed production rules, including target create/delete and unrelated-path semantics. Its lifecycle proves exact rules release restoration; retained diagnostics document why the hosted Rules Test API was not sufficient. Replayed verdict-for-verdict by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L28); [`get-after.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/simulator/get-after.test.ts#L2) and [`get-after-batch.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/simulator/get-after-batch.test.ts#L4) cover target, sibling-write, solo-write denial, delete, and atomicity locally. | 164 |\n| Rules simulator | | `get()` of a missing document (RULES-B8) in rules — resource identity (`id`/`__name__`) exposure on a mocked/missing get() result | ✓ | [`oracle:rules-firestore-get-missing-doc`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-get-missing-doc.json) — production Firestore Rules Test API verdicts for corpus scenario \"get-missing-doc\", replayed verdict-for-verdict against the local rules simulator by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7). Serializable `functionMocks` now retain their production data-only shape, so mocked `get()` results leave `id`/`__name__` absent and both identity reads error to DENY; real DocStore lookups retain their separate identity-bearing path. All 8 cases match production. | 165 |\n| Rules simulator | | `request.path`/`request.query`/`resource.id`/`resource.__name__` globals (Item 6) in rules | ✓ | [`oracle:rules-firestore-globals-request-path-and-resource-id`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-globals-request-path-and-resource-id.json) — production Firestore Rules Test API verdicts for corpus scenario \"globals-request-path-and-resource-id\", replayed verdict-for-verdict against the local rules simulator by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7). `request.query` is now absent on non-list operations, so reading it on create errors to DENY instead of false-ALLOWing through an invented empty map. `request.path` and the create-time absent-resource controls remain unchanged; all 6 cases match production. Resource identity on an EXISTING document is scoped to firestore-rules#183. | 166 |\n| Rules simulator | | `int`/`float` division and type distinction (RULES-B5) in rules — truncating int÷int, float division stays float, div-by-zero denies, `is int`/`is float` distinct | ✓ | [`oracle:rules-firestore-int-float-and-division`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-int-float-and-division.json) — production Firestore Rules Test API verdicts for all ten corpus cases, replayed verdict-for-verdict by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7). The simulator now revives non-integral JSON wire numbers as RulesFloat values, preserving production's float payload type while retaining bare integral numbers as ints; `unit:rules/simulator/handler.test.ts` pins both literal and payload distinctions. | 167 |\n| Rules simulator | | `List.concat()`/`removeAll()`/`toSet()` (Item 5.2) in rules | ✓ | [`oracle:rules-firestore-list-methods-concat-removeall-toset`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-list-methods-concat-removeall-toset.json) — production Firestore Rules Test API verdicts for all nine cases, replayed verdict-for-verdict by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7). The seven positive List witnesses and the concat DENY witness match; the historical `toSet().difference(list)` case remains DENY because production requires a Set argument. | 168 |\n| Rules simulator | | `Map.get(key, default)`, including list-form nested-path traversal (Item 3), in rules | ✓ | [`oracle:rules-firestore-map-get-string-and-list-form`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-map-get-string-and-list-form.json) — production Firestore Rules Test API verdicts for corpus scenario \"map-get-string-and-list-form\", replayed verdict-for-verdict against the local rules simulator by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7); all cases match production. | 169 |\n| Rules simulator | | `matches()` as an anchored full-string RE2 match (RULES-B4) in rules — a pattern matching only a substring is `false` | ✓ | [`oracle:rules-firestore-matches-full-string-regex`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-matches-full-string-regex.json) — production Firestore Rules Test API verdicts for corpus scenario \"matches-full-string-regex\", replayed verdict-for-verdict against the local rules simulator by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7); all cases match production. | 170 |\n| Rules simulator | | `Path` wrapper, `path()` constructor, and `Path.bind()` (Item 5.4) in rules | ✓ | [`oracle:rules-firestore-path-constructor-and-bind`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-path-constructor-and-bind.json) — production Firestore Rules Test API verdicts for all nine corpus cases in \"path-constructor-and-bind\", replayed verdict-for-verdict against the local rules simulator by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7), including production's rejection of a Path argument to `path()`. | 171 |\n| Rules simulator | | Own-keys-only map membership and constructor-access denial (RULES-B7) in rules — `'toString' in map` is `false`, `.constructor` access errors (no JS prototype-chain leakage) | ✓ | [`oracle:rules-firestore-prototype-chain-keys`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-prototype-chain-keys.json) — production Firestore Rules Test API verdicts for corpus scenario \"prototype-chain-keys\", replayed verdict-for-verdict against the local rules simulator by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7); all cases match production. | 172 |\n| Rules simulator | | Range-slice `[i:j]` syntax for `List` and `String` (Item 4) in rules | ✓ | [`oracle:rules-firestore-range-slice-list-and-string`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-range-slice-list-and-string.json) — production Firestore Rules Test API verdicts for all ten corpus cases in \"range-slice-list-and-string\", replayed verdict-for-verdict against the local rules simulator by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7), including out-of-bounds slice-end rejection for both lists and strings. | 173 |\n| Rules simulator | | `Set.difference()`/`union()`/`intersection()` (Item 5.1) in rules | ✓ | [`oracle:rules-firestore-set-algebra-difference-union-intersection`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-set-algebra-difference-union-intersection.json) captures the receiver and argument boundary in twelve production cases: Map.keys() is a List and rejects Set-only algebra, explicit toSet() receivers ALLOW difference/union/intersection with Set arguments, and a wrong-result control DENIES. The local simulator replays every verdict and the per-construct acceptance probes use the same positive toSet() shapes. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7)) | 174 |\n| Rules simulator | | String-literal escape handling feeding `matches()` (Class B) in rules — `\\\\.` is unescaped before RE2 compilation, not forwarded raw to a JS `RegExp` | ✓ | [`oracle:rules-firestore-string-literals-and-regex`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-string-literals-and-regex.json) — production Firestore Rules Test API verdicts for corpus scenario \"string-literals-and-regex\", replayed verdict-for-verdict against the local rules simulator by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7); all cases match production. | 175 |\n| Rules simulator | | Missing-field access as a runtime error (RULES-B2) in rules — `typo == null` denies (missing access is not null); `!(key in map)` is the real absence check | ✓ | [`oracle:rules-firestore-undefined-field-access`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-undefined-field-access.json) — production Firestore Rules Test API verdicts for corpus scenario \"undefined-field-access\", replayed verdict-for-verdict against the local rules simulator by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7); all cases match production. | 176 |\n| Rules simulator | | Explicit `UNSUPPORTED` reporting for unimplemented built-ins (Item 0.A) in rules — an unimplemented built-in abstains rather than silently DENYing | ✓ | [`oracle:rules-firestore-unsupported-feature-witness`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-unsupported-feature-witness.json) — production Firestore Rules Test API verdicts for corpus scenario \"unsupported-feature-witness\", replayed verdict-for-verdict against the local rules simulator by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7); all cases match production. | 177 |\n| Rules simulator | | Required-fields validation idiom in rules — `request.resource.data.keys().hasAll()/hasOnly()` and the `diff().addedKeys()/removedKeys()/changedKeys()/affectedKeys()/unchangedKeys()` MapDiff family gating field-level mutations | ✓ | NEW ROW, 2026-07-12: production capture proves the simulator matches the production Firestore Rules Test API verdict-for-verdict on all 7 cases (exact required-field set on create, field-scoped update via MapDiff). [`oracle:rules-firestore-required-fields-and-mapdiff`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-required-fields-and-mapdiff.json) — replayed by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7). | 178 |\n| Rules simulator | | `String.split()/lower()/upper()/trim()/replace()/size()`, `List.hasAll()/hasAny()/hasOnly()/join()`, and `Map.values()` in rules | ✓ | NEW ROW, 2026-07-12: production capture proves the simulator matches the production Firestore Rules Test API verdict-for-verdict on all 4 cases (CSV-split membership, slug/title/code normalization). [`oracle:rules-firestore-list-and-string-methods`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-list-and-string-methods.json) — replayed by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7). | 179 |\n| Rules simulator | | `request.time` Timestamp accessors (year/month/day/hours/minutes/seconds/nanos/dayOfWeek/dayOfYear/toMillis/date/time), `math.floor/round/sqrt/pow/isNaN`, `int()/float()/string()` casts, `request.method`, and `>=` / `*` operators in rules | ✓ | NEW ROW, 2026-07-12: production capture proves the simulator matches the production Firestore Rules Test API verdict-for-verdict on all 3 cases (request.time pinned via requestTime for determinism). [`oracle:rules-firestore-time-math-and-casts`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-time-math-and-casts.json) — replayed by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L24). | 180 |\n| Rules simulator | | `latlng.value().latitude()/longitude()/distance()` and `duration.value()/time()/abs()` in rules | ✓ | Production captures from 2026-07-12 and the 2026-07-21 recapture prove the simulator matches the Firestore Rules Test API verdict-for-verdict on all 3 cases: coordinate and duration bounds plus exact `duration.value(1500, 'ms').seconds()` / `.nanos()` components. [`oracle:rules-firestore-duration-and-latlng`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-duration-and-latlng.json) — replayed by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7). | 181 |\n| Rules simulator | | User-defined `function`/`let`, granular `allow get/list/update/write` verbs, the ternary operator, and a recursive `{document=**}` match in rules | ✓ | NEW ROW, 2026-07-12: production capture proves the simulator matches the production Firestore Rules Test API verdict-for-verdict on all 7 cases (role-gated docs + public recursive subtree). [`oracle:rules-firestore-functions-verbs-and-recursive`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-functions-verbs-and-recursive.json) — replayed by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L33). | 182 |\n| Rules simulator | | Resource identity (`resource.id`/`resource.__name__`/`request.resource.id`) on an EXISTING document (RULES-B12) — production leaves the identity keys ABSENT, so reading one errors → DENY, the error survives negation, and only a determining `\\|\\|` operand absorbs it | ✓ | NEW ROW, 2026-07-12: production capture proves the simulator matches the production Firestore Rules Test API verdict-for-verdict on all 13 cases. Production builds `resource` (and `request.resource`) from the supplied document alone and derives NO identity from the request path, so `resource.id`/`resource.__name__`/`request.resource.id` read as absent properties — verbatim `Property id is undefined on object.` / `Property __name__ is undefined on object.` — which absorb to DENY. The simulator previously SYNTHESIZED `id`/`__name__` from the request path on get/list/update/delete, so `resource.id == id` ALLOWed where production DENIES: an over-permissive divergence, now removed. The negation cases (`resource.id != 'zzz'`, `!(resource.id == 'zzz')`) pin that the absent read is a propagating error rather than a false, which an absent-as-undefined model would false-ALLOW. [`oracle:rules-firestore-resource-document-identity`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-resource-document-identity.json) — replayed by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7). | 183 |\n| Rules simulator | | Request-target `resource` on a missing document (RULES-B13) — get/update/delete existence guards, null comparison, and data access all error to DENY | ✓ | NEW ROW, 2026-07-12: production capture proves the simulator matches the Firestore Rules Test API verdict-for-verdict on all 9 cases. A missing request target makes the `resource` global an error value: `resource != null`, `resource == null`, and `resource.data.value` all DENY. The simulator previously constructed `{ data: {} }` for missing get/update/delete requests, so the ordinary `resource != null` existence guard false-ALLOWed all three operations. Modeling the target as raw null fixes that guard but makes `resource == null` false-ALLOW, so the evaluator now raises `Null value error.` when the absent `resource` identifier itself is evaluated. Existing-document get/update/delete and data-access controls still ALLOW. [`oracle:rules-firestore-resource-missing-document`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-resource-missing-document.json) — replayed by [`packages/pyric/test/rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7). | 184 |\n| Rules simulator | | A ruleset with NO `rules_version` declaration — production accepts it and defaults to v1 | ✓ | NEW ROW, 2026-07-17: production capture proves a version-less ruleset deploys and evaluates (production defaults `rules_version` to v1; the shared grammar previously rejected the file at parse — PR #333 grammar fix, the same evidence that reclassified corpus file 001-missing-version.rules from invalid to edge-cases). [`oracle:rules-firestore-optional-rules-version`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-optional-rules-version.json) matches the local simulator verdict-for-verdict on all 3 cases. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7)) | 185 |\n| Rules simulator | | Function declarations at GLOBAL and SERVICE scope in a Firestore ruleset, with match-scope shadowing | ✓ | ROW CORRECTED, 2026-07-17 (same day): the simulator now seeds the match-tree walk with global- and service-scope function declarations (declaration order global → service → match preserves inner-shadows-outer), so the previously-abstaining cases evaluate. [`oracle:rules-firestore-global-and-service-scope-functions`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-global-and-service-scope-functions.json) — production Firestore Rules Test API verdicts captured 2026-07-17 — now replays verdict-for-verdict against the local simulator with all 5 cases matching (shadowing included), asserted by [`unit:rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7). The prior unsupported row recorded the honest UNSUPPORTED abstention; that gap is closed (#346). | 186 |\n| Rules simulator | | Hierarchical nested `match` composition — a child match resolves relative to its parent and grants only the exact nested document path | ✓ | [`oracle:rules-firestore-hierarchical-match-cascade`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-hierarchical-match-cascade.json) captures the exact nested child ALLOW plus parent, sibling, and over-deep DENY controls from the production Firestore Rules Test API, replayed verdict-for-verdict by [`packages/pyric/test/rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7). | 187 |\n| Rules simulator | | Strict boolean operands for `&&`, `\\|\\|`, and ternary control flow, plus create-time `resource` / `request.resource` semantics | ✓ | [`oracle:rules-firestore-strict-boolean-control-flow`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-strict-boolean-control-flow.json) captures three direct non-boolean DENYs, three tautological error discriminators that still DENY, a boolean ALLOW control, create-time `resource == null` DENY, and incoming `request.resource.data` ALLOW; replayed verdict-for-verdict by [`packages/pyric/test/rules/oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7). | 188 |\n| Rules simulator | | The exact six shared `auth` and `membership` function bodies preserve signed-in, owner, custom-claim, explicit membership-map, and role semantics under Firestore Rules | ✓ | NEW ROW, 2026-07-21: one bounded, read-only Firestore Rules Test API request captured 12 verdicts for the exact bodies shipped from `auth` and `membership`; [`oracle:rules-firestore-common-auth-membership-firestore`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-common-auth-membership-firestore.json) matches the local Firestore rules simulator verdict-for-verdict (6 ALLOW, 6 DENY). The cross-service source-lock test requires these six Firestore corpus bodies, the previously captured Storage corpus bodies, and the shipped modules to remain normalized-AST-identical. (Structured evidence: [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L7)) | 189 |\n| Rules simulator | | In multi-document batched writes and transactions, getAfter() deep-merges incoming updates with pre-existing sibling document state, preserving unmodified fields; updates targeting non-existent siblings project to null so existsAfter() returns false. | ✓ | Captured production behavior via rules-firestore-atomic-batch-sibling-merge proves getAfter() evaluates against deep-merged sibling state during multi-document atomic updates. (Structured evidence: [`rules-firestore-atomic-batch-sibling-merge`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-atomic-batch-sibling-merge.json), [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L28), [`get-after-batch.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/sandbox/firestore/simulator/get-after-batch.test.ts#L2)) | 190 |\n| Rules simulator | | Compile-time AST verification rejects non-conforming casts (bool()), unsupported helper methods (debug(), math.isInfinite()), and bare Map membership assertions (map.hasAll/hasAny/hasOnly). | ✓ | Captured production behavior via rules-firestore-ast-strictness-and-unsupported-casts proves compile-time AST verification and execution rejection of bool(), math.isInfinite(), debug(), and bare Map membership assertions. (Structured evidence: [`rules-firestore-ast-strictness-and-unsupported-casts`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/firestore-rules/rules-firestore-ast-strictness-and-unsupported-casts.json), [`oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/oracle-conformance.test.ts#L55), [`hallucinations.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/linter/hallucinations.test.ts#L137)) | 191 |\n\n## Storage rules engine — `parseStorageRules` / `evaluateStorageRules` (rules-storage corpus)\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Rules | | `parseStorageRules(source)` returns an opaque handle | ✓ | [`unit:storage/sandbox/rules.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/sandbox/rules.test.ts#L2) (\"parses the canonical session-archive ruleset\") | 94 |\n| Rules | | `parseStorageRules` rejects non-`firebase.storage` service headers | ✓ | [`unit:storage/sandbox/rules.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/sandbox/rules.test.ts#L2) (\"rejects unknown service header\") | 95 |\n| Rules | | `evaluateStorageRules` supports granular verbs (`get`/`list`/`create`/`update`/`delete`) alongside `read`/`write` umbrella expansion, comma-separated verb lists, and per-verb default-deny | ✓ | STALE ROW, corrected 2026-07-10: production capture proves the evaluator already supports the full six-verb grant surface (umbrella read→{get,list}, write→{create,update,delete}, single granular grants, comma-separated grants, per-verb deny-by-default), matching production verdict-for-verdict on 12 of the scenario's 13 non-existence cases. [`oracle:rules-storage-verbs-umbrella-granular`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-verbs-umbrella-granular.json) (all `read`/`write`/`get`/comma-verb cases). One related existence-semantics case in the same scenario diverges over-permissively and is carried by `#118 Rules` (the `resource == null` create-if-absent guard), not by this granular-verb row. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L6)) | 96 |\n| Rules | | `parseStorageRules` rejects unterminated string literals with `SyntaxError` | ✓ | [`unit:storage/sandbox/rules.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/sandbox/rules.test.ts#L2) (\"rejects unterminated strings\") | 97 |\n| Rules | | `evaluateStorageRules` matches `match /sessions/{id} { allow read: if request.auth != null; }` for an authed read | ✓ | [`unit:storage/sandbox/rules-evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/sandbox/rules-evaluator.test.ts#L3) (\"allows authenticated reads of /sessions/{id}\") | 98 |\n| Rules | | `evaluateStorageRules` denies anonymous reads when the rule requires `request.auth != null` | ✓ | [`unit:storage/sandbox/rules-evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/sandbox/rules-evaluator.test.ts#L3) (\"denies anonymous reads\") | 99 |\n| Rules | | `evaluateStorageRules` supports `request.resource.size < N` constraints (with arithmetic literals like `10 * 1024 * 1024`) | ✓ | [`unit:storage/sandbox/rules-evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/sandbox/rules-evaluator.test.ts#L3) (\"allows JSON writes under 10MB\") | 100 |\n| Rules | | `evaluateStorageRules` supports `request.resource.contentType == '<mime>'` constraints | ✓ | [`unit:storage/sandbox/rules-evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/sandbox/rules-evaluator.test.ts#L3) (mime constraint inside the session-archive ruleset) | 101 |\n| Rules | | Multi-segment wildcard `{allPaths=**}` matches zero-or-more remaining segments | ✓ | [`unit:storage/sandbox/rules-evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/sandbox/rules-evaluator.test.ts#L2) (parser + evaluator both honor the `**` form) | 102 |\n| Rules | | Path-parameter binding (`{sessionId}`) accessible inside the `if` expression | ✓ | [`unit:storage/sandbox/rules-evaluator.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/sandbox/rules-evaluator.test.ts#L2) | 103 |\n| Rules | | User-defined `function` definitions — `let` bindings, functions calling functions, and match-block-scoped helper functions (lexical scoping) | ✓ | STALE ROW, corrected 2026-07-10: production capture proves the evaluator supports user-defined functions with `let` bindings, nested function calls, and block-scoped helpers. [`oracle:rules-storage-functions-let-scope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-functions-let-scope.json) matches production verdict-for-verdict on all 5 cases. Same-name shadowing and undefined-function calls are compile-time rejections in production and are covered by evaluator unit tests instead (they cannot be captured as a clean production verdict). (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L15)) | 104 |\n| Rules | | `request.time` compared against `timestamp.date(y,m,d)` and `timestamp.value(ms)` constructors | ✓ | NEW ROW, 2026-07-10: production capture proves the evaluator supports `request.time` comparisons against both timestamp constructors. [`oracle:rules-storage-request-time-timestamp`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-request-time-timestamp.json) matches production verdict-for-verdict on all 4 cases (deadline-before/after via `timestamp.date()`, epoch-bound before/after via `timestamp.value()`). (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L6)) | 112 |\n| Rules | | `string.matches(regex)` with whole-string anchoring (a partial match denies) | ✓ | NEW ROW, 2026-07-10: production capture proves `matches()` is whole-string anchored, matching a RE2 pattern only when it covers the entire string. [`oracle:rules-storage-matches-regex`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-matches-regex.json) matches production verdict-for-verdict on all 3 cases. RE2-inexpressible patterns are rejected at ruleset compile time by production and are covered by evaluator unit tests instead. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L194)) | 113 |\n| Rules | | `resource.metadata.<key>` custom-metadata access in dotted (`resource.metadata.owner`) and bracket (`resource.metadata['owner']`) form, including missing-key deny | ✓ | NEW ROW, 2026-07-10: production capture proves dotted and bracket metadata access resolve identically, and a missing key denies. [`oracle:rules-storage-metadata-access`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-metadata-access.json) matches production verdict-for-verdict on all 5 cases. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L131)) | 114 |\n| Rules | | Cross-service `firestore.get()` / `firestore.exists()` lookups from a Storage ruleset, with `$(expr)` path interpolation and qualified function-mock names | ✓ | NEW ROW, 2026-07-10: production capture proves the evaluator resolves cross-service Firestore lookups from Storage rules, including interpolated document paths and both the map-returning `get()` and bool-returning `exists()` forms. [`oracle:rules-storage-firestore-lookup`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-firestore-lookup.json) matches production verdict-for-verdict on all 4 cases. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L71)) | 115 |\n| Rules | | `resource.name` (full object path), `resource.timeCreated`, `resource.updated`, `resource.bucket` — the object-identity and time fields of the existing-object binding, plus `duration.value(n, unit)` | ✓ | ROW CORRECTED, 2026-07-12: the fields are now modeled, sourced from the persisted object record. [`oracle:rules-storage-resource-object-identity`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-resource-object-identity.json) matches production verdict-for-verdict on all 9 cases — an extension guard (`resource.name.matches`), a freshness window (`resource.timeCreated + duration.value(1, 'h')`), an immutability check (`resource.timeCreated == resource.updated`), and an absent-property negation. Production semantics pinned by the capture: `resource.name` is the object's FULL path within the bucket (GCS object-name convention, sourced from the persisted record's `fullPath`, not the client SDK's last-segment `name`); `timeCreated`/`updated` are timestamps (production rejects an int with \"Unsupported operation error. Received: int < timestamp\"); the update-time field is `updated` — there is NO `resource.timeUpdated`. An absent property is an evaluation error that absorbs to DENY and SURVIVES negation, so `resource.name != 'x'` on an object with no name denies rather than false-allowing. The prior row recorded these fields as an unsupported gap; that gap is closed. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L6)) | 116 |\n| Rules | | `request.resource.size` arithmetic bounds (`+`/`-`/`*`/`/`, `>=`/`<=`), request/existing content-type and custom `metadata` checks, `request.method`/`request.path`, a `request.auth.token` custom-claim branch, granular `allow list`, and a recursive `{allPaths=**}` public read tree | ✓ | NEW ROW, 2026-07-12: production capture proves the evaluator matches the production Storage Rules Test API verdict-for-verdict on all 8 cases (owner-scoped size-bounded upload, immutable-content-type update, list grant, public recursive read). [`oracle:rules-storage-metadata-verbs-and-arithmetic`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-metadata-verbs-and-arithmetic.json) — replayed by [`packages/pyric/test/storage/rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L75). | 117 |\n| Rules | | `resource` on a create where no object exists: the create-if-absent guard `allow create: if resource == null` | ✓ | GAP CLOSED, 2026-07-21: production raises a null-value error when `resource` is referenced on a create where no object exists, including `resource == null`, so the expression denies. The evaluator now represents the missing binding as a Rules error rather than a usable null. [`oracle:rules-storage-verbs-umbrella-granular`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-verbs-umbrella-granular.json) replays the captured DENY directly with no divergence pin; `unit:storage/sandbox/rules-evaluator.test.ts` covers the evaluator seam. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L6)) | 118 |\n| Rules | | Ternary `?:` — branch selection, error-in-condition deny, and `error \\|\\| true` absorption | ✓ | NEW ROW, 2026-07-17: production capture proves the evaluator selects ternary branches by condition, denies when the CONDITION errors (even with identical `true` branches — production errors the whole conditional rather than falling through to a branch), and still absorbs the same error through `\\|\\| true`. [`oracle:rules-storage-ternary-and-error-absorption`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-ternary-and-error-absorption.json) matches production verdict-for-verdict on all 5 cases. A division-by-zero in the condition previously yielded JS Infinity (no error) and false-ALLOWed the erroring-condition case; zero divisors now error-and-deny. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L6)) | 119 |\n| Rules | | `in` on list literals (membership) and on maps (OWN-key membership) — prototype names (`toString`/`constructor`/`hasOwnProperty`) are never keys | ✓ | NEW ROW, 2026-07-17: production capture proves `in` tests list membership and map OWN keys only — `'toString' in metadata` is false unless the map literally carries that key, for both custom-metadata maps and map literals. [`oracle:rules-storage-in-membership-and-proto-keys`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-in-membership-and-proto-keys.json) matches production verdict-for-verdict on all 8 cases. Pre-fix the evaluator used JS `in`, which walks the prototype chain and false-ALLOWed the proto-key cases (the same bug class the Firestore simulator fixed under RULES-B7); the own-key check now guards it, pinned by `unit:storage/sandbox/rules-values.test.ts` JS-semantics guards. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L6)) | 120 |\n| Rules | | `is` type tests over the storage bindings (`size is int`, `contentType is string`, `metadata is map`, `split() is list`) and int/float LITERAL typing (`1.0 is float`, `1 is int`, cross checks false) | ✓ | ROW CORRECTED, 2026-07-17 (same day): the evaluator now adopts the simulator's RULES-B5 float model — a `RulesFloat` wrapper tags float values (a numeric literal written with a decimal point converts to one) while a bare JS number means int — so `1.0 is float` / `!(1.0 is int)` type by literal form exactly as production does. [`oracle:rules-storage-type-checks-is`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-type-checks-is.json) now matches production verdict-for-verdict on all 3 cases with no knownGap abstentions. The prior same-day row recorded the value-typed divergence; that gap is closed. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L6)) | 121 |\n| Rules | | List/map literals, range-slice access `[i:j]` on lists and strings (out-of-range bounds ERROR, no JS clamp), and structural `==` on lists | ✓ | NEW ROW, 2026-07-17: production capture proves slices evaluate on `split()` lists and on strings, that an out-of-range slice END errors and denies (production does NOT clamp to length the way JS `.slice()` does — same semantics the Firestore capture pinned in rules-firestore-range-slice-list-and-string), and that a slice compares equal to a list literal structurally. [`oracle:rules-storage-list-map-literals-and-slice`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-list-map-literals-and-slice.json) matches production verdict-for-verdict on all 6 cases. Pre-fix the evaluator clamped bounds JS-style and compared lists by reference identity; both fixed and pinned by `unit:storage/sandbox/rules-values.test.ts` JS-semantics guards. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L6)) | 122 |\n| Rules | | Float literals, `%`, unary minus, float division, negative-dividend modulo (`-7 % 2 == -1`), and TRUNCATING int division (`10 / 4 == 2`, `-7 / 2 == -3`) | ✓ | ROW CORRECTED, 2026-07-17 (same day): with the RULES-B5 float model adopted from the simulator, int ÷ int truncates toward zero, float division stays float (re-tagged), and int-op-float promotes to float. [`oracle:rules-storage-float-modulo-unary-minus`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-float-modulo-unary-minus.json) now matches production verdict-for-verdict on all 6 cases with no knownGap abstentions. An int zero divisor errors-and-denies; a float zero divisor follows the simulator's CEL-pinned ±Infinity/NaN. The prior same-day row recorded the truncation divergence; that gap is closed. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L6)) | 123 |\n| Rules | | Function declarations at GLOBAL scope (above `service`) and SERVICE scope (inside `service`, outside any match), with inner-shadows-outer resolution | ✓ | NEW ROW, 2026-07-17: production capture proves production accepts functions declared at global and service scope and resolves a match-scope function over a same-named global (inner shadows outer). [`oracle:rules-storage-function-scopes-and-shadowing`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-function-scopes-and-shadowing.json) matches production verdict-for-verdict on all 5 cases. The shared grammar previously rejected both placements at parse (PR #333 grammar fix); the storage evaluator attaches global- and service-scope functions to the root match block, which the capture proves is verdict-equivalent to production's scoping for this surface. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L6)) | 124 |\n| Rules | | `2+modules` Storage rules resolve through service-aware contracts: the common `auth` and `membership` exports are admitted and retain their production auth, claim, map-membership, and role semantics; Firestore-only helpers are rejected before evaluation | ✓ | NEW ROW, 2026-07-20; paired proof added 2026-07-21: one targeted production Rules Test API capture proves the exact six bundled function bodies (`isAuthenticated`, `isOwner`, `hasClaim`, `hasClaimRole`, `isMemberOf`, `hasRole`) under `firebase.storage`, matching the local evaluator verdict-for-verdict on 12 allow/deny cases. [`oracle:rules-storage-common-auth-membership`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-common-auth-membership.json) supplies the Storage answer key; Firestore Rules row #189 independently captures the same exact bodies under `cloud.firestore` (12 matching verdicts). A separately authored, explicitly reconstructed normalized-AST digest locks the captured Storage corpus bodies, and the source-lock test requires the Firestore corpus and shipped modules to remain AST-identical. Resolver tests separately prove `2+modules` lowers these imports to plain v2 and fails closed with `INCOMPATIBLE_FUNCTION` for a Firestore-only export and for a transitive helper that reads `resource.data`; the Storage service integration test proves normal setup invokes that checked resolver. (Structured evidence: [`storage-stdlib-source-lock.test.ts`](https://github.com/davideast/pyric/blob/main/packages/conformance/test/src/storage-stdlib-source-lock.test.ts#L73), [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L7), [`resolver.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/modules/resolver.test.ts#L9), [`enforce.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/enforce.test.ts#L17)) | 125 |\n| Rules | | Storage-native upload primitives at security boundaries: inclusive byte maximums, exact and whole-string MIME checks, metadata-only updates with unchanged bytes/owner, string path wildcards, generation/metageneration identity, and strict creation-time windows | ✓ | NEW ROW, 2026-07-20: [`oracle:rules-storage-upload-primitives-boundaries`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-upload-primitives-boundaries.json) captures 26 production cases in one Rules Test API request. The local evaluator matches every case scoped to this row: size 0/exact/+1, MIME exact/case/parameter and regex anchoring, metadata update invariants, filename wildcard typing, exact/absent generation identity, and request.time just before/at/after a strict 60-second boundary plus a future timeCreated fixture. Metadata collection methods and missing request.resource are separated into divergence rows #127/#128 rather than contaminating this credit. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L7)) | 126 |\n| Rules | | Storage custom-metadata collection methods: `Map.keys().hasAll()` for required-key policy and `Map.get(key, default)` for an absent key | ✓ | GAP CLOSED, 2026-07-21: the Storage evaluator now implements own-key `Map.keys()`, structural `hasAll()`, and string-key `Map.get(key, default)`. [`oracle:rules-storage-upload-primitives-boundaries`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-upload-primitives-boundaries.json) replays the production required-key and absent-key-default cases directly with no divergence pin; public-seam unit cases cover both admitted forms. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L6)) | 127 |\n| Rules | | `request.resource` on delete where no incoming object exists: `request.resource == null` | ✓ | GAP CLOSED, 2026-07-21: production errors when `request.resource` is absent on delete, including a direct null comparison. The evaluator now carries the absent incoming binding as a Rules error. [`oracle:rules-storage-upload-primitives-boundaries`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-upload-primitives-boundaries.json) replays the captured DENY directly with no divergence pin; `unit:storage/sandbox/rules-evaluator.test.ts` covers the evaluator seam. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L128)) | 128 |\n| Rules | | Mocked Storage-to-Firestore lookup evaluation: get/exists return and missing-field errors, repeated and same-resolved-path calls, get+exists composition, and lazy `\\|\\|`/`&&`/ternary branches with hosted function-call diagnostics | ⚠ | DOCUMENTED MOCK-ORACLE DIVERGENCE, 2026-07-21: [`oracle:rules-storage-firestore-lookup-budget`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-firestore-lookup-budget.json) matches the local evaluator on 13 of 14 cases and hosted diagnostics prove lazy branches omit lookup calls. The one mismatch is intentional and pinned on both sides: the Rules Test API ALLOWs three distinct mocked exists calls, while the evaluator DENYs the third distinct path to match the separately captured real-resource production contract in row #131. This row remains diverged because its mocked observation is not verdict-identical; it supplies no budget credit. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L71)) | 129 |\n| Rules | | Anonymous `request.auth != null` used as a ternary condition before a cross-service lookup | ✓ | GAP CLOSED, 2026-07-21: anonymous `request.auth` is now represented as an absent-property Rules error, so a ternary condition denies before selecting either lookup branch, matching hosted diagnostics. [`oracle:rules-storage-firestore-lookup-budget`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-firestore-lookup-budget.json) replays the captured DENY directly with no divergence pin; `unit:storage/sandbox/rules-methods.test.ts` covers the lookup evaluator seam. (Structured evidence: [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L71)) | 130 |\n| Rules | | Real-resource Storage-to-Firestore contract: at most two distinct document paths per evaluation with repeated-path caching; typed document data and missing-document errors; default-database-only and owning-project isolation; lazy/helper/let composition; and independence from Firestore client rules | ✓ | NEW ROW, 2026-07-21: four deploy-observe-restore captures establish the real-resource contract beyond Rules Test API mocks: [`oracle:stdlib-realstorage-p3-lookup-budget-iam-enabled`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/stdlib-realstorage-p3-lookup-budget-iam-enabled.json), [`oracle:stdlib-realstorage-p3-advanced-iam-enabled`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/stdlib-realstorage-p3-advanced-iam-enabled.json), [`oracle:stdlib-realstorage-p3-named-database`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/stdlib-realstorage-p3-named-database.json), and [`oracle:stdlib-realstorage-p3-project-isolation`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/stdlib-realstorage-p3-project-isolation.json). [`unit:conformance/storage-stdlib-real-replay.test.ts`](https://github.com/davideast/pyric/blob/main/packages/conformance/test/src/storage-stdlib-real-replay.test.ts#L90) executes the locally decidable lookup matrices and locks reviewed reconstructions of their normalized probe code; the lock records state whether the digest covers an injected block or a canonical synthetic wrapper. The surrounding deployed base rules were not retained, so no full-source digest is claimed for these historical captures. Future rigs emit both probe-block and full deployed-rules-file digests. [`unit:storage/enforce.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/enforce.test.ts#L368) proves the normal enforcement seam reads only its owning sandbox and bypasses Firestore client rules. The separate consistency capture remains bounded characterization and is not promoted into a latency guarantee. | 131 |\n| Rules | | The service-scoped `storage/uploads`, `storage/metadata`, `storage/objects`, and `storage/time` modules resolve through `2+modules` and preserve their production upload-bound, MIME, metadata, operation-identity, and strict timestamp-window semantics | ✓ | NEW ROW, 2026-07-21: one bounded Rules Test API batch captures 15 verdicts for the exact 13 deployable function bodies in the four shipped modules ([`oracle:rules-storage-stdlib-storage-modules`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-stdlib-storage-modules.json)). A separately authored, explicitly reconstructed normalized-AST digest locks the captured corpus bodies, and the source-lock test requires the shipped modules to remain AST-identical. The generic Storage stdlib fixture runner and oracle replay execute those bodies locally; resolver tests prove the modules are admitted only for `firebase.storage`, lower to plain v2, and fail closed under Firestore. The module surface intentionally excludes the advanced integrity, metadata-diff, and cross-service lookup candidates. (Structured evidence: [`storage-stdlib-source-lock.test.ts`](https://github.com/davideast/pyric/blob/main/packages/conformance/test/src/storage-stdlib-source-lock.test.ts#L53), [`rules-oracle-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L7), [`stdlib-cases.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/stdlib-cases.test.ts#L5), [`stdlib.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/stdlib.test.ts#L3)) | 132 |\n\n## RTDB rules engine — production simulator conformance (rules-rtdb corpus)\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Rules simulator | | `auth != null` gate on `.read`/`.write` — authed ops allow, signed-out ops deny | ✓ | [`oracle:rules-rtdb-r1-auth-only`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r1-auth-only.json) — production verdicts captured by deploy-observe-restore against the live oracle database (RTDB has no server-side rules test API), replayed verdict-for-verdict against the in-process simulator by [`packages/pyric/test/rules/rtdb/rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L2); all 4 cases match production. | 1 |\n| Rules simulator | | `$uid` path-variable ownership (`$uid === auth.uid`) — the owner path allows, a foreign uid and an anonymous request deny | ✓ | [`oracle:rules-rtdb-r2-own-uid`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r2-own-uid.json) — production verdicts captured by deploy-observe-restore, replayed verdict-for-verdict against the in-process simulator; all 5 cases match production, so the simulator binds the path variable against `auth.uid` exactly as production does. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L2)) | 2 |\n| Rules simulator | | Write-rule CASCADE — a truthy ancestor `.write` grants the write regardless of a deeper `!data.exists()` rule, so the populated-path write ALLOWS | ✓ | [`oracle:rules-rtdb-r3-data-exists`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r3-data-exists.json) — production verdicts captured by deploy-observe-restore, replayed verdict-for-verdict. Production ALLOWS the write to the populated path even though the child rule is `!data.exists()`: the ancestor `.write: auth != null` already granted it, and RTDB never consults a deeper rule to revoke a grant. All 3 cases match; the simulator models the same cascade. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L2)) | 3 |\n| Rules simulator | | `.validate` VETO — a failing child `.validate` denies a write the `.write` rule would otherwise permit (validate does not cascade and cannot be overridden) | ✓ | [`oracle:rules-rtdb-r4-validate-structure`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r4-validate-structure.json) — production verdicts captured by deploy-observe-restore, replayed verdict-for-verdict; both cases match production. This row was the corpus's one historical simulator-vs-production divergence: at the 2026-05-18 agreement capture the simulator ALLOWED the missing-body write because it did not veto on the child `.validate`. The simulator now denies it, and this fresh capture re-confirms production's DENY, so the divergence is RESOLVED and needs no pin. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L2)) | 4 |\n| Rules simulator | | Cascade is GRANT-ONLY — a root `.read: true` grants every descendant read (authed and anonymous), and a deeper `.write: false` cannot revoke a truthy ancestor `.write` grant | ✓ | [`oracle:rules-rtdb-r5-cascade-root-grant`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r5-cascade-root-grant.json) — production verdicts captured by deploy-observe-restore, replayed verdict-for-verdict; all 4 cases ALLOW in production, including the write to a node whose own `.write` is literal `false`. The simulator reproduces grant-only cascade rather than treating the deeper `false` as a veto. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L2)) | 5 |\n| Rules simulator | | Literal `.read`/`.write: false` — the deny-all baseline denies every op, authed or anonymous, read or write | ✓ | [`oracle:rules-rtdb-r6-deny-everything`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r6-deny-everything.json) — production verdicts captured by deploy-observe-restore, replayed verdict-for-verdict; all 3 cases DENY in production and in the simulator. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L2)) | 6 |\n| Rules simulator | | Nested path variable in an expression (`$sessionId === auth.uid`) — the matching session allows, a mismatched session denies, and an unmatched anonymous op denies with no matching rule | ✓ | [`oracle:rules-rtdb-r7-pathvar-binding`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r7-pathvar-binding.json) — production verdicts captured by deploy-observe-restore, replayed verdict-for-verdict; all 4 cases match. The simulator binds the nested variable identically and returns NO_MATCHING_RULE (read as deny) where production denies for want of a rule. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L2)) | 7 |\n| Rules simulator | | `newData` is projected at the RULE node, not the written path — a root `.write` reading `newData.hasChildren(['owner'])` sees `{item:{...}}` for a write one level deeper, so the predicate is false and the write DENIES | ✓ | [`oracle:rules-rtdb-r8-combined-check`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r8-combined-check.json) — production verdicts captured by deploy-observe-restore, replayed verdict-for-verdict. Production DENIES all three writes, including the \"matching owner\" case whose `owner` field lives under `/item` rather than at the rule node; only the auth-gated read allows. All 4 cases match, so the simulator projects `newData` at the same node production does — rule placement relative to the written path decides the outcome. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L2)) | 8 |\n| Rules simulator | | Arithmetic and comparison operators in quota rules — `+` `-` `*` `/` `%`, `>` `>=` `<` `<=`, `!==`, loose `==`, `\\|\\|`, the ternary, and unary minus | ✓ | NEW ROW, 2026-07-12: production capture proves the simulator evaluates every arithmetic and ordering operator as production does, across increment-only counters, doubling and halving deltas, a parity check, inclusive and exclusive range caps, a negative sentinel, loose equality against zero, a disjunction, and a ternary tier bound. [`oracle:rules-rtdb-r9-quota-arithmetic`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r9-quota-arithmetic.json) matches production verdict-for-verdict on all 23 cases. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L2)) | 9 |\n| Rules simulator | | Snapshot structure and type guards — `hasChild`, `parent()`, `isString`, `isNumber`, `isBoolean` | ✓ | NEW ROW, 2026-07-12: production capture proves the simulator resolves the type predicates and the `parent()` path climb as production does, including RTDB's rule that a child `.validate` runs only for children PRESENT in `newData` — the name-without-age write is denied by that field's own parent guard, not by the absent sibling's type rule, which never runs. [`oracle:rules-rtdb-r10-snapshot-type-guards`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r10-snapshot-type-guards.json) matches production verdict-for-verdict on all 7 cases. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L2)) | 10 |\n| Rules simulator | | String methods on a snapshot value — `beginsWith`, `contains`, `endsWith`, `matches` with a regex literal, `.length`, `toLowerCase`, `toUpperCase`, and replace-ALL `replace` | ✓ | NEW ROW, 2026-07-12: production capture proves `String.replace` substitutes EVERY occurrence of the substring, not only the first — production ALLOWS the write whose rule is `newData.val().replace('_', '-') === 'a-b-c'` for the value `a_b_c`, which holds only under replace-all. The simulator delegated to JavaScript's `String.prototype.replace`, which given a string pattern substitutes only the first occurrence, and DENIED that write; the substitution now replaces every occurrence and the two-underscore case guards the regression. The same capture proves `toUpperCase` is a valid production rule method that the simulator's validator previously rejected outright as unknown. [`oracle:rules-rtdb-r11-string-validation`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r11-string-validation.json) matches production verdict-for-verdict on all 18 cases. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L12)) | 11 |\n| Rules simulator | | `now` (the server evaluation clock) and `auth.token` claims, read both dotted (`auth.token.firebase.sign_in_provider`) and subscripted (`auth.token['firebase']['sign_in_provider']`) | ✓ | NEW ROW, 2026-07-12: production capture proves the simulator resolves the server clock and the decoded ID-token claims as production does, through both access forms. The subscript's receiver is load-bearing: production's rules compiler ACCEPTS `auth.token[...]`, a map indexed by key, but REJECTS the same subscript applied to a snapshot value — `newData.val()['kind']` fails to compile with \"No such method/property\" — so `[]` is a map accessor, not a general indexing operator. [`oracle:rules-rtdb-r12-server-time-and-token`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r12-server-time-and-token.json) matches production verdict-for-verdict on all 7 cases. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L31)) | 12 |\n| Rules simulator | | `getPriority()` on a plain `set` (an unset priority reads null) and the `.indexOn` query-planner directive | ✓ | NEW ROW, 2026-07-12: production capture proves a plain `set` leaves a node's priority unset, so `getPriority()` reports null in production exactly as the simulator does, and that a ruleset carrying `.indexOn` deploys and evaluates with every verdict intact. The scope is exactly that: `.indexOn` never changes an allow/deny verdict, so the twin evidences only that production's compiler accepts and deploys the directive, and it says nothing about query behavior. A NON-null priority stays unverified because writing one requires `setWithPriority`, outside the corpus op vocabulary of read and set. [`oracle:rules-rtdb-r13-priority-and-index-directive`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r13-priority-and-index-directive.json) matches production verdict-for-verdict on all 4 cases. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L2)) | 13 |\n| Rules simulator | | The `root` binding — `root.child(...).exists()` resolved against the database root | ✓ | NEW ROW, 2026-07-12: production capture proves the simulator resolves the `root` binding and reports a missing child as absent exactly as production does. The scope is bounded by the rig: the capture runner mounts the scenario subtree under a per-run randomly-named namespace while the replay suite mounts it at the root, so a rule naming any PRESENT path through `root` would address a different node on each side and diverge for harness reasons. Only a sentinel key absent under BOTH roots is mount-invariant, so that is what this row verifies; a populated `root` lookup remains unverified and needs a rig that can seed outside the scenario subtree and mount both sides at the same depth. [`oracle:rules-rtdb-r14-root-lookup`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r14-root-lookup.json) matches production verdict-for-verdict on all 3 cases. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L31)) | 14 |\n| Rules simulator | | `.validate` reaches ancestors of the written path — each applicable rule is evaluated against the merged post-write value at its own location | ✓ | [`oracle:rules-rtdb-r15-validate-ancestor-scope`](https://github.com/davideast/pyric/blob/main/packages/conformance/observations/rtdb-rules/rules-rtdb-r15-validate-ancestor-scope.json) — production DENIES a deep write that leaves its validated ancestor without the required child, while the same write under a rule-free ancestor ALLOWS and the write ALLOWS once the ancestor requirement is seeded. The simulator now walks validation from the root through the write location against the merged post-write tree and matches all 5 captured production cases. (Structured evidence: [`rules-conformance.test.ts`](https://github.com/davideast/pyric/blob/main/packages/pyric/test/rules/rtdb/rules-conformance.test.ts#L2)) | 15 |\n\n## Current gaps\n\n### Documented divergences\n\nKnown differences between Pyric and production Firebase. Each remains tracked as a non-conforming row.\n\n<div class=\"compat-list\">\n<details class=\"compat-row\" data-status=\"diverged\"><summary class=\"compat-line\"><span class=\"compat-dot\" data-status=\"diverged\" role=\"img\" aria-label=\"Diverged (documented)\" title=\"Diverged (documented)\"></span><span class=\"compat-main\"><code class=\"compat-api\">Rules</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Mocked Storage-to-Firestore lookup evaluation: get/exists return and missing-field errors, repeated and same-resolved-path calls, get+exists composition, and lazy <code>||</code>/<code>&&</code>/ternary branches with hosted function-call diagnostics</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">DOCUMENTED MOCK-ORACLE DIVERGENCE, 2026-07-21: <a href=\"https://github.com/davideast/pyric/blob/main/packages/conformance/observations/storage-rules/rules-storage-firestore-lookup-budget.json\" target=\"_blank\" rel=\"noopener noreferrer\"><code>oracle:rules-storage-firestore-lookup-budget</code></a> matches the local evaluator on 13 of 14 cases and hosted diagnostics prove lazy branches omit lookup calls. The one mismatch is intentional and pinned on both sides: the Rules Test API ALLOWs three distinct mocked exists calls, while the evaluator DENYs the third distinct path to match the separately captured real-resource production contract in row #131. This row remains diverged because its mocked observation is not verdict-identical; it supplies no budget credit. (Structured evidence: <a href=\"https://github.com/davideast/pyric/blob/main/packages/pyric/test/storage/rules-oracle-conformance.test.ts#L71\" target=\"_blank\" rel=\"noopener noreferrer\"><code>rules-oracle-conformance.test.ts</code></a>)</div></div></details>\n</div>\n" }];
|
|
2
2
|
//# sourceMappingURL=conformance-docs.js.map
|