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

# Firestore

> How a Polycore runner reads Firestore through GCP IAM, bounded query tools, and optional schema grounding.

The Firestore integration gives Polycore a native document-database read
surface: point reads, collection and collection-group queries, server-side
aggregations, collection discovery, and live shape inference.

The runner authenticates with Application Default Credentials inside your GCP
environment. Polycore cloud does not receive a service-account key.

<Info>
  During early access, we design the GCP identity, runner deployment, query
  surface, indexes, and agent context with your team.
</Info>

## Architecture

```mermaid theme={"system"}
flowchart LR
  Caller["Agent or dashboard"]
  CP["Polycore control plane"]
  Runner["Runner on GCP"]
  IAM["Runtime service account"]
  DB[("Firestore")]

  Caller --> CP
  CP -- "Signed read dispatch" --> Runner
  Runner --> IAM
  IAM --> DB
```

<Warning>
  The Firebase Admin SDK bypasses Firestore Security Rules. Read-only safety
  must come from the runner's GCP IAM role and the runner's read-only query
  implementation, not client-side Firestore rules.
</Warning>

## Read-only enforcement

<Columns cols={2}>
  <Card title="GCP IAM" icon="badge-check">
    The runner's runtime service account receives `roles/datastore.viewer` on
    the Firestore project. GCP rejects write operations at the API boundary.
  </Card>

  <Card title="Runner query surface" icon="shield">
    Built-in Firestore queries call read methods only. Mutations are a separate,
    approval-gated family that is never implied by enabling reads.
  </Card>
</Columns>

Both layers stay intact when you enable
[generic mutations](#optional-generic-mutations) with a separate write
credential. If you enable mutations as `true` (ADC), write ability is whatever
the runtime SA already has: the usual pattern when customer actions already
write via the same identity.

Firestore in Native mode uses Datastore IAM roles, which is why the predefined
viewer role is named `roles/datastore.viewer`.

## 1. Bind the runtime identity

The normal hosted pattern is keyless:

<Steps>
  <Step title="Create or select a runtime service account">
    Use a dedicated identity for the runner in this environment.
  </Step>

  <Step title="Grant viewer access">
    Grant `roles/datastore.viewer` on the one GCP project the runner should
    read.
  </Step>

  <Step title="Attach it to the runner host">
    The VM or container receives the service account through GCP workload
    identity. The Admin SDK resolves it through Application Default Credentials.
  </Step>
</Steps>

For local evaluation only, Google supports
`GOOGLE_APPLICATION_CREDENTIALS` pointing to a key file. Production runner
deployments should prefer workload identity.

## 2. Identify the Firestore project

The runner needs the Firestore project id in its runtime environment:

<CodeGroup>
  ```text Explicit project theme={"system"}
  FIRESTORE_PROJECT_ID=alpha-prod
  ```

  ```text GCP runtime convention theme={"system"}
  GOOGLE_CLOUD_PROJECT=alpha-prod
  ```
</CodeGroup>

This coordinate is not a credential. The runtime service account still decides
whether the runner can read that project.

## 3. Enable Firestore queries

The committed capability config is small:

```json polycore.json theme={"system"}
{
  "integrations": {
    "firebase": { "firestore": {} }
  },
  "context": { "file": "./context/product.md" }
}
```

## Available capabilities

<ResponseField name="firestore.get" type="read">
  Reads one full document path with an optional field projection and returns
  document metadata.
</ResponseField>

<ResponseField name="firestore.getMany" type="read">
  Reads up to 100 document paths in one batched call and reports missing paths
  separately.
</ResponseField>

<ResponseField name="firestore.query" type="read">
  Queries a collection path or collection-group id with filters, boolean groups,
  ordering, projections, limits, offsets, and cursors.
</ResponseField>

<ResponseField name="firestore.count" type="read">
  Runs a server-side count over a collection or collection group.
</ResponseField>

<ResponseField name="firestore.aggregate" type="read">
  Runs up to five server-side `count`, `sum`, or `average` aggregations.
</ResponseField>

<ResponseField name="firestore.listCollections" type="read">
  Lists top-level collections or subcollections beneath a document path.
</ResponseField>

<ResponseField name="firestore.describe" type="read">
  Samples bounded live documents and reports observed field names, types,
  presence, and examples.
</ResponseField>

### Enforced limits

* `firestore.query` returns 100 documents by default and accepts a maximum limit
  of 1,000.
* Query offset is capped at 10,000.
* `firestore.getMany` accepts at most 100 document paths.
* `firestore.aggregate` accepts at most five aggregate expressions.
* `firestore.describe` samples 50 documents by default and at most 100.

## Empty results explain themselves

An empty result is the most ambiguous answer a document database can give. A
wrong collection path, a misspelled field, a filter value that never matches, an
`orderBy` on a field the documents do not carry, and a genuinely empty
collection all come back as `[]`. A caller cannot tell them apart, so it guesses
again — and a question that had a one-line answer turns into a long, expensive
search.

When `firestore.query` or `firestore.describe` finds nothing, the runner spends
one extra query to establish *why* and returns a `diagnosis` alongside the empty
result:

```json theme={"system"}
{
  "docs": [],
  "hasMore": false,
  "diagnosis": {
    "source": "users/6R97OiZ.../systemLogs",
    "sourceHasDocuments": false,
    "parentExists": false,
    "conclusion": "users/6R97OiZ.../systemLogs holds no documents, and its parent document \"users/6R97OiZ...\" does not exist either. The parent id is the thing to question here …"
  }
}
```

It distinguishes three situations:

<Columns cols={3}>
  <Card title="Nothing there" icon="folder-open">
    The collection or collection group holds no documents at all, so no filter
    could have matched. For a nested path it also reports whether the parent
    document exists, which separates "wrong id" from "no data yet".
  </Card>

  <Card title="Wrong field name" icon="spell-check">
    The collection has data, but a field named in `where` or `orderBy` appears
    in none of the sampled documents. The field name is the bug, not the data.
  </Card>

  <Card title="Genuinely no match" icon="circle-check">
    Every referenced field exists and the values simply matched nothing. That is
    a real answer to report, not a query to broaden.
  </Card>
</Columns>

<Tip>
  The second case catches a Firestore behaviour that surprises almost everyone:
  ordering by a field a document does not have **excludes that document
  entirely**. Sorting an optional field returns nothing while every part of the
  query looks correct.
</Tip>

The diagnosis costs one sampled read and only ever runs on an empty result. If
it fails — a permission boundary, say — the read still returns normally without
it: an explanation is a courtesy on top of a correct answer, never a way for a
successful read to fail.

## Collections and collection groups

<Tabs>
  <Tab title="Collection path">
    A path such as `accounts` or `teams/team_42/members` addresses one concrete
    collection.
  </Tab>

  <Tab title="Collection group">
    A bare id such as `orders` addresses every same-named subcollection at any
    depth. This is useful when parent document ids are not known in advance.
  </Tab>
</Tabs>

For totals, `firestore.count` and `firestore.aggregate` keep computation in
Firestore. The agent receives the aggregate, not every matching document.

## Firestore values across JSON

Firestore types that JSON cannot represent directly use tagged values:

<CodeGroup>
  ```json Timestamp theme={"system"}
  {
    "$timestamp": "2026-07-25T08:00:00.000Z"
  }
  ```

  ```json Document reference theme={"system"}
  {
    "$ref": "accounts/account_42"
  }
  ```

  ```json Geopoint theme={"system"}
  {
    "$geopoint": {
      "latitude": 40.4093,
      "longitude": 49.8671
    }
  }
  ```

  ```json Bytes theme={"system"}
  {
    "$bytes": "<base64>"
  }
  ```
</CodeGroup>

A Firestore Timestamp does not compare equal to a plain ISO string. Tagged
values keep filters and returned data faithful to the datastore's native types.

## Schema grounding

Firestore does not enforce a schema, so Polycore supports two complementary
ways to ground an agent.

<Columns cols={2}>
  <Card title="Live description" icon="scan-search">
    `firestore.describe` samples a bounded set of documents and reports observed
    fields, types, and presence. It reflects live drift but is not a formal
    schema.
  </Card>

  <Card title="Typesync definition" icon="file-json">
    If your application maintains a Typesync schema, the runner can load a
    build-generated JSON definition and expose bounded model lookup tools.
  </Card>
</Columns>

### Optional Typesync configuration

```json polycore.json theme={"system"}
{
  "integrations": {
    "firebase": {
      "firestore": { "typesync": "./schema/typesync-definition.json" }
    }
  },
  "context": { "file": "./context/product.md" }
}
```

The generated definition is baked into the runner image and read locally. The
full schema is not appended to every agent prompt. Instead, the runner exposes
list and get tools for document models and reusable aliases.

<ResponseField name="firestore.typesync.listDocumentModels" type="read">
  Searches available document models and their Firestore paths.
</ResponseField>

<ResponseField name="firestore.typesync.getDocumentModel" type="read">
  Returns one document model with its fields and field types.
</ResponseField>

<ResponseField name="firestore.typesync.listAliasModels" type="read">
  Searches reusable Typesync aliases.
</ResponseField>

<ResponseField name="firestore.typesync.getAliasModel" type="read">
  Returns one complete alias model.
</ResponseField>

<Note>
  Keep the canonical Typesync source and generation step in the application or
  schema repository. Copy only the generated JSON artifact into the runner build
  context.
</Note>

## Index requirements

Firestore automatically creates many collection-scoped indexes. Filtered or
ordered **collection-group** queries may additionally need a field indexed at
collection-group scope.

When an index is missing:

1. Firestore returns `FAILED_PRECONDITION`.
2. The error includes a ready-made console link.
3. The runner marks the failure as terminal and preserves the original message.
4. The agent reports the blocker instead of repeatedly scanning parent
   documents.

<Tip>
  Commit required indexes in `firestore.indexes.json` and deploy them through
  your normal Firebase workflow. A console-created index exists only in the
  selected project and can drift across environments.
</Tip>

## Environment isolation

Deploy one runner per Polycore environment. Each process receives:

* One Polycore project slug.
* One enrolled environment.
* One GCP project id.
* One runtime service account scoped to that GCP project.

This prevents a single agent argument from switching a runner between
production and non-production identities.

## Optional: generic mutations

The query family is read-only. One-off document changes are covered by a
separate built-in family, off by default:

| Capability             | What it does                                                    |
| ---------------------- | --------------------------------------------------------------- |
| `firestore.create`     | Create one document; fails if the id already exists             |
| `firestore.set`        | Write one document: merge into it or replace it outright        |
| `firestore.update`     | Change named fields of an existing document                     |
| `firestore.delete`     | Delete one document, optionally including its subcollections    |
| `firestore.batchWrite` | Commit up to 100 create/set/update/delete operations atomically |

Every one carries the `write` hazard, so the control plane holds the request
until a human approves that exact call with those exact arguments. Nothing here
can run unattended.

Enable with an explicit grant. Prefer `true` when the runtime service account
already writes (the usual CIP/SAP shape: same Application Default Credentials
customer actions use):

```json theme={"system"}
{
  "integrations": {
    "firebase": {
      "firestore": { "write": "ambient" }
    }
  }
}
```

To keep the runtime SA viewer-only and inject a separate write identity, name
the env var that holds a write-capable service-account key (only the name is
committed):

```json theme={"system"}
{
  "integrations": {
    "firebase": {
      "firestore": { "write": { "keyEnv": "FIRESTORE_WRITE_CREDENTIALS" } }
    }
  }
}
```

<Warning>
  `"write": "ambient"` uses the ambient ADC identity. That matches runners
  whose actions already write via ADC. Use `{ "keyEnv": "…" }` when you want
  Layer 1 IAM (`roles/datastore.viewer` on the runtime SA) to stay intact for
  every read; a missing or malformed key then fails at boot rather than
  advertising a mutation the runner cannot perform.
</Warning>

Two properties keep an approval meaningful:

* **Targets are enumerated, never filtered.** There is no delete-by-query: that
  set would only be resolved after approval, so nobody could review what they
  approved. To change many documents, read the paths with `firestore.query`
  (a Lane 1 read, no approval) and propose a `firestore.batchWrite` listing them.
* **Preconditions close the approval window.** Pass either
  `precondition.lastUpdateTime` (the `updateTime` from a `firestore.get`) or
  `precondition.exists`, never both: Firestore treats them as mutually
  exclusive. Prefer `lastUpdateTime` so a write whose document changed while
  the approval was pending fails instead of overwriting.
* **Destructive writes have to say so.** `firestore.set` requires an explicit
  `mode`. Firestore's own default replaces the document, so a three-field payload
  can quietly drop everything else; `"mode": "replace"` puts that in the
  arguments the approver reads, next to `merge` and `mergeFields`.

Deleting a document does not delete its subcollections. The usual call is just
`{ "path": "…" }`; nested documents stay put, which is Firestore's own default
and fine for a normal delete. Pass `recursive: true` with `maxDocuments` only
when you deliberately need to wipe the subtree: `teams/acme` looks the same in
an approval card whether three documents or thirty thousand hang off it, so the
runner counts first and refuses, having deleted nothing, if the bound the
approver saw is exceeded.

A multi-step or reusable write workflow is still better as a named
[action](/authoring/actions): a narrow input schema, application-level
validation, and PR review of the code that runs.

## Production validation

<Check>
  The production runner uses workload identity and has only the intended GCP
  project grants.
</Check>

<Check>
  Document, collection, collection-group, and server-side aggregate reads work
  for the collections in scope.
</Check>

<Check>
  A deliberate write through the generic *query* surface is unavailable.
</Check>

<Check>
  With mutations enabled, a `firestore.delete` request produces an approval card
  and writes nothing until it is approved; with mutations disabled, the tool is
  not advertised at all.
</Check>

<Check>
  Timestamp and reference filters use tagged values and return expected data.
</Check>

<Check>
  Missing indexes fail once and preserve the actionable Firestore error.
</Check>

<Check>
  Schema context contains no secrets and does not return the entire Typesync
  definition to every request.
</Check>
