pyric/firestore compatibility matrix
100% of the public API supported
182 of 182 public API
Status legend
Conforming — sandbox matches prod, locked by a passing probe
Diverged (documented) — intentional difference with a written reason
getFirestore(target) — initializer
getFirestore(target)getFirestore(ctx) returns a tagged sandbox-target handle (frozen identity)
getFirestore(target)getFirestore(sandbox) returns a tagged sandbox-live handle (per-op identity)
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)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
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)(wrap)
getFirestore(target)Two getFirestore(sandbox) calls share state (same underlying LocalEnvironment)
unit:sandbox-live-identity.test.ts ("two handles share the same sandbox")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")Path constructors — doc / collection / collectionGroup
Path constructorsdoc(db, path) returns a tagged DocumentReference with id / path
Path constructorsdoc(db, 'a', 'b', 'c', 'd') joins variadic path segments
Path constructorscollection(db, path) returns a tagged CollectionReference
Path constructorsdoc(coll, id) appends under a collection ref
Path constructorsdoc(coll) (no id) mints an auto-id DocumentReference
Path constructorscollection(docRef, name) builds a subcollection ref
Path constructorscollectionGroup(db, id) returns a query spanning every collection with that id
unit:sandbox-target.test.ts ("gathers documents across every parent collection")Path constructorsUnknown ref (not produced by this package) → TypeError with "unrecognized reference"
Path constructorsHeld 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")getDoc(ref) — single-doc read
getDoc(ref)Returns DocumentSnapshot with id, exists (method form), data()
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)getDoc(ref)snap.data() returns undefined for missing doc
getDoc(ref)snap.ref is tagged so it routes through targetOf in follow-up ops
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) (Structured evidence: query-rules-enforcement.test.ts)getDoc(ref)Rules-denied modular writes throw a FirebaseError('permission-denied'), matching the production error class and code
Oracle-locked by
packages/conformance/observations/firestore/firestore-rules-denied-error.json: prod throws a FirebaseError (name + constructor name both FirebaseError), .code === 'permission-denied', and the value is an instanceof FirebaseError and Error. oracle:firestore#21 replays the same denied modular write against the sandbox and asserts that complete class shape. (Structured evidence: oracle-conformance.test.ts)getDocs(query) — bulk read
getDocs(query)Returns QuerySnapshot with size, empty, docs (QueryDocumentSnapshot[])
unit:sandbox-target.test.ts, playground:firestore-query (Structured evidence: oracle-conformance.test.ts)getDocs(query)Each snap.docs[i].ref is tagged for follow-up ops
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")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")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-querygetDocs(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)getDocs(query)Empty result for a collection with no docs (size === 0, empty === true)
setDoc(ref, data[, options]) — full write
setDoc(ref, data[, options])No options → replaces the existing document entirely
unit:sandbox-target.test.ts ("setDoc default replaces")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)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.tssetDoc(ref, data[, options])Passing both merge and mergeFields — mergeFields wins on sandbox (matches JS SDK effective behavior)
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.setDoc(ref, data[, options])Converter (via withConverter) runs toFirestore(data) before the write
unit:sandbox-target.test.ts ("withConverter on a DocumentReference round-trips")setDoc(ref, data[, options])Rules-denied setDoc throws a FirebaseError with code permission-denied, matching production
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); oracle:firestore#32 pins the sandbox error shape (Structured evidence: oracle-conformance.test.ts)updateDoc(ref, data) — partial write
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) (Structured evidence: firestore-updatedoc-dotpath-fieldpath)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: …")updateDoc(ref, data)Does NOT run a converter — partial updates don't have a typed home (matches JS SDK)
(documented in
withConverter block)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.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")deleteDoc(ref)
deleteDoc(ref)Removes the document; subsequent getDoc returns exists()===false
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.jsondeleteDoc(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)addDoc(coll, data) — auto-id write
addDoc(coll, data)Returns a tagged DocumentReference with auto-id
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.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")addDoc(coll, data)Converter on the parent collection propagates onto the returned ref
unit:sandbox-target.test.ts ("addDoc through a converted collection")addDoc(coll, data)Auto-id format — production and sandbox mint 20-character IDs from [A-Za-z0-9]
Oracle-locked:
packages/conformance/observations/firestore/firestore-adddoc-autoid-format.json records production's 20-character alphanumeric shape; packages/pyric/test/firestore/oracle-conformance.test.ts generates 25 sandbox IDs and checks both invariants against that observation.withConverter — typed refs
withConverterwithConverter(docRef, converter) returns a shell that runs toFirestore on writes, fromFirestore on reads
withConverterwithConverter(collRef, converter) propagates onto doc(typedColl, id)
withConverterwithConverter(collRef, converter) propagates through query(typedColl, …) + getDocs()
withConverterwithConverter(ref, null) strips the converter, returns the underlying untyped view
withConverterOriginal untyped ref keeps its identity after withConverter(ref, c) (two views, one path)
withConvertersetDoc through a converted ref invokes toFirestore(data)
withConvertergetDoc through a converted ref invokes fromFirestore(snapshot); .data() returns the typed model
withConverterupdateDoc through a converted ref does NOT invoke the converter
(documented constraint; matches JS SDK)
Query construction — query / where / or / and / orderBy / limit
Query constructionquery(coll, where(…), orderBy(…), limit(…)) composes constraints in order
unit:sandbox-target.test.ts, playground:firestore-queryQuery constructionwhere(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.tsQuery constructionExistence + 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)Query constructionor(...) 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)Query constructionand(...) 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})Query constructionNested 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)Query constructionorderBy(field, 'asc'|'desc') — direction parameter
Query constructionCanonical type-order comparison (FS-B3) — orderBy + range filters compare by Firestore's canonical type order (null < bool < number < timestamp < string < bytes < ref < geopoint < array < map), then within-type; numbers sort numerically (not lexicographically), NaN sorts as the smallest number, and range filters (</<=/>/>=) only match same-type values. Pre-FS-B3 the comparator fell back to String(a).localeCompare(String(b)).
unit:firestore/sandbox/query-value-order.test.ts (cross-type ranking, numeric sort, NaN, timestamps, arrays; verified failing pre-fix)Query constructionorderBy excludes missing-field docs (FS-B3) — a doc lacking an orderBy field is omitted from the result (matches prod); pre-fix it was sorted in via compareValues(undefined, …).
unit:firestore/sandbox/query-value-order.test.ts ("excludes the missing-field doc")Query constructionImplicit 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)Query constructionlimit(n) — caps result count
Query constructionlimitToLast(n) returns the trailing n documents in an ordered result; without orderBy, it throws a FirestoreError with production's unimplemented code
Oracle-locked by
packages/conformance/observations/firestore/firestore-limittolast-preconditions.json: production's no-orderBy precondition throws code unimplemented, while ordered trailing-window semantics return ["b"]. oracle:firestore#61 replays both claims. Cursor composition + descending: unit:upstream-query-probes.test.ts; cursor/empty-snapshot preconditions: unit:sandbox-target.test.ts + unit:admin-compat/cursors.test.ts. (Structured evidence: oracle-conformance.test.ts)Query constructionComposite filters AND with other constraints — query(coll, or(...), orderBy(...), limit(...))
Query constructionPassing orderBy / limit into or() / and() → TypeError
Query constructionZero-arg or() / and() → TypeError
Query constructionChained queries re-tag for further constraints (query(query(coll, where), orderBy))
unit:sandbox-target.test.ts ("chained queries are taggable")Query constructionIndex 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 existsCursor pagination — startAt / startAfter / endAt / endBefore
Cursor paginationstartAt(...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)Cursor paginationstartAfter(...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)Cursor paginationendAt(...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)Cursor paginationendBefore(...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)Cursor paginationstartAt(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) (Structured evidence: firestore-startat-snapshot-implicit-name)Cursor paginationendAt(snapshot) overload
unit:sandbox-target.test.ts ("endAt(snapshot) trims to-and-including the anchor")Cursor paginationstartAfter + limit — canonical pagination pattern
Aggregates — getCountFromServer / getAggregateFromServer / count / sum / average
AggregatesgetCountFromServer(query) returns { data: () => ({ count: N }) }
unit:sandbox-target.test.ts; collectionGroup: unit:upstream-write-aggregate-probes.test.tsAggregatesgetCountFromServer honors where filters
AggregatesgetAggregateFromServer(query, spec) returns { data: () => Record<alias, number|null> }
unit:sandbox-target.test.ts; collectionGroup + nested paths: unit:upstream-write-aggregate-probes.test.tsAggregatescount() / 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.tsAggregatesaverage returns null on empty input (matches JS SDK)
AggregatesAggregates 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).onSnapshot(refOrQuery, …) — listeners
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).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.tsonSnapshot(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)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)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.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.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)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)onSnapshot(refOrQuery, …)Snapshot's .ref / .docs[i].ref are tagged so consumer code can pass them to follow-up ops
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")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")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.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-mountrunTransaction(db, fn)
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.tsrunTransaction(db, fn)Identity is frozen at runTransaction start — mid-transaction setUser does NOT re-auth in-flight reads
unit:firestore/sandbox-live-identity.test.ts changes the live session from alice to bob inside the callback; the transaction still reads and commits under alice's captured identity.runTransaction(db, fn)Retry behavior — a read-document conflict reruns the callback against fresh data; maxAttempts bounds persistent contention
Oracle:
packages/conformance/observations/firestore/firestore-transaction-contention-retries.json — two production Web SDK clients force one retry ([0, 40], final 42) and persistent contention with maxAttempts: 2 runs exactly twice then throws failed-precondition. packages/pyric/test/firestore/oracle-conformance.test.ts replays both cases through the sandbox. (Structured evidence: sandbox-target.test.ts)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)writeBatch(db)
writeBatch(db)batch.set / batch.update / batch.delete queue mutations
unit:sandbox-target.test.ts, playground:firestore-batchwriteBatch(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.writeBatch(db)Batch is tagged on construction and remains bound to the sandbox owner that created it
unit:firestore/sandbox-live-identity.test.ts constructs a batch from sandbox A with a reference from sandbox B and proves the commit remains isolated to A.writeBatch(db)Batch identity is frozen at construction (per current implementation)
unit:firestore/sandbox-live-identity.test.ts constructs under alice, switches the live session to bob, and proves commit still succeeds under alice's captured identity.Sentinels — serverTimestamp / increment / arrayUnion / arrayRemove / deleteField / FieldValue / Timestamp
SentinelsserverTimestamp() 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.Sentinelsincrement(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.tsSentinelsarrayUnion(...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.tsSentinelsarrayRemove(...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.tsSentinelsdeleteField() 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)SentinelsTimestamp shape ({seconds, nanoseconds}) is identical between prod and sandbox — round-trips cleanly
SentinelsTimestamp nanos normalization + value API (FS-B12) — fromMillis/fromDate/now derive nanoseconds as floor((ms - seconds1000) 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)SentinelsUnified 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)SentinelsFieldValue re-exported from pyric-admin (alias of ChainFieldValue)
type-only smoke
SentinelsSentinel 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)Scalar types — Bytes / GeoPoint / FieldPath / documentId
Scalar typesThe 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.tsScalar typesdocumentId() 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 FieldPathScalar typesFieldPath (nested) works in queries against sandbox
Scalar typesBytes 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.Scalar typesGeoPoint 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.Scalar typesVector 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.Equality helpers — refEqual / queryEqual / snapshotEqual
Equality helpersrefEqual(a, b) — true when paths match under the same target
Equality helpersrefEqual 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")Equality helpersrefEqual is false for refs at different paths
Equality helpersrefEqual(sandboxRef, foreignRef) throws TypeError — references not created by this sandbox mirror are unrecognized
unit:sandbox-target.test.ts (foreign refs throw unrecognized-reference TypeError)Equality helpersqueryEqual(a, b) structurally compares collection and collection-group scope, filter/order/limit/cursor constraint structure, converter identity, and construction-time Firestore value snapshots for both equality and execution without re-observing caller objects. Snapshots cover maps/arrays, scalar objects, references (including raw and converted addDoc() results), and vectors; normalize Date to Timestamp; preserve the distinction between -0 and 0; compare snapshot and explicit bounds for all four cursor overloads by value without invoking snapshot converters, including across live-target rebuilds; reject undefined, bigint, nested arrays for ordinary and array-membership operands, and recursively nested cross-database references during query construction; and preserve Firebase's nested-array allowance for in/not-in candidate lists.
Oracle-locked by
packages/conformance/observations/firestore/firestore-queryequal-structural.json: collection and collection-group scope, order sequence/direction, limits, composite filters, cursor values/inclusivity, converters, and Firestore operands all distinguish equal from changed queries as production does. The capture executes mutable maps, Timestamp, Bytes, GeoPoint, DocumentReference (including raw and converted addDoc() results), and Vector queries; proves Bytes/Vector source-array mutation cannot change existing or new queries; proves all four snapshot cursor overloads compare with explicit bounds and execute twice without invoking a consumer converter; and locks recursive foreign-reference rejection, Date/Timestamp normalization, and -0 handling. firestore-query-nested-array-validation.json separately pins the production SDK's client-side rejection of nested arrays without implying a cloud round trip. The replay forces live-target identity rebuilds for every snapshot cursor overload. oracle:firestore#116 replays every claimed fact. (Structured evidence: oracle-conformance.test.ts, equality.test.ts)Equality helperssnapshotEqual(a, b) follows production snapshot state. A query's first one-shot read differs from the next identical read, then repeated settled reads (including an independently built equivalent query) compare structurally until query state changes and settles again. Listener snapshots compare query, documents, changes, and metadata. Document snapshots compare path, existence, Firestore-typed data, converter identity, and snapshot kind, so two gets of the same document are equal while a query-child snapshot and a direct get are distinct; scalar-shaped plain maps remain distinct from Timestamp, reference, GeoPoint, and Vector values.
Oracle-locked by
packages/conformance/observations/firestore/firestore-snapshotequal-structural.json: sequential and equivalent-query reads prove the initial-to-settled transition; a changed result restarts that transition; listener sensitivity cases vary query, documents/change history, and metadata-only state; direct document cases vary path, existence, data, converter, and snapshot kind; and four collision probes distinguish scalar-shaped maps from their Timestamp, reference, GeoPoint, and Vector counterparts. oracle:firestore#117 replays every reachable fact. JSON reconstruction remains raw characterization only because Pyric does not expose that separate API. (Structured evidence: oracle-conformance.test.ts, equality.test.ts)Equality helpersCross-flavor refEqual via QuerySnapshot.docs[i].ref works
unit:sandbox-live-identity.test.ts ("cross-flavor refEqual via QuerySnapshot doc refs")connectFirestoreEmulator
connectFirestoreEmulatorNo-op on sandbox-target handles (the sandbox already IS a local emulator)
connectFirestoreEmulatorProduction 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)connectFirestoreEmulatorThe sandbox mirror accepts Firebase's mockUserToken option shape as an inert compatibility argument; production uses Firebase's untouched implementation
type-only smoke
Offline / persistence / network family
Offline / persistence / network family (continued)
enableIndexedDbPersistenceResolves before Firestore starts and rejects with failed-precondition after any operation has started the service
Real Chromium:
firestore-browser-lifecycle.json; replayed through the public modular API by oracle-conformance.test.ts and focused lifecycle coverage. (Structured evidence: persistence-network.test.ts)enableMultiTabIndexedDbPersistenceEnables multi-client shared persistence before first use, permits two clients to opt in, and rejects initialization after a client has started with failed-precondition
Real Chromium:
firestore-browser-lifecycle.json exercises fresh, after-use, and two-client initialization; replayed through public APIs by oracle-conformance.test.ts and focused persistence tests. (Structured evidence: persistence-network.test.ts)clearIndexedDbPersistenceMaps to Sandbox.clearPersistence() — actually wipes the persisted blob (honest, not a no-op); already a no-op when persistence was never enabled
enableNetwork / disableNetworkDisabling network exposes local mutations with cache/pending-write metadata while holding write acknowledgement until network is re-enabled
Real Chromium:
firestore-browser-lifecycle.json; replayed through the public modular API by oracle-conformance.test.ts and focused network coverage. (Structured evidence: persistence-network.test.ts)waitForPendingWritesWaits for outstanding offline write acknowledgements and resolves when network re-enable drains them
Real Chromium:
firestore-browser-lifecycle.json; replayed through the public modular API by oracle-conformance.test.ts and focused pending-write coverage. (Structured evidence: persistence-network.test.ts)terminateTerminates only the selected Firestore service instance: held refs reject with failed-precondition, owned listeners stop, and sibling Firestore or Auth services remain usable
Real Chromium:
packages/conformance/observations/firestore/firestore-browser-lifecycle.json records failed-precondition on the terminated instance while Auth and a sibling Firestore instance continue. Replayed by oracle-conformance.test.ts; focused ownership coverage in terminate.test.ts.Tier-1 cache-init + get-from-* family
Tier-1 cache-init + get-from-* family (continued)
initializeFirestoreDelegates 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
persistentLocalCache / memoryLocalCache / persistentSingleTabManager / persistentMultipleTabManager / memoryEagerGarbageCollector / memoryLruGarbageCollectorConfig 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
getDocFromServer / getDocsFromServerDelegates 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
getDocFromCache / getDocsFromCacheExplicit cache reads distinguish cold and warm state: cold document reads reject with unavailable, cold queries return empty, and server/default reads populate subsequent cache reads
Real Chromium:
firestore-browser-lifecycle.json captures cold and warm document and query cache behavior; oracle-conformance.test.ts replays it through public APIs. (Structured evidence: tier1-cache-init-align.test.ts)setLogLevelAccepted 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
onSnapshotsInSyncEmits an initial in-sync signal, batches another signal after snapshot listener delivery, and stops after unsubscribe
Real Chromium:
firestore-browser-lifecycle.json records initial sync, write snapshot, then sync; replayed by oracle-conformance.test.ts and focused listener tests. (Structured evidence: tier1-cache-init-align.test.ts)Runtime ES Classes / Constructor TokensExports ES classes and constructor tokens matching structural type handles for instanceof checks and prototype inheritance
unit:firestore/tier1-cache-init-align.test.ts (Structured evidence: runtime-classes.test.ts)PersistentCacheIndexManager / Index auto-creationConfig tokens and index manager controller accepted as honest inert runtime tokens without failing execution
unit:firestore/tier1-cache-init-align.test.ts (Structured evidence: persistence-tokens.test.ts)loadBundle / namedQueryDecodes string/ArrayBuffer bundle payloads into local sandbox store and registers named queries
unit:firestore/tier1-cache-init-align.test.ts (Structured evidence: bundles.test.ts)documentSnapshotFromJSON / querySnapshotFromJSON / onSnapshotResumeDeserializes JSON snapshot objects into active Pyric snapshot wrappers and attaches resume listener
unit:firestore/tier1-cache-init-align.test.ts (Structured evidence: ssr-snapshots.test.ts)aggregateFieldEqual / aggregateQuerySnapshotEqualCompares aggregate descriptors and snapshot data for value equality
unit:firestore/tier1-cache-init-align.test.ts (Structured evidence: runtime-classes.test.ts)ensureFirestoreConfigured / executeWriteValidates sandbox initialization and executes write callback within mutation evaluation pipeline
unit:firestore/tier1-cache-init-align.test.ts (Structured evidence: writes-plumbing.test.ts)Rules engine (via setRules from pyric/sandbox/firestore)
Rules-engine behavior is technically pyric-admin’s LocalEnvironment,
but it’s the most-tested surface for divergence — request.auth,
cross-doc reads via get(), data validation. These rows pin the
shape consumer code depends on.
request.auth.uid reads through to sandbox.currentUser?.uid on sandbox-live
playground:auth-anonymous, playground:rules-cross-doc-getrequest.auth == null when sandbox.currentUser is null (anonymous path)
unit:sandbox-live-identity.test.ts ("anonymous fallback")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)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)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)Custom claims in request.auth.token.<claim>
playground:rules-custom-claimsTri-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)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)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)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)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 enginesupdate 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)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)Strict boolean control flow matches production: non-booleans in &&, ||, or a ternary condition error and deny. Firestore double payloads retain float identity in the simulator (fractional JSON numbers are revived directly; { __type:'float', value } preserves an explicitly tagged double). On create, resource == null denies while request.resource exposes the incoming document.
Production behavior is recorded by Firestore Rules rows
firestore-rules#188, firestore-rules#167, and firestore-rules#166. Direct evaluator/handler tests guard strict operands, fractional-number revival, the explicit float tag, and create-time resource verdicts on the SDK-facing simulator path. (Structured evidence: evaluator.test.ts, handler.test.ts)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)
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 24cCurrent gaps
Documented divergences
Known differences between Pyric and production Firebase. Each remains tracked as a non-conforming row.
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)Query constructionIndex 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 existsAggregatesAggregates 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).