@pyric/cli 0.1.0-alpha.10 → 0.1.0-alpha.12
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.orig +2 -2
- package/dist/assurance/.generated/conformance-verdicts.d.ts +16 -0
- package/dist/assurance/.generated/conformance-verdicts.d.ts.map +1 -1
- package/dist/assurance/.generated/conformance-verdicts.js +16 -0
- package/dist/assurance/.generated/conformance-verdicts.js.map +1 -1
- package/dist/bridge/server/peer.d.ts +1 -1
- package/dist/bridge/server.d.ts +1 -1
- package/dist/bridge/server.js +1 -1
- package/dist/cli/index.d.ts +11 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +13 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init.d.ts +3 -3
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +5 -5
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/serve.d.ts.map +1 -1
- package/dist/cli/serve.js +8 -2
- package/dist/cli/serve.js.map +1 -1
- package/dist/conformance/.generated/can-i-use-browser.d.ts.map +1 -1
- package/dist/conformance/.generated/can-i-use-browser.js +1 -1
- package/dist/conformance/.generated/can-i-use-browser.js.map +1 -1
- package/dist/conformance/.generated/can-i-use.d.ts.map +1 -1
- package/dist/conformance/.generated/can-i-use.js +2 -2
- package/dist/conformance/.generated/can-i-use.js.map +1 -1
- package/dist/conformance/.generated/conformance-docs.d.ts +12 -0
- package/dist/conformance/.generated/conformance-docs.d.ts.map +1 -0
- package/dist/conformance/.generated/conformance-docs.js +2 -0
- package/dist/conformance/.generated/conformance-docs.js.map +1 -0
- package/dist/conformance/docs.d.ts +6 -0
- package/dist/conformance/docs.d.ts.map +1 -0
- package/dist/conformance/docs.js +6 -0
- package/dist/conformance/docs.js.map +1 -0
- package/dist/remote/index.d.ts.map +1 -1
- package/dist/remote/index.js +1 -0
- package/dist/remote/index.js.map +1 -1
- package/dist/serve/activity-guard.d.ts +4 -0
- package/dist/serve/activity-guard.d.ts.map +1 -0
- package/dist/serve/activity-guard.js +22 -0
- package/dist/serve/activity-guard.js.map +1 -0
- package/dist/serve/activity-route.d.ts +4 -0
- package/dist/serve/activity-route.d.ts.map +1 -0
- package/dist/serve/activity-route.js +157 -0
- package/dist/serve/activity-route.js.map +1 -0
- package/dist/serve/activity-warning.d.ts +4 -0
- package/dist/serve/activity-warning.d.ts.map +1 -0
- package/dist/serve/activity-warning.js +21 -0
- package/dist/serve/activity-warning.js.map +1 -0
- package/dist/serve/bridge-mount.d.ts +5 -0
- package/dist/serve/bridge-mount.d.ts.map +1 -1
- package/dist/serve/bridge-mount.js +1 -0
- package/dist/serve/bridge-mount.js.map +1 -1
- package/dist/serve/docs-ui/404.html +3 -0
- package/dist/serve/docs-ui/_astro/{_slug_.DPUwShm2.css → _slug_.CXh9yTP1.css} +1 -1
- package/dist/serve/docs-ui/_astro/_slug_.DUci-duz.css +1 -0
- package/dist/serve/docs-ui/docs/_rhythm/index.html +2 -1
- package/dist/serve/docs-ui/docs/agent/set-up-your-agent/index.html +3 -2
- package/dist/serve/docs-ui/docs/agent/set-up-your-agent.md +1 -1
- package/dist/serve/docs-ui/docs/agent/watch-and-review/index.html +2 -1
- package/dist/serve/docs-ui/docs/agent/work-with-an-agent/index.html +2 -1
- package/dist/serve/docs-ui/docs/ai-compat/index.html +2 -1
- package/dist/serve/docs-ui/docs/api-reference/index.html +51 -49
- package/dist/serve/docs-ui/docs/api-reference.md +3 -3
- package/dist/serve/docs-ui/docs/app-compat/index.html +2 -1
- package/dist/serve/docs-ui/docs/auth-compat/index.html +2 -1
- package/dist/serve/docs-ui/docs/build/ai-logic/index.html +18 -3
- package/dist/serve/docs-ui/docs/build/ai-logic.md +30 -2
- package/dist/serve/docs-ui/docs/build/authentication/index.html +2 -1
- package/dist/serve/docs-ui/docs/build/authentication.md +1 -1
- package/dist/serve/docs-ui/docs/build/cloud-firestore/index.html +2 -1
- package/dist/serve/docs-ui/docs/build/cloud-messaging/index.html +46 -2
- package/dist/serve/docs-ui/docs/build/cloud-messaging.md +56 -1
- package/dist/serve/docs-ui/docs/build/cloud-storage/index.html +2 -1
- package/dist/serve/docs-ui/docs/build/cloud-storage.md +1 -1
- package/dist/serve/docs-ui/docs/build/realtime-database/index.html +21 -3
- package/dist/serve/docs-ui/docs/build/realtime-database.md +25 -1
- package/dist/serve/docs-ui/docs/conformance-scores/index.html +2 -1
- package/dist/serve/docs-ui/docs/create-pyric-reference-api/index.html +61 -28
- package/dist/serve/docs-ui/docs/create-pyric-reference-api.md +65 -20
- package/dist/serve/docs-ui/docs/database-compat/index.html +2 -1
- package/dist/serve/docs-ui/docs/firestore-compat/index.html +6 -5
- package/dist/serve/docs-ui/docs/firestore-compat.md +4 -4
- package/dist/serve/docs-ui/docs/functions-rtdb-compat/index.html +2 -1
- package/dist/serve/docs-ui/docs/get-started/how-the-swap-works/index.html +2 -1
- package/dist/serve/docs-ui/docs/get-started/start-building/index.html +4 -3
- package/dist/serve/docs-ui/docs/get-started/start-building.md +2 -2
- package/dist/serve/docs-ui/docs/index.json +979 -579
- package/dist/serve/docs-ui/docs/messaging-compat/index.html +2 -1
- package/dist/serve/docs-ui/docs/observe/see-whats-happening/index.html +2 -1
- package/dist/serve/docs-ui/docs/observe/shape-your-data/index.html +2 -1
- package/dist/serve/docs-ui/docs/overview/index.html +2 -1
- package/dist/serve/docs-ui/docs/pyric-admin-app-reference-api/index.html +5 -4
- package/dist/serve/docs-ui/docs/pyric-admin-app-reference-api.md +3 -4
- package/dist/serve/docs-ui/docs/pyric-admin-auth-reference-api/index.html +5 -4
- package/dist/serve/docs-ui/docs/pyric-admin-auth-reference-api.md +3 -4
- package/dist/serve/docs-ui/docs/pyric-admin-database-reference-api/index.html +5 -4
- package/dist/serve/docs-ui/docs/pyric-admin-database-reference-api.md +3 -4
- package/dist/serve/docs-ui/docs/pyric-admin-firestore-reference-api/index.html +2503 -12
- package/dist/serve/docs-ui/docs/pyric-admin-firestore-reference-api.md +2219 -8
- package/dist/serve/docs-ui/docs/pyric-admin-messaging-reference-api/index.html +916 -0
- package/dist/serve/docs-ui/docs/pyric-admin-messaging-reference-api.md +706 -0
- package/dist/serve/docs-ui/docs/pyric-admin-storage-reference-api/index.html +4 -3
- package/dist/serve/docs-ui/docs/pyric-admin-storage-reference-api.md +2 -3
- package/dist/serve/docs-ui/docs/pyric-ai-reference-api/index.html +16 -11
- package/dist/serve/docs-ui/docs/pyric-ai-reference-api.md +14 -13
- package/dist/serve/docs-ui/docs/pyric-ai-scripting-reference-api/index.html +4 -3
- package/dist/serve/docs-ui/docs/pyric-ai-scripting-reference-api.md +2 -3
- package/dist/serve/docs-ui/docs/pyric-app-reference-api/index.html +7 -2
- package/dist/serve/docs-ui/docs/pyric-app-reference-api.md +5 -2
- package/dist/serve/docs-ui/docs/pyric-auth-reference-api/index.html +23 -12
- package/dist/serve/docs-ui/docs/pyric-auth-reference-api.md +15 -12
- package/dist/serve/docs-ui/docs/pyric-cli-assurance-browser-reference-api/index.html +10 -48
- package/dist/serve/docs-ui/docs/pyric-cli-assurance-browser-reference-api.md +11 -24
- package/dist/serve/docs-ui/docs/pyric-cli-assurance-reference-api/index.html +87 -97
- package/dist/serve/docs-ui/docs/pyric-cli-assurance-reference-api.md +53 -65
- package/dist/serve/docs-ui/docs/pyric-cli-bridge-client-reference-api/index.html +14 -15
- package/dist/serve/docs-ui/docs/pyric-cli-bridge-client-reference-api.md +19 -28
- package/dist/serve/docs-ui/docs/pyric-cli-bridge-reference-api/index.html +4 -3
- package/dist/serve/docs-ui/docs/pyric-cli-bridge-reference-api.md +2 -3
- package/dist/serve/docs-ui/docs/pyric-cli-conformance-browser-reference-api/index.html +3 -2
- package/dist/serve/docs-ui/docs/pyric-cli-conformance-browser-reference-api.md +1 -2
- package/dist/serve/docs-ui/docs/pyric-cli-conformance-docs-reference-api/index.html +51 -0
- package/dist/serve/docs-ui/docs/pyric-cli-conformance-docs-reference-api.md +36 -0
- package/dist/serve/docs-ui/docs/pyric-cli-conformance-reference-api/index.html +3 -2
- package/dist/serve/docs-ui/docs/pyric-cli-conformance-reference-api.md +1 -2
- package/dist/serve/docs-ui/docs/pyric-cli-credentials-node-reference-api/index.html +3 -2
- package/dist/serve/docs-ui/docs/pyric-cli-credentials-node-reference-api.md +1 -2
- package/dist/serve/docs-ui/docs/pyric-cli-discover-reference-api/index.html +3 -2
- package/dist/serve/docs-ui/docs/pyric-cli-discover-reference-api.md +1 -2
- package/dist/serve/docs-ui/docs/pyric-cli-register-reference-api/index.html +3 -2
- package/dist/serve/docs-ui/docs/pyric-cli-register-reference-api.md +1 -2
- package/dist/serve/docs-ui/docs/pyric-cli-remote-reference-api/index.html +1067 -83
- package/dist/serve/docs-ui/docs/pyric-cli-remote-reference-api.md +1291 -160
- package/dist/serve/docs-ui/docs/pyric-cli-serve-worker-reference-api/index.html +253 -157
- package/dist/serve/docs-ui/docs/pyric-cli-serve-worker-reference-api.md +179 -85
- package/dist/serve/docs-ui/docs/pyric-cli-verify-reference-api/index.html +72 -7
- package/dist/serve/docs-ui/docs/pyric-cli-verify-reference-api.md +22 -7
- package/dist/serve/docs-ui/docs/pyric-cli-vite-reference-api/index.html +33 -12
- package/dist/serve/docs-ui/docs/pyric-cli-vite-reference-api.md +15 -12
- package/dist/serve/docs-ui/docs/pyric-database-reference-api/index.html +18 -17
- package/dist/serve/docs-ui/docs/pyric-database-reference-api.md +19 -22
- package/dist/serve/docs-ui/docs/pyric-firestore-reference-api/index.html +1512 -545
- package/dist/serve/docs-ui/docs/pyric-firestore-reference-api.md +1311 -316
- package/dist/serve/docs-ui/docs/pyric-messaging-reference-api/index.html +5 -4
- package/dist/serve/docs-ui/docs/pyric-messaging-reference-api.md +3 -4
- package/dist/serve/docs-ui/docs/pyric-messaging-sw-reference-api/index.html +69 -369
- package/dist/serve/docs-ui/docs/pyric-messaging-sw-reference-api.md +103 -216
- package/dist/serve/docs-ui/docs/pyric-rules-reference-api/index.html +10 -9
- package/dist/serve/docs-ui/docs/pyric-rules-reference-api.md +9 -10
- package/dist/serve/docs-ui/docs/pyric-sandbox-database-reference-api/index.html +8 -7
- package/dist/serve/docs-ui/docs/pyric-sandbox-database-reference-api.md +6 -7
- package/dist/serve/docs-ui/docs/pyric-sandbox-firestore-reference-api/index.html +10 -9
- package/dist/serve/docs-ui/docs/pyric-sandbox-firestore-reference-api.md +9 -10
- package/dist/serve/docs-ui/docs/pyric-sandbox-reference-api/index.html +371 -884
- package/dist/serve/docs-ui/docs/pyric-sandbox-reference-api.md +369 -883
- package/dist/serve/docs-ui/docs/pyric-storage-reference-api/index.html +25 -17
- package/dist/serve/docs-ui/docs/pyric-storage-reference-api.md +21 -19
- package/dist/serve/docs-ui/docs/pyric-ui-agents-reference-api/index.html +3 -2
- package/dist/serve/docs-ui/docs/pyric-ui-agents-reference-api.md +1 -2
- package/dist/serve/docs-ui/docs/pyric-ui-auth-hooks-reference-api/index.html +63 -776
- package/dist/serve/docs-ui/docs/pyric-ui-auth-hooks-reference-api.md +69 -510
- package/dist/serve/docs-ui/docs/pyric-ui-auth-reference-api/index.html +22 -21
- package/dist/serve/docs-ui/docs/pyric-ui-auth-reference-api.md +29 -30
- package/dist/serve/docs-ui/docs/pyric-ui-events-hooks-reference-api/index.html +15 -168
- package/dist/serve/docs-ui/docs/pyric-ui-events-hooks-reference-api.md +17 -87
- package/dist/serve/docs-ui/docs/pyric-ui-events-reference-api/index.html +6 -5
- package/dist/serve/docs-ui/docs/pyric-ui-events-reference-api.md +5 -6
- package/dist/serve/docs-ui/docs/pyric-ui-firestore-hooks-reference-api/index.html +82 -970
- package/dist/serve/docs-ui/docs/pyric-ui-firestore-hooks-reference-api.md +104 -479
- package/dist/serve/docs-ui/docs/pyric-ui-firestore-reference-api/index.html +30 -29
- package/dist/serve/docs-ui/docs/pyric-ui-firestore-reference-api.md +54 -55
- package/dist/serve/docs-ui/docs/pyric-ui-primitives-reference-api/index.html +3 -2
- package/dist/serve/docs-ui/docs/pyric-ui-primitives-reference-api.md +1 -2
- package/dist/serve/docs-ui/docs/pyric-ui-rtdb-reference-api/index.html +5 -4
- package/dist/serve/docs-ui/docs/pyric-ui-rtdb-reference-api.md +3 -4
- package/dist/serve/docs-ui/docs/pyric-ui-rules-hooks-reference-api/index.html +9 -124
- package/dist/serve/docs-ui/docs/pyric-ui-rules-hooks-reference-api.md +7 -58
- package/dist/serve/docs-ui/docs/pyric-ui-rules-reference-api/index.html +24 -23
- package/dist/serve/docs-ui/docs/pyric-ui-rules-reference-api.md +31 -34
- package/dist/serve/docs-ui/docs/pyric-ui-storage-hooks-reference-api/index.html +104 -1385
- package/dist/serve/docs-ui/docs/pyric-ui-storage-hooks-reference-api.md +133 -817
- package/dist/serve/docs-ui/docs/pyric-ui-storage-reference-api/index.html +34 -33
- package/dist/serve/docs-ui/docs/pyric-ui-storage-reference-api.md +37 -38
- package/dist/serve/docs-ui/docs/pyric-ui-traffic-hooks-reference-api/index.html +116 -1169
- package/dist/serve/docs-ui/docs/pyric-ui-traffic-hooks-reference-api.md +142 -590
- package/dist/serve/docs-ui/docs/pyric-ui-traffic-reference-api/index.html +4 -3
- package/dist/serve/docs-ui/docs/pyric-ui-traffic-reference-api.md +3 -4
- package/dist/serve/docs-ui/docs/rules-compat/index.html +34 -1
- package/dist/serve/docs-ui/docs/rules-compat.md +32 -0
- package/dist/serve/docs-ui/docs/secure/audit-your-rules/index.html +19 -2
- package/dist/serve/docs-ui/docs/secure/audit-your-rules.md +32 -0
- package/dist/serve/docs-ui/docs/secure/firestore-rules-limits/index.html +2 -1
- package/dist/serve/docs-ui/docs/secure/read-a-denial/index.html +2 -1
- package/dist/serve/docs-ui/docs/secure/rtdb-rules-in-typescript/index.html +2 -1
- package/dist/serve/docs-ui/docs/secure/rtdb-rules-in-typescript.md +1 -1
- package/dist/serve/docs-ui/docs/secure/rules-standard-library/index.html +2 -1
- package/dist/serve/docs-ui/docs/secure/secure-it-with-rules/index.html +2 -1
- package/dist/serve/docs-ui/docs/secure/simulate-and-lint/index.html +2 -1
- package/dist/serve/docs-ui/docs/secure/write-a-rules-test-suite/index.html +2 -1
- package/dist/serve/docs-ui/docs/ship/ship-to-production/index.html +2 -1
- package/dist/serve/docs-ui/docs/ship/test-in-node/index.html +2 -1
- package/dist/serve/docs-ui/docs/storage-compat/index.html +2 -1
- package/dist/serve/docs-ui/docs/trust/how-we-know-it-matches-firebase/index.html +2 -1
- package/dist/serve/docs-ui/docs/trust/versioning-and-compatibility/index.html +2 -1
- package/dist/serve/docs-ui/llms.txt +2 -1
- package/dist/serve/entries/ai.d.ts.map +1 -1
- package/dist/serve/entries/ai.js +39 -1
- package/dist/serve/entries/ai.js.map +1 -1
- package/dist/serve/entries/messaging-sw.d.ts.map +1 -1
- package/dist/serve/entries/messaging-sw.js +5 -1
- package/dist/serve/entries/messaging-sw.js.map +1 -1
- package/dist/serve/entries/messaging.d.ts.map +1 -1
- package/dist/serve/entries/messaging.js +7 -1
- package/dist/serve/entries/messaging.js.map +1 -1
- package/dist/serve/entries/runtime.d.ts.map +1 -1
- package/dist/serve/entries/runtime.js +20 -1
- package/dist/serve/entries/runtime.js.map +1 -1
- package/dist/serve/init-payload.d.ts +25 -0
- package/dist/serve/init-payload.d.ts.map +1 -1
- package/dist/serve/namespace.d.ts +39 -0
- package/dist/serve/namespace.d.ts.map +1 -1
- package/dist/serve/namespace.js +80 -1
- package/dist/serve/namespace.js.map +1 -1
- package/dist/serve/standalone-assets.d.ts +11 -9
- package/dist/serve/standalone-assets.d.ts.map +1 -1
- package/dist/serve/standalone-assets.js +6 -5
- package/dist/serve/standalone-assets.js.map +1 -1
- package/dist/serve/studio-ui/assets/{RulesCodeEditor-CXdSLKhO.js → RulesCodeEditor-CxVYcZXw.js} +1 -1
- package/dist/serve/studio-ui/assets/index-cpSfgQ7H.js +1218 -0
- package/dist/serve/studio-ui/assets/seed-DeuFKi4J.js +20 -0
- package/dist/serve/studio-ui/index.html +1 -1
- package/dist/serve/vite-plugin.d.ts +92 -15
- package/dist/serve/vite-plugin.d.ts.map +1 -1
- package/dist/serve/vite-plugin.js +281 -11
- package/dist/serve/vite-plugin.js.map +1 -1
- package/dist/serve/worker/activity-bootstrap.d.ts +8 -0
- package/dist/serve/worker/activity-bootstrap.d.ts.map +1 -0
- package/dist/serve/worker/activity-bootstrap.js +11 -0
- package/dist/serve/worker/activity-bootstrap.js.map +1 -0
- package/dist/serve/worker/client/connection.d.ts.map +1 -1
- package/dist/serve/worker/client/connection.js +17 -5
- package/dist/serve/worker/client/connection.js.map +1 -1
- package/dist/serve/worker/client/core.d.ts +2 -2
- package/dist/serve/worker/client/core.d.ts.map +1 -1
- package/dist/serve/worker/client/core.js +33 -5
- package/dist/serve/worker/client/core.js.map +1 -1
- package/dist/serve/worker/client/firestore-writes.d.ts.map +1 -1
- package/dist/serve/worker/client/firestore-writes.js +1 -0
- package/dist/serve/worker/client/firestore-writes.js.map +1 -1
- package/dist/serve/worker/client/messaging.d.ts +9 -1
- package/dist/serve/worker/client/messaging.d.ts.map +1 -1
- package/dist/serve/worker/client/messaging.js +15 -0
- package/dist/serve/worker/client/messaging.js.map +1 -1
- package/dist/serve/worker/client/studio.d.ts +10 -0
- package/dist/serve/worker/client/studio.d.ts.map +1 -1
- package/dist/serve/worker/client/studio.js +11 -0
- package/dist/serve/worker/client/studio.js.map +1 -1
- package/dist/serve/worker/durable-persistence.d.ts +12 -0
- package/dist/serve/worker/durable-persistence.d.ts.map +1 -0
- package/dist/serve/worker/durable-persistence.js +118 -0
- package/dist/serve/worker/durable-persistence.js.map +1 -0
- package/dist/serve/worker/entry.d.ts +7 -3
- package/dist/serve/worker/entry.d.ts.map +1 -1
- package/dist/serve/worker/entry.js +7 -3
- package/dist/serve/worker/entry.js.map +1 -1
- package/dist/serve/worker/host/core.d.ts +9 -5
- package/dist/serve/worker/host/core.d.ts.map +1 -1
- package/dist/serve/worker/host/core.js +34 -9
- package/dist/serve/worker/host/core.js.map +1 -1
- package/dist/serve/worker/host/dispatch.d.ts.map +1 -1
- package/dist/serve/worker/host/dispatch.js +9 -2
- package/dist/serve/worker/host/dispatch.js.map +1 -1
- package/dist/serve/worker/host/firestore-writes.d.ts.map +1 -1
- package/dist/serve/worker/host/firestore-writes.js +3 -2
- package/dist/serve/worker/host/firestore-writes.js.map +1 -1
- package/dist/serve/worker/host/studio.d.ts +1 -1
- package/dist/serve/worker/host/studio.d.ts.map +1 -1
- package/dist/serve/worker/host/studio.js +36 -1
- package/dist/serve/worker/host/studio.js.map +1 -1
- package/dist/serve/worker/host/subscriptions.d.ts.map +1 -1
- package/dist/serve/worker/host/subscriptions.js +27 -7
- package/dist/serve/worker/host/subscriptions.js.map +1 -1
- package/dist/serve/worker/host-context.d.ts +15 -0
- package/dist/serve/worker/host-context.d.ts.map +1 -1
- package/dist/serve/worker/host-context.js +10 -0
- package/dist/serve/worker/host-context.js.map +1 -1
- package/dist/serve/worker/host-messaging.d.ts.map +1 -1
- package/dist/serve/worker/host-messaging.js +9 -2
- package/dist/serve/worker/host-messaging.js.map +1 -1
- package/dist/serve/worker/index.d.ts +3 -2
- package/dist/serve/worker/index.d.ts.map +1 -1
- package/dist/serve/worker/index.js +4 -1
- package/dist/serve/worker/index.js.map +1 -1
- package/dist/serve/worker/protocol.d.ts +25 -3
- package/dist/serve/worker/protocol.d.ts.map +1 -1
- package/dist/serve/worker/protocol.js +2 -2
- package/dist/serve/worker/protocol.js.map +1 -1
- package/dist/serve/worker/serve-init.d.ts +23 -37
- package/dist/serve/worker/serve-init.d.ts.map +1 -1
- package/dist/serve/worker/serve-init.js +95 -172
- package/dist/serve/worker/serve-init.js.map +1 -1
- package/dist/vite.d.ts +4 -4
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +3 -3
- package/dist/vite.js.map +1 -1
- package/package.json +9 -4
- package/src/assurance/.generated/conformance-verdicts.ts +1090 -0
- package/src/assurance/attachment.ts +211 -0
- package/src/assurance/browser.ts +80 -0
- package/src/assurance/campaign.ts +496 -0
- package/src/assurance/capabilities.ts +446 -0
- package/src/assurance/cases.ts +43 -0
- package/src/assurance/index.ts +75 -0
- package/src/assurance/runner.ts +885 -0
- package/src/assurance/tool-names.ts +15 -0
- package/src/assurance/tools.ts +830 -0
- package/src/assurance/types.ts +352 -0
- package/src/assurance/validation.ts +434 -0
- package/src/bridge/client/bridge.ts +550 -0
- package/src/bridge/client/dispatch.ts +137 -0
- package/src/bridge/client.ts +45 -0
- package/src/bridge/protocol.ts +351 -0
- package/src/bridge/server/audit.ts +53 -0
- package/src/bridge/server/bridge.ts +597 -0
- package/src/bridge/server/headless.ts +160 -0
- package/src/bridge/server/json-schema-to-zod.ts +108 -0
- package/src/bridge/server/local-bridge.ts +66 -0
- package/src/bridge/server/logger.ts +50 -0
- package/src/bridge/server/mcp-contract.ts +93 -0
- package/src/bridge/server/mcp.ts +129 -0
- package/src/bridge/server/peer.ts +232 -0
- package/src/bridge/server/standalone.ts +347 -0
- package/src/bridge/server/tool-metadata.ts +99 -0
- package/src/bridge/server.ts +28 -0
- package/src/cli/can-i-use.ts +50 -0
- package/src/cli/cli.test.ts +493 -0
- package/src/cli/database-rules.ts +329 -0
- package/src/cli/dev-runner.ts +282 -0
- package/src/cli/firebase-json.ts +100 -0
- package/src/cli/firestore-indexes.ts +70 -0
- package/src/cli/index.ts +425 -0
- package/src/cli/init.ts +260 -0
- package/src/cli/mcp-proxy.ts +196 -0
- package/src/cli/parse-args.ts +80 -0
- package/src/cli/rules.ts +259 -0
- package/src/cli/scope.ts +67 -0
- package/src/cli/serve.ts +996 -0
- package/src/cli/service-commands.ts +73 -0
- package/src/cli/snapshot.ts +164 -0
- package/src/cli/storage-rules.ts +172 -0
- package/src/cli/verify.ts +416 -0
- package/src/conformance/.generated/can-i-use-browser.ts +113 -0
- package/src/conformance/.generated/can-i-use.ts +117 -0
- package/src/conformance/.generated/conformance-docs.ts +14 -0
- package/src/conformance/browser.ts +24 -0
- package/src/conformance/can-i-use-tool.ts +51 -0
- package/src/conformance/can-i-use.ts +18 -0
- package/src/conformance/docs.ts +8 -0
- package/src/conformance/index.ts +20 -0
- package/src/conformance/tools.ts +16 -0
- package/src/credentials/core/memoize-ttl.ts +142 -0
- package/src/credentials/core/types.ts +10 -0
- package/src/credentials/node/from-adc.ts +96 -0
- package/src/credentials/node/from-service-account.ts +133 -0
- package/src/credentials/node/index.ts +6 -0
- package/src/discover/concurrency.ts +127 -0
- package/src/discover/crawler-adapter.ts +142 -0
- package/src/discover/crawler.ts +1127 -0
- package/src/discover/credential-free.ts +27 -0
- package/src/discover/findCollectionGroup.ts +131 -0
- package/src/discover/firestore-source.ts +59 -0
- package/src/discover/index.ts +5 -0
- package/src/discover/merge.ts +523 -0
- package/src/discover/session.ts +402 -0
- package/src/discover/tools.ts +201 -0
- package/src/discover/types.ts +187 -0
- package/src/discover/wire.ts +324 -0
- package/src/functions-rtdb/child.ts +382 -0
- package/src/functions-rtdb/delivery.ts +7 -0
- package/src/functions-rtdb/discovery.ts +136 -0
- package/src/functions-rtdb/event.ts +64 -0
- package/src/functions-rtdb/execution.ts +111 -0
- package/src/functions-rtdb/in-memory-delivery.ts +40 -0
- package/src/functions-rtdb/project.ts +74 -0
- package/src/functions-rtdb/projection.ts +104 -0
- package/src/functions-rtdb/reference-pattern.ts +20 -0
- package/src/functions-rtdb/remote-delivery.ts +23 -0
- package/src/pkg-version.ts +49 -0
- package/src/register/esm-exports.ts +63 -0
- package/src/register/hooks.ts +30 -0
- package/src/register/index.ts +150 -0
- package/src/register/mapping.ts +33 -0
- package/src/remote/index.ts +1050 -0
- package/src/rtdb/crawl-snapshot.ts +118 -0
- package/src/rtdb/inspection.ts +137 -0
- package/src/rtdb/load-rules-document.ts +56 -0
- package/src/rtdb/rules-generation-tool.ts +38 -0
- package/src/rtdb/rules-json.ts +29 -0
- package/src/serve/activity-guard.ts +29 -0
- package/src/serve/activity-route.ts +164 -0
- package/src/serve/activity-warning.ts +26 -0
- package/src/serve/bridge-mount.ts +200 -0
- package/src/serve/bundler.ts +493 -0
- package/src/serve/capture-store.ts +65 -0
- package/src/serve/discovery.ts +199 -0
- package/src/serve/entries/ai.ts +292 -0
- package/src/serve/entries/app-backend.ts +4 -0
- package/src/serve/entries/app-client.ts +26 -0
- package/src/serve/entries/app-session-store.ts +47 -0
- package/src/serve/entries/app.ts +11 -0
- package/src/serve/entries/auth-helper-core.ts +181 -0
- package/src/serve/entries/auth-helper-dom.ts +134 -0
- package/src/serve/entries/auth-helper-runtime.ts +20 -0
- package/src/serve/entries/auth.ts +255 -0
- package/src/serve/entries/bridge-url.ts +31 -0
- package/src/serve/entries/database.ts +129 -0
- package/src/serve/entries/firestore.ts +232 -0
- package/src/serve/entries/init.ts +46 -0
- package/src/serve/entries/keepalive.ts +46 -0
- package/src/serve/entries/messaging-sw.ts +75 -0
- package/src/serve/entries/messaging.ts +96 -0
- package/src/serve/entries/runtime.ts +540 -0
- package/src/serve/entries/session-store.ts +97 -0
- package/src/serve/entries/storage.ts +92 -0
- package/src/serve/entries/tab-sync-wiring.ts +274 -0
- package/src/serve/entries/worker-runtime.ts +75 -0
- package/src/serve/init-payload.ts +42 -0
- package/src/serve/namespace.ts +675 -0
- package/src/serve/open-browser.ts +68 -0
- package/src/serve/rules.ts +272 -0
- package/src/serve/sandbox-marker.ts +31 -0
- package/src/serve/server.ts +428 -0
- package/src/serve/standalone-assets.ts +180 -0
- package/src/serve/state-store.ts +166 -0
- package/src/serve/studio/disk-project-store.ts +185 -0
- package/src/serve/studio/disk-workspace.ts +162 -0
- package/src/serve/studio/index.ts +20 -0
- package/src/serve/studio/routes.ts +237 -0
- package/src/serve/studio/store-types.ts +48 -0
- package/src/serve/studio/studio-storage.test.ts +289 -0
- package/src/serve/vite-plugin.ts +1102 -0
- package/src/serve/worker/activity-bootstrap.ts +24 -0
- package/src/serve/worker/client/admin-firestore.ts +38 -0
- package/src/serve/worker/client/ai.ts +124 -0
- package/src/serve/worker/client/auth.ts +489 -0
- package/src/serve/worker/client/connection.ts +202 -0
- package/src/serve/worker/client/core.ts +350 -0
- package/src/serve/worker/client/disconnect.ts +45 -0
- package/src/serve/worker/client/firestore-reads.ts +181 -0
- package/src/serve/worker/client/firestore-refs.ts +226 -0
- package/src/serve/worker/client/firestore-writes.ts +232 -0
- package/src/serve/worker/client/handles.ts +91 -0
- package/src/serve/worker/client/messaging.ts +119 -0
- package/src/serve/worker/client/presence.ts +174 -0
- package/src/serve/worker/client/rtdb.ts +311 -0
- package/src/serve/worker/client/rules.ts +63 -0
- package/src/serve/worker/client/service-worker-connection.ts +59 -0
- package/src/serve/worker/client/snapshots.ts +86 -0
- package/src/serve/worker/client/storage.ts +195 -0
- package/src/serve/worker/client/studio.ts +88 -0
- package/src/serve/worker/client.ts +57 -0
- package/src/serve/worker/durable-persistence.ts +137 -0
- package/src/serve/worker/entry.ts +179 -0
- package/src/serve/worker/host/admin-firestore.ts +88 -0
- package/src/serve/worker/host/connection.ts +179 -0
- package/src/serve/worker/host/core.ts +387 -0
- package/src/serve/worker/host/dispatch.ts +316 -0
- package/src/serve/worker/host/firestore-reads.ts +117 -0
- package/src/serve/worker/host/firestore-writes.ts +456 -0
- package/src/serve/worker/host/presence.ts +312 -0
- package/src/serve/worker/host/rtdb.ts +136 -0
- package/src/serve/worker/host/rules.ts +128 -0
- package/src/serve/worker/host/storage.ts +312 -0
- package/src/serve/worker/host/studio.ts +75 -0
- package/src/serve/worker/host/subscriptions.ts +238 -0
- package/src/serve/worker/host-ai.ts +165 -0
- package/src/serve/worker/host-auth.ts +469 -0
- package/src/serve/worker/host-context.ts +273 -0
- package/src/serve/worker/host-events.ts +94 -0
- package/src/serve/worker/host-messaging.ts +240 -0
- package/src/serve/worker/host.ts +47 -0
- package/src/serve/worker/index.ts +196 -0
- package/src/serve/worker/presence-timing.ts +15 -0
- package/src/serve/worker/protocol.ts +1112 -0
- package/src/serve/worker/serve-init.ts +606 -0
- package/src/serve/worker/service-worker-channel.ts +33 -0
- package/src/serve/worker/service-worker-relay.ts +86 -0
- package/src/serve/writer-lock.ts +53 -0
- package/src/verify/cases.ts +233 -0
- package/src/verify/fixture.ts +258 -0
- package/src/verify/index.ts +519 -0
- package/src/verify/tools.ts +108 -0
- package/src/version/compat-target.ts +17 -0
- package/src/vite.ts +17 -0
- package/dist/serve/docs-ui/docs/pyric-firestore-values-reference-api/index.html +0 -30
- package/dist/serve/docs-ui/docs/pyric-firestore-values-reference-api.md +0 -30
- package/dist/serve/studio-ui/assets/index-BvtQIpGn.js +0 -1211
- package/dist/serve/studio-ui/assets/seed-3DMedLL3.js +0 -2
|
@@ -0,0 +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: 52.7%\"></span></span><span class=\"compat-score-pct\">52.7%</span></div><div class=\"compat-score-basis\">96 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: 72.9%\"></span></span><span class=\"compat-score-pct\">72.9%</span></div><div class=\"compat-score-basis\">43 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: 62.9%\"></span></span><span class=\"compat-score-pct\">62.9%</span></div><div class=\"compat-score-basis\">22 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: 63.7%\"></span></span><span class=\"compat-score-pct\">63.7%</span></div><div class=\"compat-score-basis\">429 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` (firebase 12.13.0) + replay: `oracle-conformance.test.ts` | 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` (firebase 12.13.0) + replay: `oracle-conformance.test.ts` | 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` (firebase 12.13.0) + replay: `oracle-conformance.test.ts` | 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` (firebase 12.13.0) + replay: `oracle-conformance.test.ts` | 4 |\n| getApp() | | `getApp()` with no name resolves the default app instance; its name is `'[DEFAULT]'` | ✓ | oracle: `app-registry-getapp-default` (firebase 12.13.0) + replay: `oracle-conformance.test.ts` | 5 |\n| getApp('secondary') | | `getApp('secondary')` resolves the named app instance; its name is `'secondary'` | ✓ | oracle: `app-registry-getapp-named` (firebase 12.13.0) + replay: `oracle-conformance.test.ts` | 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` (firebase 12.13.0) + replay: `oracle-conformance.test.ts` | 7 |\n| getApps() | | `getApps()` returns an array containing every registered app by identity (the exact instances, not copies) | ✓ | oracle: `app-registry-getapps-contents` (firebase 12.13.0) + replay: `oracle-conformance.test.ts` | 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` (firebase 12.13.0) + replay: `oracle-conformance.test.ts` | 9 |\n| deleteApp(app) | | `deleteApp` on an already-deleted app throws `FirebaseError` code `app/app-deleted` | ✓ | oracle: `app-registry-deleteapp-double` (firebase 12.13.0) + replay: `oracle-conformance.test.ts` | 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` (firebase 12.13.0) + replay: `oracle-conformance.test.ts` | 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` (firebase 12.13.0) + replay: `oracle-conformance.test.ts` | 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` (firebase 12.13.0) + replay: `oracle-conformance.test.ts` | 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` (firebase 12.13.0) + replay: `oracle-conformance.test.ts` | 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` (firebase 12.13.0) + replay: `oracle-conformance.test.ts` | 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` (firebase 12.13.0) + replay: `oracle-conformance.test.ts` | 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` (firebase 12.13.0) + replay: `oracle-conformance.test.ts` | 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` (firebase 12.13.0) + replay pins `app/multiple-configs-not-supported`; `packages/cli/test/e2e/app-multi-app.pw.ts` proves the same lock is enforced by the authoritative SharedWorker across same-origin tabs, not only by one page registry | 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` (firebase 12.13.0) + replay pins `app/multiple-configs-not-supported` | 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` (firebase 12.13.0) + replay: `oracle-conformance.test.ts` | 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` (firebase 12.13.0) + replay: `oracle-conformance.test.ts` | 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` (firebase 12.13.0, real Chromium against production) + twin replay: `production-multi-app-oracle.test.ts`; served SharedWorker replay: `app-multi-app.pw.ts` | 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` (firebase 12.13.0) + replay: `oracle-conformance.test.ts` | 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` and real-Chromium `app-production-multi-app-topology` (firebase 12.13.0) + replays: `oracle-conformance.test.ts`, `deleted-service-lifecycle.test.ts`, and `production-multi-app-oracle.test.ts` | 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` (firebase 12.13.0, real Chromium against production) + twin replay: `production-multi-app-oracle.test.ts`; focused family tests: `multi-app-listener-auth.test.ts` and served `app-multi-app.pw.ts` | 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: <code>app-registry-initializeapp-named-different-config</code> (firebase 12.13.0) + replay pins <code>app/multiple-configs-not-supported</code>; <code>packages/cli/test/e2e/app-multi-app.pw.ts</code> proves the same lock is enforced by the authoritative SharedWorker across same-origin tabs, not only by one page registry</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: <code>app-registry-delete-reinitialize-different-config</code> (firebase 12.13.0) + replay pins <code>app/multiple-configs-not-supported</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\">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` 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` 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` 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` 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` 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` 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` 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` 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` 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` 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` | 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` | 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` | 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` | 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` | 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` | 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` | 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` 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` | 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` | 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` | generate-thinking-signature |\n| generateContent(request, singleRequestOptions) | | A pre-aborted `SingleRequestOptions.signal` rejects the call | ✓ | `unit:generate-content.test.ts` 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` | 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` | 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` | 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` | 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` | 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` | 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` | 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` 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` 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` (I1 validateChatHistory accept/reject via startChat) + `unit:chat-session.test.ts` 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` 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` 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` | chat-sendmessage-envelope |\n| ChatSession.sendMessageStream() | | `sendMessageStream` returns a stream plus a response promise; history updates after aggregation completes | ✓ | `unit:chat-session.test.ts` 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` 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` 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` | 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` | 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` | 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` | 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` | 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` test `ai#fncall-signature-minted` (capture ai-error-fncall-missing-thought-signature cited as the motivating rejection) | 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` | 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` | 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` | 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` | 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` | 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` | 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` | 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` test `ai#error-aierror-shape` (capture ai-error-bad-api-key cited as the sample envelope) | error-aierror-shape |\n| AIErrorCode | | `AIErrorCode` exposes the 14 documented codes, from `error` through `unsupported` | ✓ | `unit:errors-counttokens.test.ts` 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` (I3 text() across mixed parts) + `unit:helpers-schema.test.ts` 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` 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` (I3 text+functionCall mix) + Capture ai-function-call-shape replayed by `unit:helpers-schema.test.ts` test `ai#helper-functioncalls` | 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` (I3 thoughtSummary from thought parts) + Capture ai-thinking-thought-parts replayed by `unit:helpers-schema.test.ts` test `ai#helper-thoughtsummary` | helper-thoughtsummary |\n| response.inlineDataParts() | | `inlineDataParts()` returns the `InlineDataPart` array when inlineData parts exist and undefined when none do | ✓ | `unit:helpers-schema.test.ts` 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` 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` (I2 empty optionalProperties + propertyOrdering) + `unit:helpers-schema.test.ts` 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` 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` 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` (I2 empty anyOf → invalid-schema) + `unit:helpers-schema.test.ts` 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` | 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` test `ai#scripted-zero-config` (capture ai-generate-minimal-envelope cited as the shape source) | scripted-zero-config |\n| scripted engine | | The same unscripted request twice yields an identical envelope, candidates and usage included | ✓ | `unit:engines.test.ts` 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` 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` 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` 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` test `ai#scripted-shorthand-text` (capture ai-generate-minimal-envelope cited as the expansion target) | 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` test `ai#scripted-shorthand-functioncall` (capture ai-error-fncall-missing-thought-signature cited as the motivating rejection) | 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` test `ai#scripted-stream-chunks` (capture ai-generate-stream-framing cited as the framing source) | 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` 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` 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` 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` 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` 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` 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\"><code>unit:instances.test.ts</code> 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\"><code>unit:streaming.test.ts</code> 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\"><code>unit:chat-session.test.ts</code> 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\"><code>unit:chat-session.test.ts</code> 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\"><code>unit:chat-session.test.ts</code> 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\"><code>unit:helpers-schema.test.ts</code> 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` + canonical Node register child (`register-child.test.ts`) | 1 |\n| getAuth(target) | | `getAuth(sandbox)` dispatches to the sandbox backend | ✓ | `unit:sandbox-anonymous.test.ts` | 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`) + oracle: `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` | 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` (firebase 12.13.0, real Chromium against production) + twin replay: `packages/pyric/test/app/production-multi-app-oracle.test.ts` | 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` (firebase 12.13.0, two real Chromium pages in one browser context) + served twin: `packages/cli/test/e2e/app-multi-app.pw.ts` | 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`, `playground:auth-anonymous` (bundled) + `playground:auth-row-6-anon-credential-shape` (one-claim) + oracle: `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` — 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` (\"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`, `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`, `playground:auth-anonymous` (bundled) + `playground:auth-row-10-onauthstatechanged-one-per-transition` (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`, `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` + oracle: `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` + oracle: `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`, `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` (`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` (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` + oracle: `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` + oracle: `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` + oracle: `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` (\"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` | 20 |\n| createUserWithEmailAndPassword(auth, email, password) | | Returns `UserCredential` with `operationType: 'signIn'` (NOT `'register'` — matches prod) | ✓ | `unit:sandbox-email-password.test.ts` + oracle: `packages/conformance/observations/auth/auth-createUser-operationType.json` (`operationType: 'signIn'` against blockingfun, fb-js-sdk 12.13.0; matches sandbox) | 21 |\n| createUserWithEmailAndPassword(auth, email, password) | | Throws `auth/email-already-in-use` when the email is already registered | ✓ | `unit:sandbox-email-password.test.ts` + oracle: `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` + `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` + oracle: `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` + oracle: `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`, `playground:auth-anonymous` (bundled) + `playground:auth-row-26-signout-fires-null-once` (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` + 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` + oracle: `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`, `playground:auth-anonymous` + oracle: `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` (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` (regression test from #399), `playground:auth-anonymous` + oracle baseline: `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`, `playground:auth-listener-unsub` (bundled) + `playground:auth-row-32-unsubscribe-stops-fires` (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` + oracle: `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` (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` | 34 |\n| onAuthStateChanged(auth, observer) | | A throwing observer doesn't block other observers from firing | ✓ | `unit:sandbox-listeners.test.ts` + oracle: `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` + oracle: `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` + oracle: `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` + oracle: `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` (locks AUTH-B8) | 38a |\n| onIdTokenChanged(auth, observer) | | Fires on token refresh (`getIdToken(true)`) | ✓ | `unit:sandbox-token-refresh.test.ts` — 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` 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` + oracle: `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` | 41 |\n| setPersistence(auth, persistence) | | Returns `Promise<void>` | ✓ | `unit:types.test.ts` | 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` (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` | 44 |\n| signInWithPopup(auth, provider)` / `signInWithCredential(auth, credential) | | Throws `auth/no-mock-configured` when no mock is pre-staged | ✓ | `unit:sandbox-providers.test.ts` | 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` | 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` | 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` (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` (\"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` | 49a |\n| signInWithRedirect` / `getRedirectResult` / resolver seam | | `getRedirectResult(auth)` returns the stashed credential once, then `null` (one-shot, matches prod) | ✓ | `unit:sandbox-resolver.test.ts` | 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` | 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` | 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` | 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` | 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` | 50 |\n| | | `Provider.credential(...)` static factories produce `AuthCredential`-shaped objects | ✓ | `unit:sandbox-providers.test.ts` | 51 |\n| | | `GoogleAuthProvider.providerId === 'google.com'` (and per-provider analogs) | ✓ | `unit:sandbox-providers.test.ts` | 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` | 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` — 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` 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>-<hash>` strings; prod's are real JWTs. | 55 |\n| User` methods | | `user.getIdTokenResult()` returns claims | ✓ | `unit:sandbox-providers.test.ts` (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` (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` | 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` | 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` (\"IdTokenResult.signInProvider\") — prod shape is documented SDK behavior; no oracle capture yet | 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`, `unit:sandbox-cluster-b9-b12.test.ts` | 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` (in-place mutation, listUsers persist, null clear, no extra listener); re-sign-in persistence: `unit:upstream-auth-probes.test.ts` | 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` — 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`. 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` (\"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` (\"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` (\"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` — 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` — 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` | 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` — 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` — 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` — 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` | 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` — `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` — 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` against firebase-js-sdk 12.13.0. Replayed in `unit:oracle-conformance.test.ts`. | 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` captured all five cases against prod. Replayed in `unit:oracle-conformance.test.ts`. | 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` 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`. | 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` captured `missingUrl: auth/invalid-continue-uri` and `handleCodeInAppFalse: auth/argument-error`. Replayed in `unit:oracle-conformance.test.ts`; the send path is unit-backed in `unit:sandbox-email-link.test.ts`. | 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`: `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` drives send -> read the outbox -> sign in). | 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` 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`. | 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` 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` 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. | 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`) 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`. | 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` — 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`. | 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. | 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`) 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`, 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`) 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`. 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` captured `noSuchProviderCode: auth/no-such-provider` against prod on an anonymous user. Replayed in `unit:oracle-conformance.test.ts`. The detach path is unit-backed (`unit:sandbox-linking-reauth.test.ts`). | 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` — 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`) 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`. | 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` 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` — 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` captured `{isNewUser: true, providerId: null, profile: {}}` against prod. Replayed in `unit:oracle-conformance.test.ts`. Email create/sign-in + custom-token mint/return isNewUser matrix (providerId null per AUTH-B2): `unit:upstream-auth-probes.test.ts`. Email-link + link/reauth arms: `unit:sandbox-email-link.test.ts`, `unit:sandbox-linking-reauth.test.ts`. | 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` against firebase-js-sdk 12.13.0, replayed value-for-value in `unit:oracle-conformance.test.ts`. 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` captured `auth/invalid-custom-token` for both a malformed token and the empty string, replayed in `unit:oracle-conformance.test.ts`. 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` 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`. 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` 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` + `unit:sandbox-email-link.test.ts` 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` — 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` — 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` — 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` 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\"><code>unit:sandbox-anonymous.test.ts</code> — 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\"><code>unit:sandbox-cluster-b9-b12.test.ts</code> (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\"><code>unit:sandbox-before-auth-state-changed.test.ts</code> (\"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\"><code>unit:fruit-aliases.test.ts</code> — 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\"><code>unit:fruit-aliases.test.ts</code> — 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\"><code>unit:fruit-aliases.test.ts</code> — 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.</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. <code>unit:sandbox-linking-reauth.test.ts</code> 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: <code>auth-signinwithcustomtoken-invalid</code> captured <code>auth/invalid-custom-token</code> for both a malformed token and the empty string, replayed in <code>unit:oracle-conformance.test.ts</code>. 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. <code>unit:types.test.ts</code> + <code>unit:sandbox-email-link.test.ts</code> 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\"><code>unit:types.test.ts</code> — 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 <code>auth-fetchsigninmethodsforemail-deprecated</code> 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\">52.7%</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: 52.7%\"></span></span>\n</div>\n<p class=\"compat-stat-denom\">96 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` | 1 |\n| getFirestore(target) | | `getFirestore(sandbox)` returns a tagged sandbox-live handle (per-op identity) | ✓ | `unit:sandbox-live-identity.test.ts` | 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`, `node-register:register-child.test.ts` (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` (`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` (\"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` (\"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` | 7 |\n| Path constructors | | `doc(db, 'a', 'b', 'c', 'd')` joins variadic path segments | ✓ | `unit:sandbox-target.test.ts` | 8 |\n| Path constructors | | `collection(db, path)` returns a tagged `CollectionReference` | ✓ | `unit:sandbox-target.test.ts` | 9 |\n| Path constructors | | `doc(coll, id)` appends under a collection ref | ✓ | `unit:sandbox-target.test.ts` | 10 |\n| Path constructors | | `doc(coll)` (no id) mints an auto-id `DocumentReference` | ✓ | `unit:sandbox-target.test.ts` | 11 |\n| Path constructors | | `collection(docRef, name)` builds a subcollection ref | ✓ | `unit:sandbox-target.test.ts` | 12 |\n| Path constructors | | `collectionGroup(db, id)` returns a query spanning every collection with that id | ✓ | `unit:sandbox-target.test.ts` (\"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` | 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` (\"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` | 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` | 18 |\n| getDoc(ref) | | `snap.ref` is tagged so it routes through `targetOf` in follow-up ops | ✓ | `unit:sandbox-target.test.ts` | 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` (\"doc read denied when current user lacks read access\"), oracle: `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`) | 20 |\n| getDoc(ref) | | Rules denial throws `SandboxError('permission-denied', …)` on sandbox; `FirebaseError('permission-denied')` on prod | ⚠ | divergence: same code, different class — both expose `.code === 'permission-denied'`. Oracle-locked: `packages/conformance/observations/firestore/firestore-rules-denied-error.json` — prod throws a `FirebaseError` (name + constructor name both `FirebaseError`), `.code === 'permission-denied'`, `.message === '7 PERMISSION_DENIED: Missing or insufficient permissions.'`, and the value is an `instanceof Error`. | 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`, `playground:firestore-query` | 22 |\n| getDocs(query) | | Each `snap.docs[i].ref` is tagged for follow-up ops | ✓ | `unit:sandbox-target.test.ts` | 23 |\n| getDocs(query) | | Sandbox-live: re-evaluates filters under the current user (different docs visible per identity) | ✓ | `unit:sandbox-live-identity.test.ts` (\"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` (deny-all + auth-gated `getDocs`/aggregate), `unit:admin-compat/per-op-auth.test.ts` (\"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` (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` (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` (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` (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` | 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` (\"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`, `unit:admin-compat/field-path-merge.test.ts` (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`, `unit:admin-compat/field-path-merge.test.ts` (dotted mergeField); mask edges (delete/transform outside mask, empty mask, deleteField in mask): `unit:upstream-write-aggregate-probes.test.ts` | 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` | 29 |\n| setDoc(ref, data[, options]) | | Sentinels (`serverTimestamp`, `increment`, `arrayUnion`, `arrayRemove`, `deleteField`) resolve in the same call | ✓ | `unit:sandbox-target.test.ts`, `playground:firestore-sentinels`, oracle: `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` (\"withConverter on a DocumentReference round-trips\") | 31 |\n| setDoc(ref, data[, options]) | | Rules-denied write throws `permission-denied` (sandbox) / `FirebaseError` (prod) | ✓ | `unit:sandbox-target.test.ts` (\"getDoc denies when rules reject\"), `playground:rules-data-validation`, oracle: `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`) | 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`, `unit:admin-compat/field-path-merge.test.ts` (FS-B5 dot-path nested write + delete; verified failing pre-fix) | 33 |\n| updateDoc(ref, data) | | Throws `not-found` (sandbox) / `FirebaseError('not-found')` (prod) on missing doc | ✓ | `unit:sandbox-target.test.ts` (implicit in writes-fail-on-missing tests), oracle: `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`, `playground:firestore-sentinels`, oracle: `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` (\"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` | 38 |\n| deleteDoc(ref) | | Idempotent — `deleteDoc` on missing doc resolves without throwing (matches JS SDK) | ✓ | `unit:deletedoc-missing.test.ts`, `playground:firestore-deletedoc-missing`, oracle: `packages/conformance/observations/firestore/firestore-deletedoc-missing.json` | 39 |\n| deleteDoc(ref) | | Rules-denied delete throws `permission-denied` | ✓ | `unit:sandbox-target.test.ts` (rules-reject branch), oracle: `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` | 41 |\n| addDoc(coll, data) | | Returned ref is usable in subsequent ops (`getDoc`, `setDoc`, `onSnapshot`) | ✓ | `unit:sandbox-target.test.ts`, oracle: `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` (\"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` (\"addDoc through a converted collection\") | 44 |\n| addDoc(coll, data) | | Auto-id format — prod uses 20-char base64-ish IDs; sandbox uses `pyric-admin`'s auto-id (also opaque, distinct format) | ⚠ format | divergence: IDs are opaque on both sides; format differs but consumer code never parses them. Oracle-locked: `packages/conformance/observations/firestore/firestore-adddoc-autoid-format.json` — prod auto-ids are 20 characters, all alphanumeric (mixed upper, lower, digits; no other chars). Example: `S3PJENMPOk4qcDXol8Ez`. | 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` | 46 |\n| withConverter | | `withConverter(collRef, converter)` propagates onto `doc(typedColl, id)` | ✓ | `unit:sandbox-target.test.ts` | 47 |\n| withConverter | | `withConverter(collRef, converter)` propagates through `query(typedColl, …)` + `getDocs()` | ✓ | `unit:sandbox-target.test.ts` | 48 |\n| withConverter | | `withConverter(ref, null)` strips the converter, returns the underlying untyped view | ✓ | `unit:sandbox-target.test.ts` | 49 |\n| withConverter | | Original untyped ref keeps its identity after `withConverter(ref, c)` (two views, one path) | ✓ | `unit:sandbox-target.test.ts` | 50 |\n| withConverter | | `setDoc` through a converted ref invokes `toFirestore(data)` | ✓ | `unit:sandbox-target.test.ts` | 51 |\n| withConverter | | `getDoc` through a converted ref invokes `fromFirestore(snapshot)`; `.data()` returns the typed model | ✓ | `unit:sandbox-target.test.ts` | 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`, `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` (canonical query test); membership ops + OR/`in`/`array-contains` composites: `unit:upstream-query-probes.test.ts` | 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` (verified failing pre-fix) | 55a |\n| Query construction | | `or(...)` composite — at least one sub-filter matches | ✓ | `unit:sandbox-target.test.ts` (\"or() matches docs where any sub-filter matches\"), oracle: `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` (\"and() requires every sub-filter\"), oracle: `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` (\"nested or/and — the canonical composite pattern\"), oracle: `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` | 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:admin-compat/canonical-type-order.test.ts` (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:admin-compat/canonical-type-order.test.ts` (\"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` (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` | 60 |\n| Query construction | | `limitToLast(n)` — trailing n in ordered result (requires `orderBy`). Sandbox: the no-orderBy precondition throws a `FirestoreError` with `.code === 'invalid-argument'` (FS-B16; pre-fix plain `Error`s). Prod: the same precondition throws `.code === 'unimplemented'` | ⚠ | divergence, oracle-locked by `packages/conformance/observations/firestore/firestore-limittolast-preconditions.json`: prod's no-orderBy `limitToLast` throws code `unimplemented`, the sandbox throws `invalid-argument`. Trailing-window semantics with `orderBy` conform (observed `[\"b\"]` matches). Both sides pinned in `oracle-conformance.test.ts`. Cursor composition + descending: `unit:upstream-query-probes.test.ts`. Cursor/empty-snapshot precondition codes remain per `unit:sandbox-target.test.ts` + `unit:admin-compat/cursors.test.ts` (verified failing pre-fix) | 61 |\n| Query construction | | Composite filters AND with other constraints — `query(coll, or(...), orderBy(...), limit(...))` | ✓ | `unit:sandbox-target.test.ts` | 62 |\n| Query construction | | Passing `orderBy` / `limit` into `or()` / `and()` → `TypeError` | ✓ | `unit:sandbox-target.test.ts` | 63 |\n| Query construction | | Zero-arg `or()` / `and()` → `TypeError` | ✓ | `unit:sandbox-target.test.ts` | 64 |\n| Query construction | | Chained queries re-tag for further constraints (`query(query(coll, where), orderBy)`) | ✓ | `unit:sandbox-target.test.ts` (\"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`, oracle: `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`, oracle: `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`, oracle: `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`, oracle: `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`, `unit:admin-compat/implicit-order-name.test.ts` (snapshot cursor w/o orderBy), `unit:admin-compat/cursors.test.ts` (value-cursor too-many-args throws with `.code`) | 71 |\n| Cursor pagination | | `endAt(snapshot)` overload | ✓ | `unit:sandbox-target.test.ts` (\"endAt(snapshot) trims to-and-including the anchor\") | 72 |\n| Cursor pagination | | `startAfter + limit` — canonical pagination pattern | ✓ | `unit:sandbox-target.test.ts` | 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`; collectionGroup: `unit:upstream-write-aggregate-probes.test.ts` | 74 |\n| Aggregates | | `getCountFromServer` honors `where` filters | ✓ | `unit:sandbox-target.test.ts` | 75 |\n| Aggregates | | `getAggregateFromServer(query, spec)` returns `{ data: () => Record<alias, number\\|null> }` | ✓ | `unit:sandbox-target.test.ts`; collectionGroup + nested paths: `unit:upstream-write-aggregate-probes.test.ts` | 76 |\n| Aggregates | | `count()` / `sum(field)` / `average(field)` compose under one spec — `field` may be a dotted nested path | ✓ | `unit:sandbox-target.test.ts`; nested `sum('metadata.pages')`: `unit:upstream-write-aggregate-probes.test.ts` | 77 |\n| Aggregates | | `average` returns `null` on empty input (matches JS SDK) | ✓ | `unit:sandbox-target.test.ts` | 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` — `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` (`firstFireSyncDuringRegister: false`, fire count + contents) in `oracle-conformance.test.ts`; also `unit:sandbox-target.test.ts`, `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`, oracle: `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` | 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` (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` (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` (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`, oracle: `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` — 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` (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` 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` | 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` (\"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` (\"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` (\"snapshot ref is usable in subsequent ops under the new user\"), oracle: `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`, `playground:firestore-transaction`; get-missing/deleted + empty txn + nested update: `unit:upstream-transform-txn-listener-probes.test.ts` | 91 |\n| runTransaction(db, fn) | | Identity is frozen at `runTransaction` start — mid-transaction `setUser` does NOT re-auth in-flight reads | ✓ | (documented invariant) | 92 |\n| runTransaction(db, fn) | | Retry behavior — prod retries on contention up to 5 times; sandbox is single-threaded, no contention possible | ⚠ | divergence: contention story not modeled; sandbox just runs once | 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` (writes-reject branch), oracle: `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`, `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`, oracle: `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 | ✓ | (implementation invariant; cross-sandbox ownership is not directly probed) | 97 |\n| writeBatch(db) | | Batch identity is frozen at construction (per current implementation) | ✓ | (documented invariant) | 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`, `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` — `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`, `playground:firestore-sentinels` (bundled) + `playground:firestore-row-100-increment-bumps-numeric` (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` | 100 |\n| Sentinels | | `arrayUnion(...values)` de-dupes against existing members **and** against duplicate args within the same call | ✓ | `unit:sandbox-target.test.ts`, `playground:firestore-sentinels` (bundled) + `playground:firestore-row-101-arrayunion-dedupes` (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` | 101 |\n| Sentinels | | `arrayRemove(...values)` strips matching members; values not present in the array are silent no-ops | ✓ | `unit:sandbox-target.test.ts`, `playground:firestore-sentinels` (bundled) + `playground:firestore-row-102-arrayremove-strips` (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` | 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` (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` (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` | 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` (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` (`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` (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` (constructibility + round trips), `package-edge:package-dependencies.test.ts` | 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` (`documentId() filters + orderBy`); modular `where`/`orderBy` accept `FieldPath` | 107 |\n| Scalar types | | `FieldPath` (nested) works in queries against sandbox | ✓ | `unit:sandbox-target.test.ts` | 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` + `unit:packages/pyric/test/firestore/sandbox-target.test.ts` (\"Bytes + GeoPoint round-trip\"), oracle: `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` + `unit:packages/pyric/test/firestore/sandbox-target.test.ts` (\"Bytes + GeoPoint round-trip\"), oracle: `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` (\"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` | 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` (\"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` | 114 |\n| Equality helpers | | `refEqual(sandboxRef, foreignRef)` throws `TypeError` — references not created by this sandbox mirror are unrecognized | ✓ | `unit:sandbox-target.test.ts` (foreign refs throw unrecognized-reference TypeError) | 115 |\n| Equality helpers | | `queryEqual(a, b)` is identity-only in the sandbox mirror; production's untouched Firebase SDK uses structural equality | ⚠ | divergence: sandbox does identity-only; prod does deep structural. Oracle-locked: `packages/conformance/observations/firestore/firestore-queryequal-structural.json` — two independently-built queries with the same `where('x','==',1)` constraint compare equal in prod (`sameQueryBuiltTwice: true`), confirming structural semantics. Common use case (caching the same returned query) works on both. | 116 |\n| Equality helpers | | `snapshotEqual(a, b)`. Prod: returns a boolean — true on identity, false even for two fetches of the same data. Sandbox: **throws** (`unrecognized reference`) for sandbox-target snapshots instead of returning a boolean | ⚠ | divergence, oracle-locked by `packages/conformance/observations/firestore/firestore-snapshotequal-structural.json` (`identity: true`, `twoFetchesSameData: false` — prod is identity-only, NOT structural; an earlier structural guess was corrected by the oracle). The sandbox routes both args through the ref-tagging path, which does not recognize sandbox `QuerySnapshot`s, so `snapshotEqual` throws rather than comparing. Both sides pinned in `oracle-conformance.test.ts`. Fix candidate: identity-compare sandbox snapshots before the ref-tagging dispatch. | 117 |\n| Equality helpers | | Cross-flavor `refEqual` via `QuerySnapshot.docs[i].ref` works | ✓ | `unit:sandbox-live-identity.test.ts` (\"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` | 119 |\n| connectFirestoreEmulator | | Production does not enter the mirror: inactive package resolution leaves Firebase's `connectFirestoreEmulator` implementation unchanged | ✓ | `node-register:register-child.test.ts` (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 in the sandbox mirror — persistence is already the default; does not reject with `'failed-precondition'` when called after other ops (deliberately more lenient than the real SDK — no cache-init race to protect) | ⚠ no failed-precondition | `unit:firestore/persistence-network.test.ts` | 140 |\n| enableMultiTabIndexedDbPersistence | | Resolves in the sandbox mirror — the SharedWorker path already is the one shared store every tab talks to | ⚠ no failed-precondition | `unit:firestore/persistence-network.test.ts` | 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` | 142 |\n| enableNetwork / disableNetwork | | Resolve in the sandbox mirror — no network exists to toggle; writes issued while \"disabled\" still commit immediately (no offline queue is simulated) | ⚠ no offline queue | `unit:firestore/persistence-network.test.ts` | 143 |\n| waitForPendingWrites | | Resolves immediately in the sandbox mirror — every accepted write is already committed locally by the time its own promise resolves, so there are never writes still pending a server round-trip | ⚠ always resolves; prod can hang offline | `unit:firestore/persistence-network.test.ts` | 144 |\n| terminate | | Genuinely tears the sandbox target down by calling `Sandbox.dispose()`, which tears down listener registries on the sandbox's environment (idempotent, doesn't touch data). This differs from the real SDK in scope: `dispose()` operates on the whole `Sandbox`, not a Firestore-only slice, so if `pyric/database`/`pyric/storage` share the same `Sandbox` their listener registries are torn down too | ⚠ tears down the whole Sandbox, not a Firestore-only slice | `unit:firestore/terminate.test.ts` | 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` | 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` | 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` | 147 |\n| getDocFromCache / getDocsFromCache | | Delegates to `getDoc` / `getDocs` in the sandbox mirror. Real Firebase THROWS `'unavailable'` here on a genuine cache miss; pyric never misses — the local store always has the answer (or a non-existent snapshot) — so it never throws for that reason | ⚠ never throws unavailable; sandbox has no cache miss | `unit:firestore/tier1-cache-init-align.test.ts` | 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` | 149 |\n| onSnapshotsInSync | | Fires the callback once the current snapshot-delivery microtask queue settles — the closest honest approximation of \"every active listener has delivered its latest state\" available without a true cross-listener sync signal. Not scoped to real server round-trips like the real SDK's guarantee; scoped to local delivery only | ⚠ approximated from local snapshot-delivery settle, not a true global in-sync signal | `unit:firestore/tier1-cache-init-align.test.ts` | 150 |\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` (\"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` (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` (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` (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` (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` (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` (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` (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`; 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` (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` (RULES-B5 + \"RULES-B6 remainder\" blocks); `unit:rules/simulator/handler.test.ts` (\"RULES-B5 end-to-end\" block) | 138 |\n| | | DEFERRED sub-items of row 138: strict bool in `&&`/`\\|\\|`/ternary (`1 && true` should error) — corpus-coupled, needs emulator; a FLOAT stored in JSON test-data reads as int (`data.x is float`→false; prod uses the stored Firestore type tag) — needs a `__type:'float'` test-data revive marker; `resource`-null-on-create (RULES-B12 rest) | ⚠ | DEFERRED — see the design rationale (limitation + sub-items); strict-bool also in `step-07`. | 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` | 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` (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\">getDoc(ref)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Rules denial throws <code>SandboxError('permission-denied', …)</code> on sandbox; <code>FirebaseError('permission-denied')</code> on prod</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence: same code, different class — both expose <code>.code === 'permission-denied'</code>. Oracle-locked: <code>packages/conformance/observations/firestore/firestore-rules-denied-error.json</code> — prod throws a <code>FirebaseError</code> (name + constructor name both <code>FirebaseError</code>), <code>.code === 'permission-denied'</code>, <code>.message === '7 PERMISSION_DENIED: Missing or insufficient permissions.'</code>, and the value is an <code>instanceof Error</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\">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\"><code>unit:rules/simulator/query-proof.test.ts</code> (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), <code>unit:firestore/query-proof-enforcement.test.ts</code> (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\">addDoc(coll, data)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Auto-id format — prod uses 20-char base64-ish IDs; sandbox uses <code>pyric-admin</code>'s auto-id (also opaque, distinct format)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence: IDs are opaque on both sides; format differs but consumer code never parses them. Oracle-locked: <code>packages/conformance/observations/firestore/firestore-adddoc-autoid-format.json</code> — prod auto-ids are 20 characters, all alphanumeric (mixed upper, lower, digits; no other chars). Example: <code>S3PJENMPOk4qcDXol8Ez</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\">Query construction</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>limitToLast(n)</code> — trailing n in ordered result (requires <code>orderBy</code>). Sandbox: the no-orderBy precondition throws a <code>FirestoreError</code> with <code>.code === 'invalid-argument'</code> (FS-B16; pre-fix plain <code>Error</code>s). Prod: the same precondition throws <code>.code === 'unimplemented'</code></span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence, oracle-locked by <code>packages/conformance/observations/firestore/firestore-limittolast-preconditions.json</code>: prod's no-orderBy <code>limitToLast</code> throws code <code>unimplemented</code>, the sandbox throws <code>invalid-argument</code>. Trailing-window semantics with <code>orderBy</code> conform (observed <code>[\"b\"]</code> matches). Both sides pinned in <code>oracle-conformance.test.ts</code>. Cursor composition + descending: <code>unit:upstream-query-probes.test.ts</code>. Cursor/empty-snapshot precondition codes remain per <code>unit:sandbox-target.test.ts</code> + <code>unit:admin-compat/cursors.test.ts</code> (verified failing pre-fix)</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: <code>packages/conformance/observations/firestore/firestore-count-aggregate-shape.json</code> — <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\">runTransaction(db, fn)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Retry behavior — prod retries on contention up to 5 times; sandbox is single-threaded, no contention possible</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence: contention story not modeled; sandbox just runs once</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\">Equality helpers</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>queryEqual(a, b)</code> is identity-only in the sandbox mirror; production's untouched Firebase SDK uses structural equality</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence: sandbox does identity-only; prod does deep structural. Oracle-locked: <code>packages/conformance/observations/firestore/firestore-queryequal-structural.json</code> — two independently-built queries with the same <code>where('x','==',1)</code> constraint compare equal in prod (<code>sameQueryBuiltTwice: true</code>), confirming structural semantics. Common use case (caching the same returned query) works on both.</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\">Equality helpers</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>snapshotEqual(a, b)</code>. Prod: returns a boolean — true on identity, false even for two fetches of the same data. Sandbox: **throws** (<code>unrecognized reference</code>) for sandbox-target snapshots instead of returning a boolean</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence, oracle-locked by <code>packages/conformance/observations/firestore/firestore-snapshotequal-structural.json</code> (<code>identity: true</code>, <code>twoFetchesSameData: false</code> — prod is identity-only, NOT structural; an earlier structural guess was corrected by the oracle). The sandbox routes both args through the ref-tagging path, which does not recognize sandbox <code>QuerySnapshot</code>s, so <code>snapshotEqual</code> throws rather than comparing. Both sides pinned in <code>oracle-conformance.test.ts</code>. Fix candidate: identity-compare sandbox snapshots before the ref-tagging dispatch.</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\">enableIndexedDbPersistence</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Resolves in the sandbox mirror — persistence is already the default; does not reject with <code>'failed-precondition'</code> when called after other ops (deliberately more lenient than the real SDK — no cache-init race to protect)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>unit:firestore/persistence-network.test.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\">enableMultiTabIndexedDbPersistence</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Resolves in the sandbox mirror — the SharedWorker path already is the one shared store every tab talks to</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>unit:firestore/persistence-network.test.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\">enableNetwork / disableNetwork</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Resolve in the sandbox mirror — no network exists to toggle; writes issued while \"disabled\" still commit immediately (no offline queue is simulated)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>unit:firestore/persistence-network.test.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\">waitForPendingWrites</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Resolves immediately in the sandbox mirror — every accepted write is already committed locally by the time its own promise resolves, so there are never writes still pending a server round-trip</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>unit:firestore/persistence-network.test.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\">terminate</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Genuinely tears the sandbox target down by calling <code>Sandbox.dispose()</code>, which tears down listener registries on the sandbox's environment (idempotent, doesn't touch data). This differs from the real SDK in scope: <code>dispose()</code> operates on the whole <code>Sandbox</code>, not a Firestore-only slice, so if <code>pyric/database</code>/<code>pyric/storage</code> share the same <code>Sandbox</code> their listener registries are torn down too</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>unit:firestore/terminate.test.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\">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\"><code>unit:firestore/tier1-cache-init-align.test.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\">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\"><code>unit:firestore/tier1-cache-init-align.test.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\">getDocFromCache / getDocsFromCache</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Delegates to <code>getDoc</code> / <code>getDocs</code> in the sandbox mirror. Real Firebase THROWS <code>'unavailable'</code> here on a genuine cache miss; pyric never misses — the local store always has the answer (or a non-existent snapshot) — so it never throws for that reason</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>unit:firestore/tier1-cache-init-align.test.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\">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\"><code>unit:firestore/tier1-cache-init-align.test.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\">onSnapshotsInSync</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Fires the callback once the current snapshot-delivery microtask queue settles — the closest honest approximation of \"every active listener has delivered its latest state\" available without a true cross-listener sync signal. Not scoped to real server round-trips like the real SDK's guarantee; scoped to local delivery only</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>unit:firestore/tier1-cache-init-align.test.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\"><span class=\"compat-sub\"><span class=\"compat-behavior\">DEFERRED sub-items of row 138: strict bool in <code>&&</code>/<code>||</code>/ternary (<code>1 && true</code> should error) — corpus-coupled, needs emulator; a FLOAT stored in JSON test-data reads as int (<code>data.x is float</code>→false; prod uses the stored Firestore type tag) — needs a <code>__type:'float'</code> test-data revive marker; <code>resource</code>-null-on-create (RULES-B12 rest)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">DEFERRED — see the design rationale (limitation + sub-items); strict-bool also in <code>step-07</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\">firestore.runtime-class-values</code><span class=\"compat-sub\"><span class=\"compat-behavior\">These Firebase classes and constraint objects are present in Pyric's type surface but are not exported as runtime constructor values. Runtime identity and instanceof-compatible tokens remain unbuilt and cannot be credited by type-only mirrors.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>AbstractUserDataWriter</code> <code>AggregateField</code> <code>AggregateQuerySnapshot</code> <code>CollectionReference</code> <code>DocumentReference</code> <code>DocumentSnapshot</code> <code>Firestore</code> <code>FirestoreError</code> <code>Query</code> <code>QueryCompositeFilterConstraint</code> <code>QueryConstraint</code> <code>QueryDocumentSnapshot</code> <code>QueryEndAtConstraint</code> <code>QueryFieldFilterConstraint</code> <code>QueryLimitConstraint</code> <code>QueryOrderByConstraint</code> <code>QuerySnapshot</code> <code>QueryStartAtConstraint</code> <code>SnapshotMetadata</code> <code>Transaction</code> <code>WriteBatch</code></div>\n<div class=\"compat-note\">upstream:firebase/firestore</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\">firestore.cache-indexes</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Index-tuning / GC-policy APIs have no real knob to turn in an in-memory sandbox today, but sibling cache-factory tokens are already honest inert tokens; these are buildable the same way and not genuinely out of scope.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>CACHE_SIZE_UNLIMITED</code> <code>PersistentCacheIndexManager</code> <code>deleteAllPersistentCacheIndexes</code> <code>disablePersistentCacheIndexAutoCreation</code> <code>enablePersistentCacheIndexAutoCreation</code> <code>getPersistentCacheIndexManager</code> <code>setIndexConfiguration</code></div>\n<div class=\"compat-note\">upstream:firebase/firestore</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\">firestore.bundle-loading</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Bundle-loading depends on a protobuf packaging format not modeled in the sandbox yet — a data/parsing problem, not external infrastructure, so it is buildable rather than genuinely un-modelable.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>LoadBundleTask</code> <code>loadBundle</code> <code>namedQuery</code></div>\n<div class=\"compat-note\">upstream:firebase/firestore</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\">firestore.aggregate-equality-helpers</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Aggregate-field and aggregate-snapshot equality helpers are exported upstream but have no Pyric runtime implementation yet.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>aggregateFieldEqual</code> <code>aggregateQuerySnapshotEqual</code></div>\n<div class=\"compat-note\">upstream:firebase/firestore</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\">firestore.snapshot-json-resume</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Snapshot JSON reconstruction and listener-resume helpers depend on serialized snapshot state that the sandbox has not modeled.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>documentSnapshotFromJSON</code> <code>onSnapshotResume</code> <code>querySnapshotFromJSON</code></div>\n<div class=\"compat-note\">upstream:firebase/firestore</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\">firestore.public-write-plumbing</code><span class=\"compat-sub\"><span class=\"compat-behavior\">The upstream package publicly exports these low-level configuration/write helpers, but Pyric does not expose equivalent runtime plumbing yet.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>ensureFirestoreConfigured</code> <code>executeWrite</code></div>\n<div class=\"compat-note\">upstream:firebase/firestore</div></div>\n</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\">72.9%</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: 72.9%\"></span></span>\n</div>\n<p class=\"compat-stat-denom\">43 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| — | **Not implemented yet** — pending, or intentionally outside the mirror |\n| ? | **Unverified** — not yet backed by sufficient evidence |\n\n## Archived production-toolkit observations\n\nThese unsupported tombstones preserve immutable oracle `rowIds` for the removed host, REST, data, crawl, generation, and deployment toolkit. They are historical evidence, not current API claims.\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| Removed REST host | | Historical `.json` REST transport contract for the removed production host. | — | Archived oracle observation; implementation removed. | 5 |\n| Removed data handler | | Historical admin read and set/get behavior for the removed production data handler. | — | Archived oracle observations; implementation removed. | 10 |\n| Removed data handler | | Historical user read return shape for the removed production data handler. | — | Archived oracle observation; implementation removed. | 11 |\n| Removed data handler | | Historical rules-denial normalization for the removed production data handler. | — | Archived oracle observations; implementation removed. | 14 |\n| Removed data handler | | Historical rules-denied read behavior for the removed production data handler. | — | Archived oracle observation; implementation removed. | 15 |\n| Removed data handler | | Historical set/get round trip for the removed production data handler. | — | Archived oracle observation; implementation removed. | 16 |\n| Removed data handler | | Historical set-null removal behavior for the removed production data handler. | — | Archived oracle observation; implementation removed. | 18 |\n| Removed data handler | | Historical rules-denied write behavior for the removed production data handler. | — | Archived oracle observation; implementation removed. | 20 |\n| Removed data handler | | Historical multi-path update behavior for the removed production data handler. | — | Archived oracle observation; implementation removed. | 23 |\n| Removed data handler | | Historical push key behavior for the removed production data handler. | — | Archived oracle observation; implementation removed. | 27 |\n| Removed data handler | | Historical push auto-ID format for the removed production data handler. | — | Archived oracle observation; implementation removed. | 28 |\n| Removed data handler | | Historical remove-versus-set-null behavior for the removed production data handler. | — | Archived oracle observation; implementation removed. | 31 |\n| Removed data handler | | Historical idempotent removal behavior for the removed production data handler. | — | Archived oracle observation; implementation removed. | 32 |\n| Removed rules fetch handler | | Historical deployed-rules JSON round trip for the removed production fetch handler. | — | Archived oracle observation; implementation removed. | 39 |\n| Removed rules deployment handler | | Historical rules deployment propagation timing for the removed production deploy handler. | — | Archived oracle observation; implementation removed. | 46 |\n| Removed REST crawler | | Historical shallow REST response shape for the removed production crawler. | — | Archived oracle observation; implementation removed. | 58 |\n\n## `simulateRtdbRules(compiled, input)` — in-process rule evaluator\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| simulateRtdbRules(compiled, input) | | The removed stateful simulator returned a generate-before-simulate error when no IR had been generated | — | The stateless `simulateRtdbRules(compiled, input)` API requires a compiled rules tree and has no generate-before-simulate lifecycle. | 59 |\n| simulateRtdbRules(compiled, input) | | Returns `{ success: false, error: { code: 'INVALID_INPUT' } }` when input doesn't parse against `SimulationInputSchema` (e.g. path missing leading slash, operation not in read / write / validate) | ✓ | `unit:simulation/handler.test.ts` | 60 |\n| simulateRtdbRules(compiled, input) | | Walks ancestors from root → target; the first ancestor whose rule expression evaluates to `true` grants access — matches RTDB's documented \"rules cascade from root, true at any ancestor grants\" semantics | ✓ | `unit:simulation/handler.test.ts` | 61 |\n| simulateRtdbRules(compiled, input) | | Path variables (`$userId`) are bound from the URL path and exposed in `pathVariableBindings` (also without the `$` prefix for ergonomic access in expressions) | ✓ | `unit:simulation/handler.test.ts` | 62 |\n| simulateRtdbRules(compiled, input) | | `auth` context: when `null`, `auth` is null inside expressions; when present, `auth.uid` and `auth.token.*` are bound | ✓ | `unit:simulation/handler.test.ts`, `unit:grammar/simulator.test.ts` | 63 |\n| simulateRtdbRules(compiled, input) | | `mockData` becomes the value of `data` at every path during evaluation; `newData` is the proposed value for write/validate | ✓ | `unit:simulation/handler.test.ts` | 64 |\n| simulateRtdbRules(compiled, input) | | `data.child(\"…\")`, `data.parent()`, `data.exists()`, `data.val()` evaluate against the in-process snapshot — matches the documented `DataSnapshot` rule-context surface | ✓ | `unit:grammar/simulator.test.ts` | 65 |\n| simulateRtdbRules(compiled, input) | | Cross-path `root.child(…).val()` reads return `null` for paths NOT present in `mockData` — divergence from real prod rules where the engine reads the live database | ⚠ | divergence: the simulator uses ONLY what's in `mockData`. Real rules engine reads from the live RTDB. Documented in `validated.ts` (\"simulation uses empty mockData, so cross-path rule lookups … will evaluate as false\") | 66 |\n| simulateRtdbRules(compiled, input) | | An expression that fails to parse (`parsed.valid === false`) produces an unsupported result rather than silently granting or fabricating a deny | ✓ | `unit:simulation/handler.test.ts` | 67 |\n| simulateRtdbRules(compiled, input) | | When no ancestor rule allows, the result is `{ allowed: false }` with `matchedPath` set to the deepest matched node | ✓ | `unit:simulation/handler.test.ts` | 68 |\n| simulateRtdbRules(compiled, input) | | When NO ancestor has a rule for the operation at all, returns `{ success: false, error: { code: 'NO_MATCHING_RULE' } }` | ✓ | `unit:simulation/handler.test.ts` | 69 |\n| simulateRtdbRules(compiled, input) | | Evaluation errors (grammar mismatch, unknown identifier) surface as `EVALUATION_ERROR` | ✓ | `unit:simulation/handler.test.ts` | 70 |\n| simulateRtdbRules(compiled, input) | | Simulator's allow/deny decision matches the real RTDB rules engine for the same `{ rules, mockData, auth, operation, path, newData }` tuple, modulo the documented cross-path divergence on row #66 | ✓ | oracle: `packages/conformance/observations/rtdb/rtdb-simulator-vs-prod-agreement.json` — 8 test rules × 29 (rule, op) tuples; 28 agreements, 1 disagreement at capture time (`r4-validate-structure`: the simulator did not evaluate `.validate` on writes). The `.validate` walk is now implemented (`src/rules/rtdb/simulation/handler.ts`, reached from all backend write sites; grammar array-literals + `hasChildren(keys)` fixed alongside), closing the recorded disagreement — replayed as prod-conforming denial in `oracle-conformance.test.ts`. The frozen capture documents the historical divergence | 71 |\n\n## Constraint authoring surface (`atoms` / `policies` / `compose` / `ruleset`)\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | `atoms` exports the documented set of primitive predicates (`authenticated`, `ownPath`, `ownField`, `isNew`, `hasChildren`, `hasChild`, `fieldIsString/Number/Boolean`, `fieldEnum`, `immutable`, `immutableSelf`, `rootExists`, `rootEquals`) — each returns an `Expr` | ✓ | `unit:constraints/atoms.test.ts` | 84 |\n| | | `policies` exports composite predicates that compose atoms: `pathOwnerOnly`, `fieldOwnerOnly`, `ownerOrNew`, `hasRole`, `isMember`, `required`, `transition` | ✓ | `unit:constraints/policies.test.ts` | 85 |\n| | | `compose` exports the boolean combinators `all`, `any`, `not`, `deny`, `always`, plus the raw `expr` constructor | ✓ | `unit:constraints/compose.test.ts` | 86 |\n| | | `ruleset(...)` builds an environment-independent compiled RTDB rules tree from path definitions + expression objects | ✓ | `unit:constraints/ruleset.test.ts` | 87 |\n| | | Game-domain helpers (`turnGuard`, `flip`, `winCheckHelper`) compose into legal rule expressions | ✓ | `unit:constraints/game.test.ts` | 88 |\n\n## Compiled RTDB rules tree ↔ rules JSON\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| compileRtdbRules | | `compileRtdbRules(rulesJson)` produces an environment-independent tree where each node carries its path, parsed expressions, and child nodes | ✓ | `unit:compiled-rules.test.ts` | 89 |\n| serializeRtdbRules | | `serializeRtdbRules(compiled)` produces the Firebase rules-JSON payload for the compiled tree | ✓ | `unit:compiled-rules.test.ts` | 90 |\n| compileRtdbRules / serializeRtdbRules | | Round-trip `compileRtdbRules(serializeRtdbRules(compiled))` produces an equivalent rules tree (locked path/expression-text equality, not object identity) | ✓ | `unit:compiled-rules.test.ts` | 91 |\n| compileRtdbRules / serializeRtdbRules | | Path-variable segments (`$userId`, `$gameId`) preserved across the round-trip | ✓ | `unit:compiled-rules.test.ts` | 92 |\n| compileRtdbRules / serializeRtdbRules | | `.indexOn` arrays preserved across the round-trip | ✓ | `unit:compiled-rules.test.ts` | 93 |\n\n## Modular SDK surface\n\n`pyric/database` is the sandbox-only mirror of `firebase/database`'s\ntree-shakable free-function shape (`getDatabase`, `ref`, `child`, `get`,\n`set`, `update`, `remove`, `push`, listeners, queries, transactions, and\nsentinels). Package resolution selects production or sandbox before either\nmodule loads; this mirror has no production target or runtime dependency on\n`firebase/database`.\n\nTwo sandbox identity modes are selected by the value passed to\n`getDatabase`:\n\n- **Sandbox** — `getDatabase(ctx: SandboxContext)`. Frozen identity baked into\n the handle at construction.\n- **Sandbox-live** — `getDatabase(sandbox: Sandbox)`. Identity read per\n operation from `sandbox.currentUser`.\n\nThe implementation lives in `packages/pyric/src/database/modular.ts`; the\nin-process backend lives in `packages/pyric/src/database/sandbox/`. Rows below\nare scoped to the modular mirror. The pure rules-engine rows above are internal\ntooling coverage and are not exports of `pyric/database`.\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` (\"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` (\"reads sandbox.currentUser at op time, not at getDatabase time\") | M2 |\n| | | An in-module production target is intentionally absent; direct calls with a real `FirebaseApp` reject with package-resolution guidance | — | `unit:modular.test.ts`; production remains the responsibility of the unchanged `firebase/database` package | M3 |\n| | | `ref(db, path?)` returns a path-tagged `DatabaseReference`; default is root | ✓ | `unit:modular/sandbox-target.test.ts` (\"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` (\"child(ref, 'sub') composes paths\") | M5 |\n| | | `ref.parent` returns the parent ref; `root.parent === null` | ✓ | `unit:modular/sandbox-target.test.ts` (\"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` (\"ref.root returns the root ref\") | M7 |\n| | | `get(ref)` returns a `DataSnapshot`-shaped object with `val()`, `exists()`, `key`, `child()`, `hasChildren()`, `numChildren()`, `toJSON()` | ✓ | `unit:modular/sandbox-target.test.ts` (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` (\"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` (\"round-trips a primitive value\" + \"round-trips nested objects\"); matches oracle observation `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` (\"set(ref, null) deletes the path\"); matches oracle observation `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` (\"remove and set(null) produce identical end-state\"); matches oracle observation `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` (\"shallow-merges top-level keys\") | M13 |\n| | | `null` value in a shallow update deletes that key | ✓ | `unit:modular/sandbox-target.test.ts` (\"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` (\"one update nulls, mutates, and displaces within a limitToFirst window\") + `unit:modular/sandbox-target.test.ts` (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` (\"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` (\"mints 20-char keys starting with \\\"-\\\"\" + \"sequential push keys are lex-sortable\"); matches oracle observation `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` (\"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` (\"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` (\"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` (\"resolves to a number on read-back\"); matches oracle observation `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` (\"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` (\"rules-denied set throws a plain Error with PERMISSION_DENIED code\"); matches oracle observation `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` (\"rules-denied get throws the same plain Error shape\"); matches oracle observation `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` (\"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` (\"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` (\"fires after every write that touches the watched path\") + `unit:modular/no-change-suppression.test.ts` (\"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` (\"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` (\"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` (\"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 | ✓ | Tier 2: sandbox aligned with oracle observations under `packages/conformance/observations/rtdb-modular/rtdb-modular-onchild*.json`. See M41–M48 for the per-event behavioral 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` (\"replays existing direct children on subscribe — one fire per key\"); matches oracle observation `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` (\"fires exactly once per NEW direct child after subscribe\"); matches oracle observation `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` (\"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` (`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` (\"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` (parent wipe fan-out via remove(parent) / set(parent, scalar)) + `unit:modular/sandbox-child-events.test.ts` (single-child delete carries PRIOR val); matches oracle `rtdb-modular-onchildremoved-fires-on-delete.json` | M45 |\n| | | `onChildMoved` on a plain ref (no `query(_, orderBy*)`) NEVER fires — per RTDB docs, child_moved emits only under ordered queries | ✓ | Sandbox aligned: `unit:modular/sandbox-child-events.test.ts` (\"does NOT fire on a plain ref (no ordering)\"); matches the upstream contract observed under ordered-query in `packages/conformance/observations/rtdb-modular/rtdb-modular-onchildmoved-with-orderby.json` (where ordered-query did fire — Tier 3 will wire that path; Tier 2 locks the plain-ref no-fire case). | 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` (\"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` (`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` (\"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` (\"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` (\"sandbox.setRules(db, null) clears rules\") | M33 |\n| | | `sandbox.setData(db, { '/path': value })` bulk-loads data, bypassing rules | ✓ | `unit:modular/sandbox-target.test.ts` (\"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` (\"sandbox.snapshotState dumps the full tree\") | M35 |\n| | | `query(ref, ...constraints)` + ordering/range constraints | ✓ | `unit:upstream-rtdb-probes.test.ts` (\"orderByChild('a/b') + limitToFirst orders by the nested path\") + `unit:modular/queries.test.ts` + 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` (\"resolves to { committed: boolean, snapshot } with the committed value\"); matches oracle observations `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) | 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` (\"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` — 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| | | The update fn receives the CURRENT value at the ref's path; for an absent path the argument is `null` (NOT `undefined`) | ✓ | Sandbox aligned: `unit:modular/transaction.test.ts` (\"update fn receives null for an absent path\" + \"update fn receives the existing value for a seeded path\"); matches oracle observation `packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-current-value-arg.json` (prod observation showed `missingArgs[0].isNull === true`) — note divergence: prod ALSO speculatively calls the fn with `null` for a seeded path before the server-snap arrives, the sandbox skips that speculative call (single invocation with the real current value) | 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` (\"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` (\"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` (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` (\"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` (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` (\"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` (\"committed write fans out to onValue listeners\") | M37g |\n| | | Concurrent contention / retry-on-conflict — single-client sandbox doesn't model real concurrency; the documented \"up to 25 retries\" contract is degenerate (the fn is invoked once) | — | matrix #161 documents the same gap on the spec side; oracle observation hard to obtain from a single client (oracle row stays `?`) | 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` (\"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 | ✓ | implicit via the WeakMap binding; tested transitively by `sandbox.setData` + `get` round-trips in the same test suite | M39 |\n| | | Sandbox refs carry a stable `key` (last path segment) and `toString()` returning `sandbox://rtdb/<path>` | ✓ | covered by the `ref` / `child` / `parent` tests | 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` (deep orderByChild nested path) + `unit:modular/queries.test.ts` (\"returns children whose ordered child is within [startAt, endAt] inclusive\"); matches oracle `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` (INT32 overflow/underflow cursors) + `unit:modular/queries.test.ts` + `unit:modular/name-compare.test.ts`; matches oracle `rtdb-modular-orderbykey-window.json` and upstream `core/util/util.ts:253-276` | M50 |\n| | | `orderByValue()` orders primitive children by their value; `limitToFirst(N)` returns the N smallest, ascending | ✓ | Sandbox aligned: `unit:modular/queries.test.ts` (\"returns the limitToFirst(N) smallest values, ascending\"). Oracle observation `packages/conformance/observations/rtdb-modular/rtdb-modular-orderbyvalue-numeric.json` shows prod threw `Index not defined` against blockingfun — the sandbox does NOT enforce `.indexOn` (rules-engine integration for query indexes is deferred); the semantic claim (ordering by value) is locked here. | M51 |\n| | | `orderByChild(p) + equalTo(v)` returns ALL children whose field at `p` === `v` — no uniqueness enforced | ✓ | Sandbox aligned: `unit:modular/queries.test.ts` (\"returns ALL children whose ordered field === the supplied value\"); matches oracle observation `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`, `numChildren() === 0`) | ✓ | `unit:modular/queries.test.ts` (\"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` (\"limitToFirst takes the lowest-ranked window\"); matches oracle observation `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` (\"limitToLast takes the highest-ranked window\"); matches oracle observation `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` (\"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` (\"startAfter + endBefore drop the boundary values\"); matches oracle observation `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` (\"one update nulls, mutates, and displaces within a limitToFirst window\") + `unit:modular/queries.test.ts` (\"fires only when the windowed result changes\"); matches oracle `rtdb-modular-onvalue-with-query.json` | M58 |\n| | | `onValue(query)` initial fire delivers an empty window (`numChildren() === 0`) when the path is absent | ✓ | `unit:modular/queries.test.ts` (\"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` (\"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` (\"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` (\"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` (\"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` (\"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` (\"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` (\"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` (\"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` (\"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` (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` (4 cases); matches oracle `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` (currently always `null`), `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` (\"exposes size/priority/exportVal; NOT numChildren()\"); matches oracle `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`; 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` (2 cases) | M73 |\n| | | `onValue(ref, cb, { onlyOnce: true })` fires once then auto-unsubscribes (DB-B12) | ✓ | Sandbox aligned: `unit:modular/onvalue-onlyonce.test.ts`; upstream `api/Reference_impl.ts:975-980` | M74 |\n| | | **Divergence (DB-B12, honest doc):** the onChild* callbacks do NOT receive the `previousChildName` second argument; `onValue`/`onChild*` do NOT accept a `cancelCallback`; `onChildAdded`/`Changed`/`Removed`/`Moved` accept only plain refs (not `Query`); `child_moved` never fires (ordered-query move detection unmodeled). These listener-surface holes are out of scope for the current phase — consumers needing them use `firebase/database` directly. | ⚠ | divergence documented; partial coverage: `{ onlyOnce }` IS implemented (M74). | M75 |\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` 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\n### `getDatabase(target)` — initializer\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | `getDatabase(ctx)` returns a tagged sandbox-target handle (frozen identity) | — | Phase 3 | 94 |\n| | | `getDatabase(sandbox)` returns a tagged sandbox-live handle (per-op identity) | — | Phase 3 | 95 |\n| | | Inactive canonical `firebase/database` imports remain the upstream package; the mirror does not create tagged production targets | ? | package-resolution boundary; inactive RTDB canonical-import isolation is not yet claimed by this row | 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. | 97 |\n| | | Two `getDatabase(sandbox)` calls share state (same underlying `LocalEnvironment`) | — | Phase 3 | 98 |\n| | | Handle dispatch by `TARGET_SYMBOL` brand — refs route to their owning target via a `refToTarget` WeakMap (mirror of firestore's pattern) | — | Phase 3 | 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`, `toString()` | ? | upstream `firebase/database` contract | 100 |\n| | | `ref(db)` with no path returns the root ref (`key === null`, `parent === null`) | ? | upstream contract | 101 |\n| | | `child(ref, 'a/b')` joins a relative path, including embedded slashes | ? | upstream contract | 102 |\n| | | `ref.parent` is `null` at root, otherwise the parent ref | ? | upstream contract | 103 |\n| | | `ref.key` is the final path segment, `null` for root | ? | upstream contract | 104 |\n| | | Unknown ref (not produced by this package) → `TypeError` in shim ops | — | Phase 3 | 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` | 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` — observed `threw: false, val: null, exists: false` on a never-written path against blockingfun. | 107 |\n| | | `snap.exists()` is `false` when `val() === null`, `true` otherwise | ✓ | oracle: `packages/conformance/observations/rtdb-modular/rtdb-modular-get-missing-path.json` — observed `exists: false` for `val: null`. | 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` — the payload round-trips structurally on both sides (this row's claim holds). NOTE — adjacent divergence pinned in `oracle-conformance.test.ts`: 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` | 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>`) | ? | upstream contract | 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` — observed `beforeExists: true → afterExists: false, afterVal: null` after `set(ref, null)`. | 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` — observed `final: {a: 1}` with `b` absent after the second set. | 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` — the payload round-trips structurally on both sides (this row's claim holds). NOTE — adjacent divergence pinned in `oracle-conformance.test.ts`: 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` | 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` — after `set({a:1,b:2})` then `update({a:10})`, observed `final: {a:10, b:2}`. | 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` — observed `aX: 1, bY: 2` both readable after a single update call. | 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` — observed `threw: true, code: 'PERMISSION_DENIED'` AND `okPathWrittenDespiteDenial: false` (the otherwise-permitted path also rolled back). | 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` — after `set({a:1,b:2})` then `update({a:null})`, observed `final: {b:2}` with `a` absent. | 119 |\n| | | Update path validation — overlapping paths (e.g. `'/a'` and `'/a/x'` in the same call) throws synchronously before any write | ? | upstream contract — needs targeted probe | 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` | 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` — `remove` on a never-written path observed `threw: false, afterExists: false`. | 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` | 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` | 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` | 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` — `await push(parent, {hello:'world'})` returned a ref with a 20-char key; subsequent `get(r)` returned `{hello:'world'}`. | 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` — observed all 4 follow-up ops succeed through the returned ref (`refIsUsableForFollowupOps: true`). | 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` — observed exactly 1 initial fire within ~46ms of subscribe, snapshot.val() === the seeded payload. | 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` — observed 1 initial fire on a never-written path with `firstFire.val: null, firstFire.exists: false` (~55ms after subscribe). | 129 |\n| | | Subsequent `set(ref, …)` fires the listener with the new value | ✓ | oracle: `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). | 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` — observed `preUnsubFires: 2, postUnsubFires: 2` (a write performed after `unsub()` produced 0 additional fires within a 500ms settle window). | 131 |\n| | | The returned value from `onValue(ref, cb)` is the unsubscribe function (NOT an object); calling it removes the listener | ? | upstream contract — locked indirectly by #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 `orderByKey` order by default (unlike `onValue` which fires once with the parent snapshot) | ✓ | oracle: `packages/conformance/observations/rtdb-modular/rtdb-modular-onchildadded-initial-replay.json` — seeded `{k1, k2, k3}`, observed 3 initial fires with `firedKeys: ['k1', 'k2', 'k3']` in insertion order against blockingfun. | 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` — seeded `{k1,k2}`, observed `postSubscribeFires: 1, lastFire: {key:'k3', val:{v:3}}` after writing the new child. | 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` — observed `firedOnInitial: 0, firedOnUpdate: 1, lastFire: {key:'k1', val:{v:2}}` (the NEW value, not the prior). | 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` — observed `firedOnDelete: 1, removedSnapCarriesPriorValue: true` (snapshot.val() was the pre-delete value). | 136 |\n| | | `onChildMoved` under an ordered query. Prod: fires when a child's `orderByChild`/`orderByValue` priority changes — emitted only on ordered queries. Sandbox: **never fires on reorder** — `onChildMoved` supports the plain-ref (no-fire) case only; the ordered-query overload is unimplemented | ⚠ | divergence, oracle-locked by `packages/conformance/observations/rtdb-modular/rtdb-modular-onchildmoved-with-orderby.json`: prod observed `firedOnMove: 1` under `query(ref, orderByChild('priority'))` after bumping a child's priority to a new sort position; the sandbox fires 0 on reorder. Partial alignment landed: all `onChild*` now ACCEPT a `Query` (previously threw a misleading `unrecognized reference` TypeError) with window-aware `child_added`/`child_changed`/`child_removed` diffs (`src/database/sandbox/backend.ts`); the two hold-lifting captures now exist: `rtdb-modular-onchildmoved-previouschildname-sequencing` pins prev-name sequencing (end/middle/front reorders yield prev k3/k2/null, no initial replay) and `rtdb-modular-childchanged-cofire-with-childmoved` pins co-fire semantics (a reorder fires BOTH `child_changed` and `child_moved`; a non-ordered-field change fires neither moved; prod fires `child_moved` on an ordered-field value change EVEN WHEN RANK IS UNCHANGED). Implementation of ordered `child_moved` is unblocked. Both sides pinned in `modular/oracle-conformance.test.ts` and `modular/sandbox-child-events.test.ts`. Sandbox Tier 2 locks the plain-ref no-fire case (M46). | 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` — after `off(ref)` with no eventType, a subsequent write produced `postOffFires: 0` against an `onChildAdded` registration. | 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` — 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. | 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. | 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` — `unsubReturnType: 'function'`, `unsubReturnedFnStopsListener: true` (the captured return value halted fires on write), `offRefValueCbStopsListener: true` (the same effect via `off(ref, 'value', cb)`), `bothFormsEquivalent: true`. | 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 | ? | Pyric behavior is locked by `packages/pyric/test/app/multi-app-listener-auth.test.ts`; a production duplicate-registration oracle capture is still needed | 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` — 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. | 142 |\n| | | `orderByKey()` orders by the auto-id / numeric key | ✓ | oracle: `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). | 143 |\n| | | `orderByValue()` orders by the primitive value of each child (for collections of primitives) | ✓ | oracle: `packages/conformance/observations/rtdb-modular/rtdb-modular-orderbyvalue-numeric.json` — seeded `{alice:30, bob:10, carol:50, dave:20, eve:40}`, the prod call threw `Index not defined, add \".indexOn\": \".value\"` (so prod enforces an index requirement on `orderByValue()`); semantic ordering claim still holds, sandbox does not enforce indexes. | 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` — 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` (a..b..c groups) confirms `equalTo('b')` returns the two `b` children. | 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` — seeded positions `[1,2,3,4]`, observed `matched: [2,3,4]` for `startAt(2)` (cursor doc included). | 146 |\n| | | `endAt(v)` is **inclusive** | ✓ | oracle: `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). | 147 |\n| | | `startAfter(v)` is **exclusive** | ✓ | oracle: `packages/conformance/observations/rtdb-modular/rtdb-modular-startafter-endbefore-exclusive.json` — `startAfter(2) + endBefore(5)` matched positions `[3,4]` (cursor `2` dropped). | 148 |\n| | | `endBefore(v)` is **exclusive** | ✓ | oracle: `packages/conformance/observations/rtdb-modular/rtdb-modular-startafter-endbefore-exclusive.json` — same probe; cursor `5` dropped. | 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` plus `packages/conformance/observations/rtdb-modular/rtdb-modular-limittofirst-vs-limittolast.json` (firstPositions `[1,2]`). | 150 |\n| | | `limitToLast(N)` caps from the end | ✓ | oracle: `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`. | 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` — 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. | 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` — observed `createdAtType: 'number', createdAt: 1779075391118` (i.e. a plain JS number, NOT a `Timestamp` object). | 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` — read-back showed `createdAtSentinelShape: false` (sentinel resolved server-side; client sees the number, not the `.sv` placeholder). | 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` (\"increment against a missing field starts from 0\"); matches oracle `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` (\"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` — observed `afterSecond: 8` (5+3) then `afterNegative: 6` (8-2). | 156 |\n| | | Two concurrent `increment` calls interleave correctly (last-write-wins is NOT the contract — both deltas accumulate) | ? | hard to observe deterministically from a single client; documented contract | 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` — observed `committed: true, snapVal: 1` after running `current => (current ?? 0) + 1` against an empty ref. | 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` — seeded `100` then transaction returned `undefined`; observed `committed: false, snapVal: null, afterValOnServer: 100` (existing value preserved). | 159 |\n| | | The update fn is called with the CURRENT server value (may be `null` if the ref is empty); the fn's return value is the proposed new value | ✓ | oracle: `packages/conformance/observations/rtdb-modular/rtdb-modular-runtransaction-success.json` — observed `seenCurrentValues: [null]` on first invocation against an empty ref (a single call, no speculative re-runs against `undefined`). NOTE — adjacent divergence pinned in `modular/oracle-conformance.test.ts`: for a SEEDED path, prod speculatively invokes the update fn twice (first with `null`, then the real value; `rtdb-modular-runtransaction-current-value-arg.json` `seededArgs.length: 2`) while the sandbox invokes once with the actual value. The argument-semantics claim of this row holds for the effective invocation on both sides. WARNING for update-fn authors: prod may invoke your fn first with `null` even when data exists — the pattern `if (current === null) return;` (abort-on-empty) silently loses writes on prod while working on the sandbox, and side effects inside the fn can run twice on prod. RESOLVED by the warm-client capture `rtdb-modular-runtransaction-warm-client-speculation`: a warm prod client (active listener + prior get) receives a SINGLE invocation with the cached value, exactly matching the sandbox. The cold-cache speculative double-call is an artifact of an empty client cache, which the always-warm in-process sandbox structurally never has; the sandbox behavior IS the warm-client contract. | 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) | ? | hard to observe deterministically from a single client | 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` — observed `snapVal: 1` matching the committed value. | 162 |\n\n### `goOnline` / `goOffline` — connection control\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | `goOffline(db)` — accepted no-op: there is no network connection in the local sandbox to toggle, so nothing is disconnected (we deliberately do NOT simulate a disconnect — pending writes, listeners, and `get()` keep working) | ⚠ no network connection in the local sandbox to toggle | `unit:modular/fruit-aliases.test.ts` | 163 |\n| | | `goOnline(db)` — accepted no-op: there is no connection to reopen (see `goOffline`) | ⚠ no network connection in the local sandbox to toggle | `unit:modular/fruit-aliases.test.ts` | 164 |\n\n### `connectDatabaseEmulator` — emulator hook\n\n| API | Category | Behavior | Status | Probe | # |\n|---|---|---|---|---|---|\n| | | No-op on sandbox-target handles (the sandbox IS the local emulator) | — | Phase 3 | 165 |\n| | | A production target is intentionally absent; production code continues to use `connectDatabaseEmulator` from the unchanged `firebase/database` package | — | Phase 3 | 166 |\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` | 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` | 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` | 173 |\n| | | `refFromURL(db, url)` — real alias: parses the path out of the absolute database URL and delegates to `ref(db, path)`, so the returned ref resolves + reads exactly like `ref(db, path)`. Divergence: the sandbox is single-database with no host/namespace, so the URL's HOST is NOT validated against the handle (the real SDK throws if the host doesn't match the db's namespace); only the path is honored | ⚠ path resolves like `ref`; URL host/namespace not validated (single-database sandbox) | `unit:modular/fruit-aliases.test.ts` | 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\"><code class=\"compat-api\">simulateRtdbRules(compiled, input)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Cross-path <code>root.child(…).val()</code> reads return <code>null</code> for paths NOT present in <code>mockData</code> — divergence from real prod rules where the engine reads the live database</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence: the simulator uses ONLY what's in <code>mockData</code>. Real rules engine reads from the live RTDB. Documented in <code>validated.ts</code> (\"simulation uses empty mockData, so cross-path rule lookups … will evaluate as false\")</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 (DB-B12, honest doc):** the onChild* callbacks do NOT receive the <code>previousChildName</code> second argument; <code>onValue</code>/<code>onChild*</code> do NOT accept a <code>cancelCallback</code>; <code>onChildAdded</code>/<code>Changed</code>/<code>Removed</code>/<code>Moved</code> accept only plain refs (not <code>Query</code>); <code>child_moved</code> never fires (ordered-query move detection unmodeled). These listener-surface holes are out of scope for the current phase — consumers needing them use <code>firebase/database</code> directly.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence documented; partial coverage: <code>{ onlyOnce }</code> IS implemented (M74).</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>onChildMoved</code> under an ordered query. Prod: fires when a child's <code>orderByChild</code>/<code>orderByValue</code> priority changes — emitted only on ordered queries. Sandbox: **never fires on reorder** — <code>onChildMoved</code> supports the plain-ref (no-fire) case only; the ordered-query overload is unimplemented</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence, oracle-locked by <code>packages/conformance/observations/rtdb-modular/rtdb-modular-onchildmoved-with-orderby.json</code>: prod observed <code>firedOnMove: 1</code> under <code>query(ref, orderByChild('priority'))</code> after bumping a child's priority to a new sort position; the sandbox fires 0 on reorder. Partial alignment landed: all <code>onChild*</code> now ACCEPT a <code>Query</code> (previously threw a misleading <code>unrecognized reference</code> TypeError) with window-aware <code>child_added</code>/<code>child_changed</code>/<code>child_removed</code> diffs (<code>src/database/sandbox/backend.ts</code>); the two hold-lifting captures now exist: <code>rtdb-modular-onchildmoved-previouschildname-sequencing</code> pins prev-name sequencing (end/middle/front reorders yield prev k3/k2/null, no initial replay) and <code>rtdb-modular-childchanged-cofire-with-childmoved</code> pins co-fire semantics (a reorder fires BOTH <code>child_changed</code> and <code>child_moved</code>; a non-ordered-field change fires neither moved; prod fires <code>child_moved</code> on an ordered-field value change EVEN WHEN RANK IS UNCHANGED). Implementation of ordered <code>child_moved</code> is unblocked. Both sides pinned in <code>modular/oracle-conformance.test.ts</code> and <code>modular/sandbox-child-events.test.ts</code>. Sandbox Tier 2 locks the plain-ref no-fire case (M46).</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> — accepted no-op: there is no network connection in the local sandbox to toggle, so nothing is disconnected (we deliberately do NOT simulate a disconnect — pending writes, listeners, and <code>get()</code> keep working)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>unit:modular/fruit-aliases.test.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\"><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>goOnline(db)</code> — accepted no-op: there is no connection to reopen (see <code>goOffline</code>)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>unit:modular/fruit-aliases.test.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\"><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\"><code>unit:modular/fruit-aliases.test.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\"><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\"><code>unit:modular/fruit-aliases.test.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\"><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\"><code>unit:modular/fruit-aliases.test.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\"><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>refFromURL(db, url)</code> — real alias: parses the path out of the absolute database URL and delegates to <code>ref(db, path)</code>, so the returned ref resolves + reads exactly like <code>ref(db, path)</code>. Divergence: the sandbox is single-database with no host/namespace, so the URL's HOST is NOT validated against the handle (the real SDK throws if the host doesn't match the db's namespace); only the path is honored</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>unit:modular/fruit-aliases.test.ts</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\">Removed REST host</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Historical <code>.json</code> REST transport contract for the removed production host.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Archived oracle observation; implementation removed.</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\">Removed data handler</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Historical admin read and set/get behavior for the removed production data handler.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Archived oracle observations; implementation removed.</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\">Removed data handler</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Historical user read return shape for the removed production data handler.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Archived oracle observation; implementation removed.</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\">Removed data handler</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Historical rules-denial normalization for the removed production data handler.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Archived oracle observations; implementation removed.</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\">Removed data handler</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Historical rules-denied read behavior for the removed production data handler.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Archived oracle observation; implementation removed.</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\">Removed data handler</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Historical set/get round trip for the removed production data handler.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Archived oracle observation; implementation removed.</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\">Removed data handler</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Historical set-null removal behavior for the removed production data handler.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Archived oracle observation; implementation removed.</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\">Removed data handler</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Historical rules-denied write behavior for the removed production data handler.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Archived oracle observation; implementation removed.</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\">Removed data handler</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Historical multi-path update behavior for the removed production data handler.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Archived oracle observation; implementation removed.</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\">Removed data handler</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Historical push key behavior for the removed production data handler.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Archived oracle observation; implementation removed.</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\">Removed data handler</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Historical push auto-ID format for the removed production data handler.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Archived oracle observation; implementation removed.</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\">Removed data handler</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Historical remove-versus-set-null behavior for the removed production data handler.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Archived oracle observation; implementation removed.</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\">Removed data handler</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Historical idempotent removal behavior for the removed production data handler.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Archived oracle observation; implementation removed.</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\">Removed rules fetch handler</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Historical deployed-rules JSON round trip for the removed production fetch handler.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Archived oracle observation; implementation removed.</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\">Removed rules deployment handler</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Historical rules deployment propagation timing for the removed production deploy handler.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Archived oracle observation; implementation removed.</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\">Removed REST crawler</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Historical shallow REST response shape for the removed production crawler.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Archived oracle observation; implementation removed.</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\">simulateRtdbRules(compiled, input)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">The removed stateful simulator returned a generate-before-simulate error when no IR had been generated</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">The stateless <code>simulateRtdbRules(compiled, input)</code> API requires a compiled rules tree and has no generate-before-simulate lifecycle.</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\">An in-module production target is intentionally absent; direct calls with a real <code>FirebaseApp</code> reject with package-resolution guidance</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>unit:modular.test.ts</code>; production remains the responsibility of the unchanged <code>firebase/database</code> package</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\">Concurrent contention / retry-on-conflict — single-client sandbox doesn't model real concurrency; the documented \"up to 25 retries\" contract is degenerate (the fn is invoked once)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">matrix #161 documents the same gap on the spec side; oracle observation hard to obtain from a single client (oracle row stays <code>?</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\"><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>getDatabase(ctx)</code> returns a tagged sandbox-target handle (frozen identity)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Phase 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\"><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>getDatabase(sandbox)</code> returns a tagged sandbox-live handle (per-op identity)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Phase 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\"><span class=\"compat-sub\"><span class=\"compat-behavior\">Two <code>getDatabase(sandbox)</code> calls share state (same underlying <code>LocalEnvironment</code>)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Phase 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\"><span class=\"compat-sub\"><span class=\"compat-behavior\">Handle dispatch by <code>TARGET_SYMBOL</code> brand — refs route to their owning target via a <code>refToTarget</code> WeakMap (mirror of firestore's pattern)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Phase 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\"><span class=\"compat-sub\"><span class=\"compat-behavior\">Unknown ref (not produced by this package) → <code>TypeError</code> in shim ops</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Phase 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\"><span class=\"compat-sub\"><span class=\"compat-behavior\">No-op on sandbox-target handles (the sandbox IS the local emulator)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Phase 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\"><span class=\"compat-sub\"><span class=\"compat-behavior\">A production target is intentionally absent; production code continues to use <code>connectDatabaseEmulator</code> from the unchanged <code>firebase/database</code> package</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Phase 3</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\"><span class=\"compat-sub\"><span class=\"compat-behavior\">Inactive canonical <code>firebase/database</code> imports remain the upstream package; the mirror does not create tagged production targets</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">package-resolution boundary; inactive RTDB canonical-import isolation is not yet claimed by this row</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\"><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>ref(db, path)</code> returns a tagged <code>DatabaseReference</code> carrying <code>key</code>, <code>parent</code>, <code>root</code>, <code>toString()</code></span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">upstream <code>firebase/database</code> contract</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\"><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>ref(db)</code> with no path returns the root ref (<code>key === null</code>, <code>parent === null</code>)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">upstream contract</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\"><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>child(ref, 'a/b')</code> joins a relative path, including embedded slashes</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">upstream contract</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\"><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>ref.parent</code> is <code>null</code> at root, otherwise the parent ref</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">upstream contract</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\"><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>ref.key</code> is the final path segment, <code>null</code> for root</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">upstream contract</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\"><span class=\"compat-sub\"><span class=\"compat-behavior\">Replaces the value at the path entirely; resolves to <code>undefined</code> (unlike <code>setDoc</code> which resolves to <code>void</code>, RTDB's <code>set</code> is documented as <code>Promise<void></code>)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">upstream contract</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\"><span class=\"compat-sub\"><span class=\"compat-behavior\">Update path validation — overlapping paths (e.g. <code>'/a'</code> and <code>'/a/x'</code> in the same call) throws synchronously before any write</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">upstream contract — needs targeted probe</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\"><span class=\"compat-sub\"><span class=\"compat-behavior\">The returned value from <code>onValue(ref, cb)</code> is the unsubscribe function (NOT an object); calling it removes the listener</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">upstream contract — locked indirectly by #131</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\">off(ref, eventType, callback)</code><span class=\"compat-sub\"><span class=\"compat-behavior\">When the same callback is registered more than once, each <code>off(ref, eventType, callback)</code> removes one registration without orphaning the others</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">Pyric behavior is locked by <code>packages/pyric/test/app/multi-app-listener-auth.test.ts</code>; a production duplicate-registration oracle capture is still needed</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\"><span class=\"compat-sub\"><span class=\"compat-behavior\">Two concurrent <code>increment</code> calls interleave correctly (last-write-wins is NOT the contract — both deltas accumulate)</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">hard to observe deterministically from a single client; documented contract</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\"><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\">hard to observe deterministically from a single client</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\">database.runtime-class-values</code><span class=\"compat-sub\"><span class=\"compat-behavior\">These Firebase classes are mirrored structurally in Pyric's type surface but are not exported as runtime constructor values; runtime identity remains unbuilt.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>DataSnapshot</code> <code>Database</code> <code>QueryConstraint</code> <code>TransactionResult</code></div>\n<div class=\"compat-note\">upstream:firebase/database</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\">database.connection-lifecycle</code><span class=\"compat-sub\"><span class=\"compat-behavior\">onDisconnect requires a live connection lifecycle the in-memory sandbox has no equivalent for today — buildable as an honest no-op or inert token like the connection-management APIs, not genuinely un-modelable.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>OnDisconnect</code> <code>onDisconnect</code></div>\n<div class=\"compat-note\">registry:rtdb-modular#163</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\">database.priority-ordering</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Firebase's legacy priority ordering system is absent; the sandbox exposes orderByChild/Key/Value only today. Priority ordering is data-modelable as a sort key, so this is revisitable scope rather than a hard sandbox limitation.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>orderByPriority</code> <code>setPriority</code> <code>setWithPriority</code></div>\n<div class=\"compat-note\">upstream:firebase/database</div></div>\n</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\">62.9%</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: 62.9%\"></span></span>\n</div>\n<p class=\"compat-stat-denom\">22 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` | 1 |\n| getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?) | | `getStorageSandbox(sandbox)` wraps a bare Sandbox with an anonymous context (`auth: null`) | ✓ | `unit:service.test.ts` | 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 | 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` (\"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` (\"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` (\"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` (\"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` (\"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:rules.test.ts` (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` (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` (\"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` 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. | 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` (\"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` (\"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` (\"normalizes leading/trailing/double slashes\") | 15 |\n| ref(storage[, path])` / `ref(parent, path) | | Path normalization: trailing slashes stripped | ✓ | `unit:reference.test.ts` | 16 |\n| ref(storage[, path])` / `ref(parent, path) | | Path normalization: repeated internal slashes collapsed (`a//b` → `a/b`) | ✓ | `unit:reference.test.ts` | 17 |\n| ref(storage[, path])` / `ref(parent, path) | | `ref(parent, child)` joins relative to parent's `fullPath` | ✓ | `unit:reference.test.ts` (\"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` (\"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` | 20 |\n| ref(storage[, path])` / `ref(parent, path) | | `toString()` returns `gs://<bucket>/<fullPath>` | ✓ | `unit:reference.test.ts` (\"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` 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` 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` (\"accepts a Blob and round-trips through getBlob\") | 24 |\n| uploadBytes(ref, data, metadata?) | | Accepts `Uint8Array` payload | ✓ | `unit:reference.test.ts` (\"accepts a Uint8Array\") | 25 |\n| uploadBytes(ref, data, metadata?) | | Accepts `ArrayBuffer` payload | ✓ | `unit:reference.test.ts` (\"accepts an ArrayBuffer\") | 26 |\n| uploadBytes(ref, data, metadata?) | | ContentType precedence: caller's `metadata.contentType` > `Blob.type` > `application/octet-stream` | ✓ | `unit:reference.test.ts` (\"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` (\"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` (\"round-trips customMetadata\") + `unit:metadata.test.ts` | 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` (\"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` (\"throws on root reference\") | 31 |\n| uploadBytes(ref, data, metadata?) | | Returned `metadata.fullPath` matches the ref's `fullPath` | ✓ | `unit:reference.test.ts` | 32 |\n| uploadBytes(ref, data, metadata?) | | Returned `metadata.size` matches the input blob's byte length | ✓ | `unit:reference.test.ts` | 33 |\n| uploadBytes(ref, data, metadata?) | | Returned `metadata.bucket` matches the storage handle's bucket | ✓ | `unit:reference.test.ts` | 34 |\n| uploadBytes(ref, data, metadata?) | | Replaces any existing object at the path (overwrite, not append) | ✓ | `unit:upstream-storage-probes.test.ts` (\"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` (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` + oracle: `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` | 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` (\"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` (\"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`: 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`; sandbox code path documented in `upload.ts`'s `decodeString`. Implementing base64url decoding is still one line in `decodeString`. | 41 |\n| uploadString(ref, value, format?, metadata?) | | `format='data_url'`: parses `data:<mime>;base64,<payload>`, infers `contentType` from prefix | ✓ | `unit:reference.test.ts` (\"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` (\"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` (\"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` (\"throws on malformed data_url\") | 45 |\n| uploadString(ref, value, format?, metadata?) | | Prod: `uploadString(ref, value, 'base64')` round-trips via `getDownloadURL` + fetch | ✓ (prod-only) | oracle: `packages/conformance/observations/storage/storage-uploadstring-base64-roundtrip.json` (`'aGVsbG8='` → `'hello'` against blockingfun, fb-js-sdk 12.13.0; `textMatches: true`) | 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()` | — | No `pyric/storage` implementation or behavioral probe exists; availability is classified by the owning surface contract. | 47 |\n| uploadBytesResumable(ref, data, metadata?) | | `task.on('state_changed', next, error, complete)` fires `next` with `{bytesTransferred, totalBytes, state}` snapshots | — | not implemented | 48 |\n| uploadBytesResumable(ref, data, metadata?) | | `task.pause()` flips `state` to `'paused'`; `task.resume()` continues | — | not implemented | 49 |\n| uploadBytesResumable(ref, data, metadata?) | | `task.cancel()` rejects the upload with `storage/canceled` | — | not implemented | 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` 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:`. 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. | 51 |\n| getDownloadURL(ref) | | Throws `storage/object-not-found` for missing objects | ✓ | Production observation `storage-delete-then-get-throws` 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. | 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` (\"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` (\"throws storage/object-not-found for missing paths\") + oracle: `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` (\"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` (\"just-under-cap reads return the full object\") + `unit:reference.test.ts` (\"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` (\"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` (\"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` (\"throws storage/object-not-found for missing paths\") | 59 |\n| getBlob(ref, maxDownloadSize?) | | Honors `maxDownloadSize` same as `getBytes` | ✓ | `unit:upstream-storage-probes.test.ts` (\"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` (\"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` (`code: 'storage/object-not-found'`, `name: 'FirebaseError'` against blockingfun, fb-js-sdk 12.13.0). Both sides pinned in `oracle-conformance.test.ts`; documented in `download.ts`. | 64 |\n| deleteObject(ref) | | Throws `storage/invalid-root-operation` on the root reference | ✓ | `unit:reference.test.ts` (\"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` (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` | 68 |\n| listAll(ref) | | Empty bucket → both arrays empty, `nextPageToken: undefined` | ✓ | `unit:list.test.ts` (\"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` (\"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` (\"promotes sub-folders into prefixes (deduplicated)\") | 71 |\n| listAll(ref) | | `items` sorted by path (IDB key order, lexicographic) | ✓ | `unit:list.test.ts` (\"lists direct children of a folder\") | 72 |\n| listAll(ref) | | `prefixes` sorted lexicographically by `fullPath` (for determinism) | ✓ | `unit:list.test.ts` (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` (\"does not include the scanned ref itself\") | 74 |\n| listAll(ref) | | `listAll(ref(storage))` (root) scans the entire bucket | ✓ | `unit:list.test.ts` (\"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` (\"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` (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` (\"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` (\"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` (\"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` (\"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` (\"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` | 83 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | `updateMetadata` refreshes `updated` to the call moment; `timeCreated` and `generation` stay pinned | ✓ | `unit:metadata.test.ts` | 84 |\n| getMetadata(ref)` / `updateMetadata(ref, metadata) | | `updateMetadata` preserves the blob bytes (only metadata changes) | ✓ | `unit:metadata.test.ts` (\"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` (\"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` (\"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` (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` (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` confirms prod sets `md5Hash` (`hasMd5Hash: true` after a vanilla `uploadBytes`). Both sides pinned in `oracle-conformance.test.ts`. 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` (\"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` (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:rules.test.ts` (operation-integration section) | 106 |\n| Rules enforcement | | `updateMetadata` against a denied path throws `storage/unauthorized` | ✓ | `unit:rules.test.ts` | 107 |\n| Rules enforcement | | `deleteObject` against a denied path throws `storage/unauthorized` | ✓ | `unit:rules.test.ts` | 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:rules.test.ts` (\"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; <code>unit:service.test.ts</code> 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 <code>packages/conformance/observations/storage/storage-uploadstring-unknown-format.json</code>: 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 <code>oracle-conformance.test.ts</code>; sandbox code path documented in <code>upload.ts</code>'s <code>decodeString</code>. Implementing base64url decoding is still one line in <code>decodeString</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\">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 <code>storage-upload-bytes-roundtrip</code> 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 <code>blob:</code>. 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.</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: <code>packages/conformance/observations/storage/storage-delete-missing-throws.json</code> (<code>code: 'storage/object-not-found'</code>, <code>name: 'FirebaseError'</code> against blockingfun, fb-js-sdk 12.13.0). Both sides pinned in <code>oracle-conformance.test.ts</code>; 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: <code>packages/conformance/observations/storage/storage-upload-then-getmetadata.json</code> confirms prod sets <code>md5Hash</code> (<code>hasMd5Hash: true</code> after a vanilla <code>uploadBytes</code>). Both sides pinned in <code>oracle-conformance.test.ts</code>. 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\">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\">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\">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\">not implemented</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\">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\">not implemented</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\">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\">not implemented</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\">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<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.resumable-upload</code><span class=\"compat-sub\"><span class=\"compat-behavior\">The UploadTask and observer surface is deferred — v1 shipped the one-shot uploadBytes driver first; the resumable/observable variant is unbuilt, not unbuildable.</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>uploadBytesResumable</code></div>\n<div class=\"compat-note\">registry:storage#47</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\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`. | 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` (minted, length 142, colon-separated, suffix starts `APA91b`, URL-safe) + `messaging-web-token-stability.json` (second `getToken` on the same registration returns the same token). Replayed by the conformance suite. | 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` (deleteToken resolved truthy; no delivery to client; send plane eventually UNREGISTERED). Replayed by the conformance suite. | 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` (focused page → onMessage) + `messaging-web-visibility-routing.json` (visible → onMessage, no visible client → onBackgroundMessage). Replayed by the conformance suite. | 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. | 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. | 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. | 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` + `messaging-web-onbackgroundmessage.json` (top-level keys data/from/messageId/notification; from = sender id; messageId present). Replayed by the conformance suite. | 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` (notificationKeys body, title). Replayed by the conformance suite. | 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. | 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. | 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. | 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`. | 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` (no visible client → onBackgroundMessage) + `messaging-web-visibility-routing.json` + `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`. | 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. | 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. | 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`. | 17 |\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. | 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. | 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. | 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`, `messaging-send-condition-accepted`, `messaging-send-notification-only-vs-data-only-accepted`, `messaging-send-webpush-config-accepted`; error envelopes `messaging-send-no-target-error-envelope`, `messaging-send-invalid-token-error-envelope`, `messaging-send-invalid-condition-error-envelope`, `messaging-send-invalid-topic-name-error-envelope`, `messaging-send-oversized-payload-error-envelope`, `messaging-send-webpush-invalid-ttl-error-envelope`. Replayed by the conformance suite. | 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. | 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. | 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. | 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. | 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. | 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. | 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` (both accepted). Replayed by the conformance suite. | 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` (fieldViolations names message.token). Replayed by the conformance suite. | 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` (accepted) + `messaging-send-invalid-topic-name-error-envelope.json` (bad name rejected). Replayed by the conformance suite. | 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` (accepted) + `messaging-send-invalid-condition-error-envelope.json` (malformed rejected). Replayed by the conformance suite. | 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. | 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`. Replayed by the conformance suite. | 16 |\n| interface FcmOptions { analyticsLabel? } | | Platform-independent FCM options (`analyticsLabel`). | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. | 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` (accepted) + `messaging-send-webpush-invalid-ttl-error-envelope.json` (bad TTL rejected). Replayed by the conformance suite. | 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` (link accepted). Replayed by the conformance suite. | 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. | 20 |\n| interface ApnsConfig { liveActivityToken?; headers?; payload?; fcmOptions? } | | APNs overrides. | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. | 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. | 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. | 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. | 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. | 25 |\n| interface ApnsFcmOptions { analyticsLabel?; imageUrl? } | | APNs FCM options (`analyticsLabel`, `imageUrl`). | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. | 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. | 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. | 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. | 29 |\n| interface AndroidFcmOptions { analyticsLabel? } | | Android FCM options (`analyticsLabel`). | ✓ | Upstream typings (firebase-admin 13.10.0 `messaging-api`); no observation yet. | 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. | 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. | 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. | 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. | 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. | 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. | 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. | 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. | 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` + `messaging-send-invalid-token-error-envelope.json` (both carry the INVALID_ARGUMENT FcmError). Replayed by the conformance suite. | 39 |\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` (firebase-functions 7.2.5); production exact-create behavior. Local replay: `packages/cli/test/functions-rtdb/oracle-conformance.test.ts` 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`; update/delete produced no additional delivery in the bounded production scenario. Local replay: `packages/cli/test/functions-rtdb/oracle-conformance.test.ts` 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`; pre-seeded value, zero delivery in the observation window. Local replay: `packages/cli/test/functions-rtdb/oracle-conformance.test.ts` 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`; production populated caseId and itemId. Local replay: `packages/cli/test/functions-rtdb/oracle-conformance.test.ts` 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`; one ancestor set delivered alpha and beta. Local replay: `packages/cli/test/functions-rtdb/oracle-conformance.test.ts` 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`; leaf snapshot excluded its sibling. Local replay: `packages/cli/test/functions-rtdb/oracle-conformance.test.ts` 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`; one update delivered delta and gamma. Local replay: `packages/cli/test/functions-rtdb/oracle-conformance.test.ts` 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`; frozen val/key/exists/toJSON/child enumeration shape. Local replay: `packages/cli/test/functions-rtdb/oracle-conformance.test.ts` 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`; matched Admin ref completed the awaited sibling write. Local replay: `packages/cli/test/functions-rtdb/oracle-conformance.test.ts` 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`. Local replay: `packages/cli/test/functions-rtdb/oracle-conformance.test.ts` 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`; capture logged only after the delayed Admin write completed. Local replay: `packages/cli/test/functions-rtdb/oracle-conformance.test.ts` 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`; 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`; all three arrived in observed order 2, 1, 3. Local replay: `packages/cli/test/functions-rtdb/oracle-conformance.test.ts` 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: <code>functions-rtdb-onvaluecreated-failed-execution.json</code>; 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` — 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`; 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` — production Firestore Rules Test API verdicts for corpus scenario \"bytes-toutf8-and-hashing\", replayed verdict-for-verdict against the local rules simulator by `unit:rules/oracle-conformance.test.ts`. simulator's toUtf8/md5/sha256/crc32/crc32c byte-encoding and reference-hash implementations diverge from production on all 5 scenario cases, so a rule that should DENY on hash mismatch ALLOWs locally — pinned KNOWN_DIVERGENCE | 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` — 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`; 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` — 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`; all cases match production. | 163 |\n| Rules simulator | | `getAfter()`/`existsAfter()` (Item 7) in rules — post-write document identity and existence semantics | ⚠ | `oracle:rules-firestore-get-after-and-exists-after` — production Firestore Rules Test API verdicts for corpus scenario \"get-after-and-exists-after\", replayed verdict-for-verdict against the local rules simulator by `unit:rules/oracle-conformance.test.ts`. simulator does not model the post-write document identity/existence production compares against on 4 scenario cases (getAfter target identity, existsAfter on create/delete, existsAfter over an unrelated mocked path) — pinned KNOWN_DIVERGENCE | 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` — 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`. simulator synthesizes a resource identity (`id`, `__name__`) for mocked get() results that production leaves absent, on 2 scenario cases — pinned KNOWN_DIVERGENCE | 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` — 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`. simulator models `request.query` as an empty map on the empty-query case where production denies the equivalent comparison — pinned KNOWN_DIVERGENCE. Contamination scope is `request.query` ALONE: the scenario's `request.path` cases conform, and its `resource.id`/`resource.__name__` cases conform because they run on a `create` (where `resource` is null pre-write). 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` — production Firestore Rules Test API verdicts for corpus scenario \"int-float-and-division\", replayed verdict-for-verdict against the local rules simulator by `unit:rules/oracle-conformance.test.ts`. simulator narrows a float-valued payload field toward int on the float-payload case, unlike production which preserves the float type — pinned KNOWN_DIVERGENCE | 167 |\n| Rules simulator | | `List.concat()`/`removeAll()`/`toSet()` (Item 5.2) in rules | ✓ | `oracle:rules-firestore-list-methods-concat-removeall-toset` — production Firestore Rules Test API verdicts for corpus scenario \"list-methods-concat-removeall-toset\", replayed verdict-for-verdict against the local rules simulator by `unit:rules/oracle-conformance.test.ts`; all cases match production. | 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` — 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`; 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` — 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`; 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` — production Firestore Rules Test API verdicts for corpus scenario \"path-constructor-and-bind\", replayed verdict-for-verdict against the local rules simulator by `unit:rules/oracle-conformance.test.ts`. simulator treats `path()` as idempotent on an already-Path argument where production denies — pinned KNOWN_DIVERGENCE | 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` — 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`; 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` — production Firestore Rules Test API verdicts for corpus scenario \"range-slice-list-and-string\", replayed verdict-for-verdict against the local rules simulator by `unit:rules/oracle-conformance.test.ts`. simulator clamps an out-of-bounds slice end to the collection length on both the list and string OOB-slice cases; production denies — pinned KNOWN_DIVERGENCE | 173 |\n| Rules simulator | | `Set.difference()`/`union()`/`intersection()` (Item 5.1) in rules | ✓ | `oracle:rules-firestore-set-algebra-difference-union-intersection` — production Firestore Rules Test API verdicts for corpus scenario \"set-algebra-difference-union-intersection\", replayed verdict-for-verdict against the local rules simulator by `unit:rules/oracle-conformance.test.ts`; all cases match production. | 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` — 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`; 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` — 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`; 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` — 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`; 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` — replayed by `unit:rules/oracle-conformance.test.ts`. | 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` — replayed by `unit:rules/oracle-conformance.test.ts`. | 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` — replayed by `unit:rules/oracle-conformance.test.ts`. | 180 |\n| Rules simulator | | `latlng.value().latitude()/longitude()/distance()` and `duration.value()/time()/abs()` in rules | ✓ | NEW ROW, 2026-07-12: production capture proves the simulator matches the production Firestore Rules Test API verdict-for-verdict on all 2 cases (coordinate + duration bounds). `oracle:rules-firestore-duration-and-latlng` — replayed by `unit:rules/oracle-conformance.test.ts`. | 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` — replayed by `unit:rules/oracle-conformance.test.ts`. | 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` — replayed by `unit:rules/oracle-conformance.test.ts`. | 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` — replayed by `packages/pyric/test/rules/oracle-conformance.test.ts`. | 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` matches the local simulator verdict-for-verdict on all 3 cases. | 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` — 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`. The prior unsupported row recorded the honest UNSUPPORTED abstention; that gap is closed (#346). | 186 |\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:rules.test.ts` (\"parses the canonical session-archive ruleset\") | 94 |\n| Rules | | `parseStorageRules` rejects non-`firebase.storage` service headers | ✓ | `unit:rules.test.ts` (\"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` (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. | 96 |\n| Rules | | `parseStorageRules` rejects unterminated string literals with `SyntaxError` | ✓ | `unit:rules.test.ts` (\"rejects unterminated strings\") | 97 |\n| Rules | | `evaluateStorageRules` matches `match /sessions/{id} { allow read: if request.auth != null; }` for an authed read | ✓ | `unit:rules.test.ts` (\"allows authenticated reads of /sessions/{id}\") | 98 |\n| Rules | | `evaluateStorageRules` denies anonymous reads when the rule requires `request.auth != null` | ✓ | `unit:rules.test.ts` (\"denies anonymous reads\") | 99 |\n| Rules | | `evaluateStorageRules` supports `request.resource.size < N` constraints (with arithmetic literals like `10 * 1024 * 1024`) | ✓ | `unit:rules.test.ts` (\"allows JSON writes under 10MB\") | 100 |\n| Rules | | `evaluateStorageRules` supports `request.resource.contentType == '<mime>'` constraints | ✓ | `unit:rules.test.ts` (mime constraint inside the session-archive ruleset) | 101 |\n| Rules | | Multi-segment wildcard `{allPaths=**}` matches zero-or-more remaining segments | ✓ | `unit:rules.test.ts` (parser + evaluator both honor the `**` form) | 102 |\n| Rules | | Path-parameter binding (`{sessionId}`) accessible inside the `if` expression | ✓ | `unit:rules.test.ts` | 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` 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). | 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` matches production verdict-for-verdict on all 4 cases (deadline-before/after via `timestamp.date()`, epoch-bound before/after via `timestamp.value()`). | 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` 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. | 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` matches production verdict-for-verdict on all 5 cases. | 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` matches production verdict-for-verdict on all 4 cases. | 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` 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. | 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` — replayed by `packages/pyric/test/storage/rules-oracle-conformance.test.ts`. | 117 |\n| Rules | | `resource` on a create where no object exists: the create-if-absent guard `allow create: if resource == null` | ⚠ | divergence, over-permissive (the evaluator ALLOWs where production DENIEs, the highest-severity direction defined in `docs/storage/reference/conformance-gaps.md`). `oracle:rules-storage-verbs-umbrella-granular`, case \"create allowed when object does not exist (resource == null)\": production DENIES. Production raises a null value error on a `resource` reference in a create where no object exists, and the errored expression denies, so a `create` rule guarded by `resource == null` never allows. The evaluator models `resource` as null on create, evaluates the comparison as documented, and ALLOWS. Live-probed against production before pinning with both an omitted `resource` field and an explicit null, which denied identically, ruling out a capture-harness wire-shape bug. Both sides pinned in `unit:storage/rules-oracle-conformance.test.ts` KNOWN_DIVERGENCES. Same error-value class as the Firestore `resource` identity on a missing document. | 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` 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. | 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` 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:rules.test.ts` JS-semantics guards. | 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` 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. | 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` 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:rules.test.ts` JS-semantics guards. | 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` 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. | 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` 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. | 124 |\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` — 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`; 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` — 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. | 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` — 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. | 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` — 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. | 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` — 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. | 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` — production verdicts captured by deploy-observe-restore, replayed verdict-for-verdict; all 3 cases DENY in production and in the simulator. | 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` — 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. | 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` — 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. | 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` matches production verdict-for-verdict on all 23 cases. | 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` matches production verdict-for-verdict on all 7 cases. | 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` matches production verdict-for-verdict on all 18 cases. | 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` matches production verdict-for-verdict on all 7 cases. | 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` matches production verdict-for-verdict on all 4 cases. | 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` matches production verdict-for-verdict on all 3 cases. | 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` — 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. | 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 simulator</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>Bytes</code>, <code>String.toUtf8()</code>, and <code>hashing.{md5,sha256,crc32,crc32c}()</code> (Item 5.3) in rules</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>oracle:rules-firestore-bytes-toutf8-and-hashing</code> — production Firestore Rules Test API verdicts for corpus scenario \"bytes-toutf8-and-hashing\", replayed verdict-for-verdict against the local rules simulator by <code>unit:rules/oracle-conformance.test.ts</code>. simulator's toUtf8/md5/sha256/crc32/crc32c byte-encoding and reference-hash implementations diverge from production on all 5 scenario cases, so a rule that should DENY on hash mismatch ALLOWs locally — pinned KNOWN_DIVERGENCE</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\">Rules simulator</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>getAfter()</code>/<code>existsAfter()</code> (Item 7) in rules — post-write document identity and existence semantics</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>oracle:rules-firestore-get-after-and-exists-after</code> — production Firestore Rules Test API verdicts for corpus scenario \"get-after-and-exists-after\", replayed verdict-for-verdict against the local rules simulator by <code>unit:rules/oracle-conformance.test.ts</code>. simulator does not model the post-write document identity/existence production compares against on 4 scenario cases (getAfter target identity, existsAfter on create/delete, existsAfter over an unrelated mocked path) — pinned KNOWN_DIVERGENCE</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\">Rules simulator</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>get()</code> of a missing document (RULES-B8) in rules — resource identity (<code>id</code>/<code>__name__</code>) exposure on a mocked/missing get() result</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>oracle:rules-firestore-get-missing-doc</code> — production Firestore Rules Test API verdicts for corpus scenario \"get-missing-doc\", replayed verdict-for-verdict against the local rules simulator by <code>unit:rules/oracle-conformance.test.ts</code>. simulator synthesizes a resource identity (<code>id</code>, <code>__name__</code>) for mocked get() results that production leaves absent, on 2 scenario cases — pinned KNOWN_DIVERGENCE</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\">Rules simulator</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>request.path</code>/<code>request.query</code>/<code>resource.id</code>/<code>resource.__name__</code> globals (Item 6) in rules</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>oracle:rules-firestore-globals-request-path-and-resource-id</code> — 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 <code>unit:rules/oracle-conformance.test.ts</code>. simulator models <code>request.query</code> as an empty map on the empty-query case where production denies the equivalent comparison — pinned KNOWN_DIVERGENCE. Contamination scope is <code>request.query</code> ALONE: the scenario's <code>request.path</code> cases conform, and its <code>resource.id</code>/<code>resource.__name__</code> cases conform because they run on a <code>create</code> (where <code>resource</code> is null pre-write). Resource identity on an EXISTING document is scoped to firestore-rules#183.</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\">Rules simulator</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>int</code>/<code>float</code> division and type distinction (RULES-B5) in rules — truncating int÷int, float division stays float, div-by-zero denies, <code>is int</code>/<code>is float</code> distinct</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>oracle:rules-firestore-int-float-and-division</code> — production Firestore Rules Test API verdicts for corpus scenario \"int-float-and-division\", replayed verdict-for-verdict against the local rules simulator by <code>unit:rules/oracle-conformance.test.ts</code>. simulator narrows a float-valued payload field toward int on the float-payload case, unlike production which preserves the float type — pinned KNOWN_DIVERGENCE</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\">Rules simulator</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>Path</code> wrapper, <code>path()</code> constructor, and <code>Path.bind()</code> (Item 5.4) in rules</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>oracle:rules-firestore-path-constructor-and-bind</code> — production Firestore Rules Test API verdicts for corpus scenario \"path-constructor-and-bind\", replayed verdict-for-verdict against the local rules simulator by <code>unit:rules/oracle-conformance.test.ts</code>. simulator treats <code>path()</code> as idempotent on an already-Path argument where production denies — pinned KNOWN_DIVERGENCE</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\">Rules simulator</code><span class=\"compat-sub\"><span class=\"compat-behavior\">Range-slice <code>[i:j]</code> syntax for <code>List</code> and <code>String</code> (Item 4) in rules</span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\"><code>oracle:rules-firestore-range-slice-list-and-string</code> — production Firestore Rules Test API verdicts for corpus scenario \"range-slice-list-and-string\", replayed verdict-for-verdict against the local rules simulator by <code>unit:rules/oracle-conformance.test.ts</code>. simulator clamps an out-of-bounds slice end to the collection length on both the list and string OOB-slice cases; production denies — pinned KNOWN_DIVERGENCE</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\">Rules</code><span class=\"compat-sub\"><span class=\"compat-behavior\"><code>resource</code> on a create where no object exists: the create-if-absent guard <code>allow create: if resource == null</code></span></span></span></summary>\n<div class=\"compat-evidence\"><div class=\"compat-note\">divergence, over-permissive (the evaluator ALLOWs where production DENIEs, the highest-severity direction defined in <code>docs/storage/reference/conformance-gaps.md</code>). <code>oracle:rules-storage-verbs-umbrella-granular</code>, case \"create allowed when object does not exist (resource == null)\": production DENIES. Production raises a null value error on a <code>resource</code> reference in a create where no object exists, and the errored expression denies, so a <code>create</code> rule guarded by <code>resource == null</code> never allows. The evaluator models <code>resource</code> as null on create, evaluates the comparison as documented, and ALLOWS. Live-probed against production before pinning with both an omitted <code>resource</code> field and an explicit null, which denied identically, ruling out a capture-harness wire-shape bug. Both sides pinned in <code>unit:storage/rules-oracle-conformance.test.ts</code> KNOWN_DIVERGENCES. Same error-value class as the Firestore <code>resource</code> identity on a missing document.</div></div></details>\n</div>\n" }];
|
|
2
|
+
//# sourceMappingURL=conformance-docs.js.map
|