During early access, we design the GCP identity, runner deployment, query
surface, indexes, and agent context with your team.
Architecture
Read-only enforcement
GCP IAM
The runner’s runtime service account receives
roles/datastore.viewer on
the Firestore project. GCP rejects write operations at the API boundary.Runner query surface
Built-in Firestore queries call read methods only. Mutations are a separate,
approval-gated family that is never implied by enabling reads.
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:1
Create or select a runtime service account
Use a dedicated identity for the runner in this environment.
2
Grant viewer access
Grant
roles/datastore.viewer on the one GCP project the runner should
read.3
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.
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:3. Enable Firestore queries
The committed capability config is small:polycore.json
Available capabilities
read
Reads one full document path with an optional field projection and returns
document metadata.
read
Reads up to 100 document paths in one batched call and reports missing paths
separately.
read
Queries a collection path or collection-group id with filters, boolean groups,
ordering, projections, limits, offsets, and cursors.
read
Runs a server-side count over a collection or collection group.
read
Runs up to five server-side
count, sum, or average aggregations.read
Lists top-level collections or subcollections beneath a document path.
read
Samples bounded live documents and reports observed field names, types,
presence, and examples.
Enforced limits
firestore.queryreturns 100 documents by default and accepts a maximum limit of 1,000.- Query offset is capped at 10,000.
firestore.getManyaccepts at most 100 document paths.firestore.aggregateaccepts at most five aggregate expressions.firestore.describesamples 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, anorderBy 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:
Nothing there
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”.
Wrong field name
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.Genuinely no match
Every referenced field exists and the values simply matched nothing. That is
a real answer to report, not a query to broaden.
Collections and collection groups
- Collection path
- Collection group
A path such as
accounts or teams/team_42/members addresses one concrete
collection.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:Schema grounding
Firestore does not enforce a schema, so Polycore supports two complementary ways to ground an agent.Live description
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.Typesync definition
If your application maintains a Typesync schema, the runner can load a
build-generated JSON definition and expose bounded model lookup tools.
Optional Typesync configuration
polycore.json
read
Searches available document models and their Firestore paths.
read
Returns one document model with its fields and field types.
read
Searches reusable Typesync aliases.
read
Returns one complete alias model.
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.
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:- Firestore returns
FAILED_PRECONDITION. - The error includes a ready-made console link.
- The runner marks the failure as terminal and preserves the original message.
- The agent reports the blocker instead of repeatedly scanning parent documents.
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.
Optional: generic mutations
The query family is read-only. One-off document changes are covered by a separate built-in family, off by default:
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):
- 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 afirestore.batchWritelisting them. - Preconditions close the approval window. Pass either
precondition.lastUpdateTime(theupdateTimefrom afirestore.get) orprecondition.exists, never both: Firestore treats them as mutually exclusive. PreferlastUpdateTimeso a write whose document changed while the approval was pending fails instead of overwriting. - Destructive writes have to say so.
firestore.setrequires an explicitmode. 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 tomergeandmergeFields.
{ "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: a narrow input schema, application-level
validation, and PR review of the code that runs.
Production validation
The production runner uses workload identity and has only the intended GCP
project grants.
Document, collection, collection-group, and server-side aggregate reads work
for the collections in scope.
A deliberate write through the generic query surface is unavailable.
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.Timestamp and reference filters use tagged values and return expected data.
Missing indexes fail once and preserve the actionable Firestore error.
Schema context contains no secrets and does not return the entire Typesync
definition to every request.

