Skip to content
Reference

API reference

Generated from the platform contract, version 1.26.0. The OpenAPI document is the source of truth — if this page and the spec ever disagree, the spec is right and this page is a bug.

Authentication

Every operation requires a Firebase ID token in the Authorization: Bearer header. Tenant scoping is enforced server-side: a token issued for one tenant cannot read or write another's paths, regardless of the URL.

Operations

health

Liveness and readiness.

GET/v1/health

Service health

Liveness and readiness probe. Unauthenticated so that Cloud Run and the checkServerHealth Firebase function can both poll it. The function mirrors the response into tenants/{tenantId}/server_status/health.

analysis

Shelf-image recognition results — the server-authoritative write path.

POST/v1/tenants/{tenantId}/tasks/{taskId}/images/{imageId}:analyze

Request server-side analysis of a captured image

Enqueues recognition for an image that has already been uploaded to Cloud Storage. Called by the onImageUploaded Storage trigger, which reads the target document path from the object's customMetadata.image_object_reference.

Asynchronous: returns 202 immediately with a job handle. When the job completes, the vision service writes the result through PUT .../analysis exactly as an on-device client would, so there is a single canonical write path regardless of where inference ran.

Idempotent per (task, image, model_version) — an image is identified by its path, and image ids are only unique within a task. Re-requesting an analysis that is already pending or running returns the existing job. A failed job is not reopened — a new job is minted with attempt + 1, keeping the original trace_id.

Jobs persist at tenants/{tenantId}/analysis_jobs/{jobId} so the poll endpoint and the idempotency rule survive instance churn.

firebase_storage_path is either bucket-relative (the tenant's bucket is implied) or a full gs://bucket/path. Either way the object's tenant segment and, when present, the bucket must belong to tenantId — a mismatch is 400, never a cross-tenant read.

trace_id precedence when sources disagree: the image document's value, then the request body, then the traceparent header, then mint (logging trace.orphaned). The device mints the origin; the document is where it lives.

v1.21.0 — a tenant member may ask too. Until now only service identities could call this (the Storage trigger and the retry job), so a photo whose analysis never arrived stayed that way until somebody with a console noticed. A signed-in member of tenantId may request analysis of an image that has no canonical analysis — state is uploaded or error — which is what a review client's "Retry analysis" does. For a member:

  • The caller is the visit's own capturer (Task.user_id) or a tenant admin — the rule corrections follow. Anyone else is 403.
  • model_name and model_version must be omitted (400): which model runs is the tenant's configuration, not the caller's choice.
  • Any other state answers 409 with details.reason: not_uploaded for started (there are no bytes to read yet), already_analysed for finished and warning (a result exists; replacing it is not a retry).
  • At most five attempts per image and model version are minted for members; the sixth answers 409 with details.reason: retry_limit. A photo that failed five times needs a person with the logs, not another try.

Service identities are bound by none of these.

v1.21.0 — abandoned jobs. A job still pending or running fifteen minutes after it was created is not in flight. The next request for the same image marks it failed (unavailable, "job abandoned") and mints its successor with attempt + 1 on the same trace. Without this the idempotency rule answers every retry with a job nothing will ever finish. A late delivery of the abandoned job finds it terminal and does nothing.

Request body: AnalysisRequest

GET/v1/tenants/{tenantId}/analysis-jobs/{jobId}

Poll an analysis job

GET/v1/tenants/{tenantId}/tasks/{taskId}/images/{imageId}/analysis

Read the canonical analysis for an image

Convenience read for non-Firebase consumers (external integrations, reporting). First-party apps should read Firestore directly so they get realtime updates.

v1.25. A tenant member reads an analysis only for a visit they can see (admin, capturer, or sales_node_id in visible_node_ids when the tenant is scoped — firestore.rules isInVisibleSubtree). The same holds for :analyze, PUT …/analysis, the corrections route, the job poll and the object tags route; a visit the caller cannot see is a 404, as if it did not exist, so no route says more than Firestore would.

PUT/v1/tenants/{tenantId}/tasks/{taskId}/images/{imageId}/analysis

Submit an analysis result (canonical write)

The single canonical write for image_analysis. Used by both inference sources:

  • source: server — the vision service, after a :analyze job.
  • source: device — on-device inference from the mobile capture client, submitted when the device next syncs.

Reconciliation. The server is authoritative. When a submission arrives for an image that already has an analysis, the server applies the precedence rules in docs/canonical-model.md#reconciliation: human > higher model_version > server over device > later analyzed_at > the incumbent. The response returns the analysis that is now canonical, which may not be the one submitted — check accepted. Every submission, winner or loser, is stored under .../images/{imageId}/analyses/{analysisId} with superseded set; the image document's image_analysis is a copy of the winner.

source is validated against the caller. A service identity must submit server; a Firebase user token must submit device; human is never accepted here (only the corrections endpoint produces it). Otherwise 400 invalid_argument. Client-supplied superseded, detected_* and pixel x1..y2 are ignored and recomputed.

On acceptance the same transaction sets image.state (finished, or warning when there are zero objects, when more than the tenant's configured share of product objects have class_id: null, or when the detector hit max_det) and updates Task.analyzed_image_count and Task.state by read-modify-write on the task document.

Idempotent: the server hashes the submission with server-derived fields stripped and keys sorted; a resubmission with the same hash returns the existing result without a write. There is no 409 on this operation — losing reconciliation is 200 with accepted: false.

Request body: AnalysisSubmission

corrections

Human corrections to analysis output.

POST/v1/tenants/{tenantId}/tasks/{taskId}/images/{imageId}/analysis/corrections

Apply human corrections to an analysis

Records operator corrections made in the web review UI (the Visits detection editor) or the mobile review screen — added, removed, moved and re-labelled objects.

Corrections are applied to the canonical analysis, which is then marked source: human, pinning it against any later machine submission. The correction events are also retained as a training-data signal, so send the delta, not the whole re-serialised analysis.

Rules the server applies:

  • object_index values refer to the array as the operator saw it. All ops are validated against that array first, then applied in one pass — a remove never shifts the indices of later ops.
  • relabel sets class_id, class_name and detection_class and nulls class_score and size: a human label carries no model confidence.
  • remove deletes the object from the canonical array. The pre-correction analysis survives in analyses/, so nothing is lost.
  • The result is a new analysis with source: human, a fresh analyzed_at, model_name/model_version inherited, and corrected: true on every touched or added object. It becomes canonical unconditionally and moves warning → finished.
  • base_analyzed_at null means "apply to whatever is current" — the stale check is skipped.
  • No canonical analysis yet → 404.
  • The visit is locked (Task.locked, v1.17) → 403 permission_denied: unlocking is a tenant admin's decision.
  • corrected_by must equal the caller's uid unless the caller is tenant_admin or above. A contributor may correct images in their own tasks; tenant_admin and above, any.
  • The batch itself is stored verbatim at .../images/{imageId}/corrections/{correctionId}.

Request body: CorrectionBatch

hierarchies

The three business dimensions. Parent-child, unlimited depth; level and path are server-derived, so adding a level is adding a node.

GET/v1/tenants/{tenantId}/hierarchies/{kind}/nodes

Browse one level of a hierarchy

Children of parent (or the roots when parent is omitted), one level at a time. depth may fetch more, bounded, for an editor that wants a subtree; it is not a way to page an entire hierarchy.

PUT/v1/tenants/{tenantId}/hierarchies/{kind}/nodes/{nodeId}

Create, rename or re-parent a node

level and path are ignored on input and recomputed from parent_id — for this node and, when the parent changes, for its whole subtree in the same transaction. A re-parent also queues a rewrite of the denormalised path columns on the fact rows beneath it.

A cycle (parent_id inside this node's own subtree) is 400.

Request body: HierarchyNodeInput

POST/v1/tenants/{tenantId}/hierarchies/{kind}:import

Bulk-load a hierarchy from the client's own codes

The shape every ERP can export: a row per node carrying its own code and its parent's code. Codes, not ids — the client's coding is the join key and ids are ours.

dry_run returns the same diff without writing. An admin who cannot see what an import will do will not run it, so the diff is the feature.

Request body: HierarchyImportRequest

reports

The OLAP matrix, its KPI registry and the Excel export. Every query is scoped to what the caller may see — the subtree under their sales node plus the customer nodes granted to them — so the hierarchy is the row-level security model, not just a grouping.

POST/v1/tenants/{tenantId}/reports/matrix

One expansion level of the OLAP matrix

rows is an ordered list of hierarchies — any order, any subset; that is the "combine freely" requirement. The response carries the children of each expanded node with their KPI cells, and nothing deeper. Expand-all is the client walking levels, not one enormous query.

Every query is scoped to the caller's visible subtrees before it runs. A contributor sees the subtree under their own sales node; grants widen that. The scoping is applied as a path prefix, so it costs nothing and cannot be bypassed by a crafted filter.

Results are cached per (tenant, body hash, visibility scope) for a short TTL — the same click by the same person is not re-billed.

Request body: MatrixRequest

POST/v1/tenants/{tenantId}/reports/matrix:export

The same query as an Excel workbook, tree intact

Identical body to queryMatrix, plus how deep to expand. The workbook uses Excel's own outline levels, so the tree collapses and expands in Excel exactly as it does in the browser — one indented column per hierarchy, KPI columns beside them.

Streamed: a full-depth export over a large tenant is not a JSON response held in memory.

Request body: MatrixExportRequest

GET/v1/tenants/{tenantId}/kpis

The KPI registry

What this tenant can ask for. The registry is configuration in the reporting service, not code, so a new KPI is a config change — and a ratio declares its numerator and denominator so it re-aggregates correctly at every level instead of averaging averages.

invitations

Joining a tenant. Replaces access codes: an invitation is bound to one identity, consumed once, expires, and is redeemed by the server — the client never writes its own role.

GET/v1/tenants/{tenantId}/invitations

List invitations

POST/v1/tenants/{tenantId}/invitations

Invite someone to this tenant

Requires tenant_admin or above, and the invited role may not exceed the inviter's own. A pending invitation for the same identity is replaced rather than duplicated, so re-inviting someone who lost the email does not leave two live tokens.

For channel: manual the response carries the acceptance link once and never again — the server keeps only a hash of the token.

Request body: InvitationInput

POST/v1/tenants/{tenantId}/invitations:batch
DELETE/v1/tenants/{tenantId}/invitations/{invitationId}

Revoke a pending invitation

Marks it revoked; the token stops working immediately. An accepted invitation cannot be revoked — remove the user instead, which is a different act with a different audit trail.

GET/v1/me

The caller's own profile

v1.19.0. Who the bearer token belongs to, as the platform sees them: the User document with the tenant role and labeling_role. For a signed-in account with no profile yet (an invitee) the answer is 404, which is the same thing the accept flow relies on.

Exists so that a service that verifies Firebase ID tokens on its own (ShelfPro Labeling) can learn the caller's roles without credentials of its own: it forwards the user's token and gets the user's profile.

PUT/v1/users/{uid}/labeling-role

Grant or revoke access to ShelfPro Labeling

v1.20.0. Sets User.labeling_role for one account. Platform staff only: labelling is ShelfPro's own work, not a tenant's, so no tenant role may hand it out, and nobody sets their own. Server-owned like every other role write — a client never writes a role field.

POST/v1/invitations/{invitationId}:accept

Redeem an invitation

Deliberately not tenant-scoped: the invitee does not know the tenant until they are in it, and requiring them to name it would leak which tenants exist.

The caller must already be authenticated — they sign in or create an account, then present the token. The server checks the token hash, the expiry, that the invitation is still pending, and that the authenticated identity matches the invited one; only then does it write users/{uid} with the tenant and role and mark the invitation accepted, in one transaction.

The client never writes its own role. That is the whole point of the redesign, and it is why a forwarded link is worthless: the account that redeems it must be the identity that was invited.

Idempotent for the same account: redeeming an invitation you already accepted returns the same user rather than an error.

Request body: InvitationAcceptInput

visits

A visit (Task) as a whole. Visits and their images are created by clients straight in Firestore; what is here is the one thing a client cannot do safely on its own — remove a visit together with everything that hangs off it.

DELETE/v1/tenants/{tenantId}/tasks/{taskId}

Delete a visit that is not finished

Removes a visit everywhere: every image document with its analyses/, corrections/ and events/, the images' Storage objects (capture, thumbnail, panorama frames, composite), their rows in the object index, and the task document. The visit's fact rows are marked superseded, so it leaves every report as well.

Server-owned because a client delete cannot be made safe. Rules can let a client remove one document; they cannot make it remove the subcollections, the Storage objects and the facts behind it, and a visit deleted that way leaves all of them orphaned.

Who. The visit's own capturer (Task.user_id), or a tenant admin.

Which visits. Only one that is not finished: state is anything but finished, and locked is not true. A finished visit is analysed work that reports already count; a client that wants it gone from the capturer's phone hides it locally and nothing is deleted. An image with retention_hold (an admin's "keep", or a cutout flagged for training) also refuses the delete. All three answer 409.

Photos the phone has not uploaded yet are the caller's to stop: a document or object that arrives after this call is not chased.

A visit that does not exist answers 404 with details.reason: no_such_task, so a client can tell "already gone" from a deployment that does not serve this route yet.

catalog

The writes the review UIs need: approving a capture, tagging an object, editing a product. Server-owned because each can change the meaning of analyses already written.

POST/v1/tenants/{tenantId}/product-captures/{captureId}:review

Approve or reject a product capture

Moves a capture out of pending. Approving is what hands it to annotation as a training input, and — when the capture names a provisional product — clears that product's write_in flag so it counts in share-of-shelf denominators.

Requires tenant_admin or above: a contributor may submit a capture but not bless their own.

PUT/v1/tenants/{tenantId}/objects/{objectId}/tags

Replace an object's tags

The gallery's "flag for training" and "not this product" actions. Replaces the whole set rather than patching, so two operators editing the same object converge on a value one of them chose instead of a union neither did.

Tags are advisory metadata on a derived record: they never change what the analysis says, and a re-analysis preserves them.

POST/v1/tenants/{tenantId}/products

Create a catalog product with the next free id

v1.13.0. The catalog page's "new product". Ids are the join key for every analysis, and imported catalogs bring the client's own integer codes, so the server assigns the next free id from a counter seeded above the catalog's maximum — a hand-made product never collides with a code the next import brings. brand_id and category_id must resolve. write_in defaults to false: an admin creating a product deliberately makes it real; the provisional flag is for entries a field capture proposes.

Requires tenant_admin or above.

Request body: ProductInput

POST/v1/tenants/{tenantId}/categories

Create a category with the next free id

v1.13.0. Same id rule as createProduct. A category made here is real (write_in: false); the provisional flag is for a category sweep's proposal.

Requires tenant_admin or above.

Request body: CategoryInput

PUT/v1/tenants/{tenantId}/products/{productId}

Create or update a catalog product

The catalog screen's write path. Server-owned because a product id is the join key for every analysis ever written: renaming is safe, but re-pointing an id at a different SKU silently rewrites history, so the server refuses to change id and validates that brand_id, category_id and the rest resolve.

Requires tenant_admin or above.

Request body: Product

PUT/v1/tenants/{tenantId}/locations/{locationId}/expected

Set what a location is expected to have

v1.24. The write path for Location.expected: the shelf length and the positions a client pays for at this store (roadmap §12). The whole object is replaced; a field left null is "not judged". It changes nothing about visits already rolled up — each carries the expectations it was judged against — and the next change to a visit of this location picks the new values up.

Locations are otherwise closed to clients (allow write: if false); this route writes this one field and nothing else.

Requires tenant_admin or above.

Request body: LocationExpectations

tenant

A tenant's own settings, set by its admins (retention).

PUT/v1/tenants/{tenantId}/retention

Set how long the tenant keeps its shelf photos

v1.26. The write path for Tenant.retention.images_days, which the nightly enforceRetention job applies (roadmap §6.6): past the window a photo, its analyses, its corrections and its cutouts go; the visit stays as a record that it happened, and a photo with retention_hold is never removed. null keeps everything forever. The tenant document is otherwise closed to clients; this route writes this one field and records the change in the audit log (AuditEvent, action retention.set).

Requires tenant_admin or above.

Request body: RetentionPolicy

internal

Service-to-service re-entry points. Not for clients; authenticated with the service's own identity. Listed so that deployment and functions know they exist and so conformance tests cover them.

POST/internal/jobs/{jobId}:run

Execute an analysis job (queue re-entry)

The worker entry point. :analyze enqueues a task that calls this with the service's own identity; the job runs inside the request so it is bounded by the request timeout and gets the queue's retry semantics. Not callable by clients. On success the worker submits its result through the same canonical write path as PUT .../analysis.

The tenant travels with the task: JSON body {"tenant_id", "job_id"} and, redundantly, header x-shelfpro-tenant-id. A body whose tenant_id does not match the job's stored tenant is 404.

Schemas

These describe both the REST payloads and the stored document bodies — they are the same shapes. Wire format is snake_case throughout, and unknown fields are rejected rather than ignored.

TraceId

W3C Trace Context trace-id — 32 lowercase hex characters.

Minted on the device at capture and carried through every system that touches the image: the image document, the Storage object's customMetadata, the traceparent header on API calls, and every log record emitted along the way. Filtering the platform to one trace_id yields the complete life of one shelf photo, in order, across modules.

When absent, the receiving service mints one and logs trace.orphaned. A missing trace must never fail a capture, but it is a defect and should be visible as one.

When more than one source carries a value, precedence is: the image document, then the request body, then the traceparent header. The document is where the origin lives; a header that disagrees is logged at WARNING with both values and the document wins.

The W3C format is deliberate: it costs nothing now and is what allows an upgrade to real OpenTelemetry spans later without reissuing ids.

TenantId

Tenant identifier. Lowercase slug, stable for the life of the tenant. Used as the Firestore root segment (tenants/{tenantId}), the Cloud Storage bucket suffix (the-shelfpro-{tenantId}), and the tenant claim checked by security rules.

TaskState

Lifecycle of a capture session ("visit").

Legacy note: stored as an integer 0..3 in the old schema. v1 stores the string; the ordinal is retained only in docs/delta-from-legacy.md.

startedfinishedwarningerror

ImageState

Lifecycle of a single captured image.

  • started — created locally, not yet uploaded.
  • uploaded — bytes in Cloud Storage, analysis not yet canonical.
  • finished — canonical analysis present.
  • warning — analysis present but degraded (low confidence, partial).
  • error — analysis failed terminally.

Legacy note: stored as an integer 0..4 in the old schema.

Ownership: a client may perform exactly one transition, started → uploaded, once the bytes are in Storage. Every other transition is the server's, made inside the analysis transaction (finished, warning) or by a failed job (error).

The late-uploaded race. A device that runs on-device inference submits its analysis over HTTP while its Firestore upload-complete write drains over a different channel; the two have no ordering guarantee. The analysis can therefore land first and move the image to finished, after which the queued started → uploaded write is denied by the rules. That denial is correct — allowing it would regress the state and lose the analysis — so a client must treat PERMISSION_DENIED on its own uploaded transition as success: it means the server has already moved the image past uploaded. The server logs the out-of-order submission rather than rejecting it.

starteduploadedfinishedwarningerror

PhotoSubject

v1.24. What a photo shows, where the tenant pays for positions (roadmap §12): a shelf, a rack ("stalak"), a recipe rack or a graphic. A different axis from ImageType, which says how the picture was made.

shelfrackrecipe_rackgraphics

SubjectSource

v1.24. Who chose Image.subject.

modelhuman

SubjectSuggestion

v1.24. The on-device classifier's proposal for Image.subject.

FieldTypeNotes
subjectreqPhotoSubject
scorereqnumberdouble
model_versionstring | null

The bundled classifier's version, so its suggestions can be told apart later.

ImageType

How the image was produced. stitched is an on-device panorama uploaded as one ordinary file and analysed like a single; since v1.11 it may also carry the frames it was made from (burst_originals, burst_layout, assembly: manual), which changes nothing for a consumer that only wants the picture. burst is 2–10 originals captured in order; the server is expected to analyse each, assemble a virtual shelf, upload it as result_image_path and write the analysis in the composite's coordinates (shipping in the pampers capture build; see docs/delta-from-legacy.md).

Vision v1 analyses single and stitched. A burst job fails with error_message set until virtual-shelf assembly lands.

v1.23 price: a close-up of price tags the rep takes after the shelf photo, so the tags are legible. Analysed with the same pipeline, but its product boxes are not facings: nothing that counts shelf — the stats on the phone and the web, share of shelf, the fact loader — reads objects on a price image, and its tags link to products by their printed name alone (priced_product_id), there being no rows. Everything that counts prices includes them. Shown apart from shelf photos wherever a visit's photos are listed.

singlestitchedburstprice

BurstDirection

left_to_rightright_to_left

Assembly

Who lined the frames up. See Image.assembly.

manualauto

BurstLayoutFrame

Where one original sits in the composite. crop is the part of the original that was kept, in the original's pixels after EXIF transpose; offset is where that crop's top-left corner lands in the composite, in composite pixels; scale multiplies the crop before placing it (1.0 unless the client down-scaled the frames). Frames are painted in order, later ones on top.

FieldTypeNotes
cropreqnumber[]

[x1, y1, x2, y2] in original pixels, x2 > x1, y2 > y1.

offsetreqnumber[]

[x, y] of the crop's top-left corner in composite pixels.

scalenumber

CountRegion

Operator-drawn rectangle, normalized 0..1, restricting which objects count toward share-of-shelf. Written by the web review UI directly on the image document (a capture-side field, client-owned). An object counts when its centre is inside. Since v1.17 the fact loader applies it — only counted objects become facings, and an image's facts reload when its area changes — so the review editor's Stats tab and every report agree.

FieldTypeNotes
x1reqnumberdouble
y1reqnumberdouble
x2reqnumberdouble
y2reqnumberdouble

UserRole

The single canonical role encoding. The legacy stack carried three parallel encodings (enum name, a display string that disagreed with the enum name, and a numeric value/id), which is why SUPERUSER rendered as SHELFX_ADMIN and SUPERVISOR rendered as SUPERUSER. v1 stores this string and nothing else.

Ordering is strictly decreasing in privilege as listed. Comparisons must use the generated ordinal helper, never a hand-written number.

  • platform_admin — ShelfPro staff; cross-tenant.
  • tenant_owner — customer-side owner; full control of one tenant.
  • tenant_admin — customer-side administrator; manages users and catalog.
  • contributor — field agent; captures and reviews own work.
platform_admintenant_ownertenant_admincontributor

DetectionClass

Detector-level class, independent of which product was recognised. out_of_stock marks a gap on the shelf and carries no class_id.

v1.23 price_tag: a shelf-edge or hanging price tag. It carries a price reading and links to the products it prices (priced_object_ids, priced_product_id); it has no class_id and is never a facing — nothing that counts products counts it.

productout_of_stockprice_tag

PriceReading

v1.23. What a price tag says, as the reader (an OCR engine and a parser in the vision service) or a person read it. Carried on an ImageAnalysisObject whose detection_class is price_tag, and copied to its ObjectRecord. Null on such an object means nobody has read the tag (a tenant without a reader); unreadable means the reader tried and would not guess — a person may still fill the values through a correction, which makes it read.

The values are what is printed. value is the shelf price the shopper pays; old_value the crossed-out or "regular" price when the tag shows one; unit_value the price per unit. promo says the tag presents a reduction (an old price, a percentage, a promo word): a flag read off the tag, not a comparison. text is the raw text the reader saw, kept for the editor and for retraining; score is the reader's confidence in value. currency is the store's, from Location.country_code, never read off the tag.

FieldTypeNotes
statusreqPriceReadingStatus
valuenumber | nulldouble
old_valuenumber | nulldouble
unit_valuenumber | nulldouble
unitstring | nullkg · l · kom · null

What unit_value is per — kilogram, litre or piece (kom). The parser normalises /100 g and /100 ml to these.

currencystring | null

ISO 4217 — HR → EUR, RS → RSD, BA → BAM.

promoboolean
textstring | null
scorenumber | nulldouble

PriceReadingStatus

v1.23. read: the values are what the tag says. unreadable: the reader tried and would not guess; the values are null until a person fills them. Named, not inline, so generated clients do not confuse it with other status fields.

readunreadable

AnalysisSource

Where an analysis came from. Drives reconciliation precedence: human > higher model_version > server over device on a tie.

serverdevicehuman

JobState

pendingrunningsucceededfailed

Task

A capture session at one location — a "visit". Firestore: tenants/{tenantId}/tasks/{taskId}.

FieldTypeNotes
idreqstring

Firestore document id.

location_idreqintegerint64

References tenants/{tenantId}/locations/{location_id}.

user_idreqstring

Firebase uid of the capturing user; references users/{uid}.

created_atreqstringdate-time
last_used_atstring | nulldate-time

Last time an image was added; drives "resume visit".

statereqTaskState
image_countinteger

Denormalised count of the images subcollection. Image documents are created by clients directly in Firestore, where the API has no hook, so this is maintained by a Firestore trigger in functions (onImageCreated / onImageDeleted). Clients must not set it.

analyzed_image_countinteger

Images whose state is finished or warning. Maintained by the vision service inside the analysis transaction, by read-modify-write on this document — never by a count query.

statsTaskStats
manual_shelf_cmnumber | nulldouble

v1.24. The shelf length the rep measured by hand, in centimetres, for a visit in a tenant with Tenant.measurement. Client-owned: the visit's owner or a tenant admin sets or clears it, never on a locked visit. When present it replaces the measured length as the visit's measurement.shelf_cm; the measured one stays in measurement.measured_cm and both are shown. The legacy capture build kept this inside stats.

measurementTaskMeasurement

v1.24. The visit's shelf length and positions against what the location expects. Server-owned: functions recomputes it whenever a photo of the visit changes its subject, guerilla or measurement, is added or removed, or manual_shelf_cm changes. Absent in tenants without Tenant.measurement.

lockedboolean

v1.17. A locked visit is closed to review edits — the legacy app's Task.locked ("Item 7a"), used to freeze a visit once it has been checked. Corrections to its images are refused with 403 permission_denied, and the rules refuse a change to an image's count_region while it is set. Only tenant_admin and above may set or clear it; captures and server analysis are unaffected.

visible_node_idsstring[]

v1.8. Every node, inclusive, on the path from a hierarchy root down to where this visit sits: the union of the capturing user's sales_node_id ancestor chain and the location's customer_node_id ancestor chain. Server-owned — clients must not set it, and firestore.rules refuses a write that touches it.

It exists so row-level visibility is one set-membership test in rules (sales_node_id in visible_node_ids) and one array-contains in a query. The alternative — prefix-matching the materialised path — cannot be expressed safely in rules, because building a regex out of caller-controlled data is how a scoping check turns into a scoping bypass.

Maintained by functions on create, and rewritten by the re-parent backfill. Empty means the visit is not placed in any hierarchy; whether that is readable is decided by Tenant.visibility_scoped, never guessed.

TaskMeasurement

v1.24. One visit, measured and counted (roadmap §12). Every count is a count of photos by Image.subject: one photo is one rack, one recipe rack or one graphic.

FieldTypeNotes
measured_cmreqnumber | nulldouble

The sum of image_analysis.measurement.total_cm over the visit's shelf photos. Null until one of them has been measured.

manual_cmreqnumber | nulldouble

A copy of Task.manual_shelf_cm as it stood at the rollup.

shelf_cmreqnumber | nulldouble

The length the visit is judged by: manual_cm when the rep gave one, else measured_cm.

shelf_photosreqinteger
racksreqinteger

rack photos that are not guerilla: the paid ones.

recipe_racksreqinteger
graphicsreqinteger
guerilla_racksreqinteger

rack photos with guerilla: true.

expectedreqLocationExpectations

The location's expected as it stood at the rollup, so a visit is judged against what it was shown. Null when the location expects nothing.

updated_atreqstringdate-time

TaskStats

Per-visit rollup written by the pliva capture build (shelf-measurement feature) and consumed by the pliva reporting scripts. Present on every pliva task in production; no other tenant writes it. Carried as-is so those documents stay contract-valid; no server component writes it in v1. Open to additional keys because it is a legacy client-owned map.

FieldTypeNotes
brand_countsobject
product_countsobject
total_product_countinteger | null
graphics_image_countinteger | null
total_measured_cmnumber | null
row_measurementsnumber[]

Measured shelf length per row, in centimetres.

manual_shelf_cmnumber | null

Image

One captured shelf photo. Firestore: tenants/{tenantId}/tasks/{taskId}/images/{imageId}.

The analysis is an embedded map on this document, not a separate top-level collection.

FieldTypeNotes
idreqstring
task_idreqstring

New in v1. Denormalised parent task id, stamped at capture. Redundant with the document path, but required so that collectionGroup('images') queries — which the analytics dashboards rely on — can resolve the task without a second read.

location_idreqintegerint64

New in v1. Denormalised from the parent task, stamped at capture. The legacy schema omitted it, forcing the web app to approximate per-store metrics by nearest-GPS-within-500m, which broke per-store accuracy. Stamping it here is the fix.

created_atreqstringdate-time
namestring | null

Display name, usually the original filename.

statereqImageState
firebase_storage_pathreqstring

Cloud Storage object path. The uploader stamps customMetadata.image_object_reference on the object with this document's full Firestore path, which is the handoff signal the Storage trigger reads.

firestore_storage_pathstring | null

Full Firestore path of this document; mirror of the object metadata.

media_idinteger | nullint64

Android MediaStore id; local bookkeeping only.

local_image_uristring | null

Device-local content:// URI. Written by the capture client so the offline queue can find the file; meaningless server-side.

latitudenumber | nulldouble
longitudenumber | nulldouble
trace_idTraceId

New in v1.1. Minted by the capture client at shutter and written here and to the Storage object's customMetadata. Nullable only so that a trace failure can never block a capture; a null value means this image's history cannot be reconstructed across modules.

image_typeImageType
burst_originalsstring[]

For burst: Storage paths of the originals in capture order. firebase_storage_path is the first of them.

burst_directionBurstDirection
result_image_pathstring | null

Server-written for burst: the assembled virtual-shelf composite. Review UIs display this in preference to firebase_storage_path when present.

assemblyAssembly

v1.11. How a multi-frame capture became one picture. manual: the rep lined the frames up by hand on the phone (roadmap §10.1); the composite is firebase_storage_path, the frames are burst_originals and burst_layout says where each one sits. auto: the server assembled it (roadmap §10.2). Null for a single image or a legacy stitched one that kept no originals.

burst_layoutBurstLayoutFrame[]

v1.11. One entry per burst_originals element, same order. Present when assembly is manual; the server may fill it for auto. A consumer that has the originals and the layout can re-render the composite exactly, which is what lets vision project per-frame detections into composite coordinates without re-aligning anything.

error_messagestring | null

Server-written when state is error; human-readable.

subjectPhotoSubject

v1.24. What the photo shows, in a tenant with Tenant.measurement. Client-owned: set at capture and changeable afterwards by a tenant member, never on a locked visit. Null or absent reads as shelf.

Every photo is analysed the same way whatever its subject, so a change needs no second analysis; the subject decides what counts. A shelf is measured and its facings count. A rack or a recipe_rack is one position in Task.measurement and its facings are kept. Nothing found on a graphics photo counts as a product: stats, facts and shares skip it.

subject_sourceSubjectSource

v1.24. Whether subject is the on-device classifier's pick left as it was (model) or a person's choice (human).

subject_suggestionSubjectSuggestion

v1.24. What the on-device classifier proposed at capture and how sure it was. Kept when a person chooses otherwise: the pair is the training signal for the next classifier.

guerillaboolean

v1.24. On a rack: a position that is not paid for, as the rep marked it. Counted in guerilla_racks, not in racks. Meaningless, and read as false, on any other subject. Client-owned like subject. Absent means false.

count_regionCountRegion
tagTag
image_analysisImageAnalysis

Embedded canonical analysis; absent until one is accepted. A copy of the winning document in the analyses/ subcollection, which holds every submission (with superseded set) as audit trail and training signal.

visible_node_idsstring[]

v1.8. Every node, inclusive, on the path from a hierarchy root down to where this visit sits: the union of the capturing user's sales_node_id ancestor chain and the location's customer_node_id ancestor chain. Server-owned — clients must not set it, and firestore.rules refuses a write that touches it.

It exists so row-level visibility is one set-membership test in rules (sales_node_id in visible_node_ids) and one array-contains in a query. The alternative — prefix-matching the materialised path — cannot be expressed safely in rules, because building a regex out of caller-controlled data is how a scoping check turns into a scoping bypass.

Copied from the parent task, so a collectionGroup('images') read can be scoped without a join. Maintained by functions on create, and rewritten by the re-parent backfill. Empty means the visit is not placed in any hierarchy; whether that is readable is decided by Tenant.visibility_scoped, never guessed.

retention_holdboolean

v1.14. Keep this image past the tenant's retention.images_days. Set by a tenant admin from the review screen ("keep"), and by the server when a cutout of the image is flagged for_training — a labelled example is worth more than the window. The retention job never removes a held image and never clears the flag; storage class still cools with age. Absent means false.

reviewed_atstring | nulldate-time

v1.21.0. When a person said "I have looked at this photo and it is right as it stands". Without it the only way to take a photo off the to-check list was to name every box on it, and a shelf of competitor products nobody will ever name stayed "needs a look" for good.

Client-owned, set together with reviewed_by: a tenant member writes their own uid and the server's time, or clears both — the rules accept nothing else, and nothing at all on a locked visit. It is a statement about the analysis the person saw, so the server clears both when a model analysis becomes canonical after it (a retry, a newer model). A person's corrections leave it alone: they are the review, not a reason to ask for another one.

Review clients show such a photo as checked whatever its boxes say. It changes no state, no counter and no fact row.

reviewed_bystring | null

v1.21.0. Firebase uid of whoever set reviewed_at. Null exactly when reviewed_at is.

Tag

Free-form image tag from tenants/{tenantId}/tags/images/tags/{id}, e.g. shelf type or planogram section.

FieldTypeNotes
idreqintegerint64
namereqstring

ImageAnalysis

Recognition output for one image. Embedded on the image document under image_analysis.

FieldTypeNotes
image_analysis_objectsreqImageAnalysisObject[]

One entry per detected object. May be empty (nothing on the shelf).

widthreqinteger

Pixel width of the analysed image.

heightreqinteger

Pixel height of the analysed image.

detected_objectsinteger

Total objects, all detection classes. Derived from image_analysis_objects; the server recomputes it on write and ignores any client-supplied value.

detected_productsinteger

Objects with detection_class: product. Server-derived, as above.

model_namereqstring

Recognition model identifier, e.g. nelt-detector.

model_versionreqstring

Model version, compared during reconciliation. Exactly one of two formats: semver MAJOR.MINOR.PATCH (compared numerically per component — 1.10.0 > 1.9.0) or an ISO date YYYY-MM-DD (compared as a date). Anything else is 400. Across formats a date is treated as newer than any semver: dates are how the training pipeline stamps promoted models, semver is legacy.

analysis_timenumber | nulldouble

Wall-clock seconds spent on inference.

sourcereqAnalysisSource
analyzed_atreqstringdate-time
supersededboolean

True when this analysis lost reconciliation and is retained only for audit. The canonical analysis on an image is never superseded. Server-derived; ignored on input.

submission_hashstring | null

Server-derived. SHA-256 of the submission with server-derived fields removed and keys sorted — the idempotency key for PUT. Ignored on input.

measurementImageMeasurement

v1.24. The shelf length the tenant's own products occupy on this photo, for tenants that measure (roadmap §12). Server-derived from the objects on every canonical write — a correction re-measures — ignored on input and left out of submission_hash. Null or absent where the tenant does not measure. Computed whatever the photo's subject; only a shelf photo's length enters Task.measurement.

MeasurementMethod

v1.24. Where a row's scale came from. reference: the row's own usable size references (size_reference) with a catalog width. photo_reference: the row had none and borrowed the photo's. pack_widths: the photo had none, so the length is the sum of the catalog widths of the facings. none: nothing to measure by; cm is null.

referencephoto_referencepack_widthsnone

MeasuredSpan

v1.24. A run of the tenant's own facings standing next to each other on one board, as normalized x coordinates.

FieldTypeNotes
x1reqnumberdouble
x2reqnumberdouble

MeasuredRow

v1.24. One shelf board on a photo: the bottom layer of the tenant's own facings on it. Packs stacked on other packs are not measured twice — a board counts once, by what stands on it.

FieldTypeNotes
cmreqnumber | nulldouble

The sum of the spans' lengths. Null with method: none.

methodreqMeasurementMethod
yreqnumberdouble

The board, normalized: where the row's facings stand.

spansreqMeasuredSpan[]

Left to right; what a review UI draws as rulers.

facingsreqinteger

Own bottom-layer facings in the row.

referencesreqinteger

How many of them set the row's scale.

ImageMeasurement

v1.24. One photo, measured. Rows run top to bottom.

FieldTypeNotes
total_cmreqnumber | nulldouble

The sum of the rows that could be measured. Zero when the photo shows none of the tenant's own products; null when it shows some and none could be measured.

rowsreqMeasuredRow[]

ImageAnalysisObject

One detected object. Carries a detector-level class (what kind of thing it is) and, when the detection is a product, a classifier-level result (which product it is).

Validation on write: normalized_x1 < normalized_x2, normalized_y1 < normalized_y2; detection_class: out_of_stock requires class_id: null. Violations are 400.

FieldTypeNotes
normalized_x1reqnumberdouble
normalized_y1reqnumberdouble
normalized_x2reqnumberdouble
normalized_y2reqnumberdouble
x1integer | null

Pixel coordinates. Derived, not authoritative — a convenience mirror of the normalized box against ImageAnalysis.width/height. Normalized coordinates are the contract; consumers that resize or re-crop must recompute pixels rather than trust these. The server recomputes all four on write and ignores client-supplied values. Always present on stored documents and in responses — the current review UIs render from them — and nullable only on input.

y1integer | null
x2integer | null
y2integer | null
detection_idreqinteger

Detector class index as emitted by the model, kept as provenance. Its meaning is per model: the shared product detector emits 0 = product, 1 = out_of_stock, while pliva's emits 0 = shelf, 1 = row, 2 = product. The mapping to detection_class is tenant configuration in the vision service, and classes that have no contract meaning (shelf, row) are dropped before emission. Never interpret this index directly.

detection_classreqDetectionClass
detection_scorereqnumberdouble
class_idinteger | nullint64

Classifier result: the numeric id of the recognised tenants/{tenantId}/products/{class_id} document. Null when the object is out_of_stock, or when the classifier declined.

class_namestring | null

Denormalised product name at analysis time. Display convenience — class_id is authoritative; renaming a product does not rewrite historical analyses.

class_scorenumber | nulldouble
sizestring | null

Pack size, e.g. 0.5L. Catalog-derived, not a model output: no classifier has ever emitted it. The server fills it from Product.sizes when the recognised product has exactly one size; otherwise null and clients may derive it themselves.

correctedboolean

True when a human added, moved or re-labelled this object. Marks high-value training data. Since v1.3 this is derived from provenance (any human entry) and kept for older readers.

object_idstring | null

v1.3. Stable identity of this object across analyses and corrections — the thread that lets a cutout be found from the catalog and its history be followed. A uuid4, minted by the server the first time an object exists (model detection or human add) and preserved by relabel and move. Nullable only on input; always present on stored documents and in responses.

provenanceProvenance

v1.3. Who produced the box and who produced the label. Server filled; ignored on input.

pricePriceReading

v1.23. The reading of a price_tag; null on a tag nobody has read and on every other class. Validation on write: present only with detection_class: price_tag (400 otherwise).

priced_object_idsstring[]

v1.23. On a price_tag: the object_ids of the product objects on the same image this tag prices — the facings in the row the tag's printed name matched (the row directly above the tag or the one below it), or the row above when the name matched neither. Empty on a price image, where there are no facings, and on every other class. A person corrects it by editing the object.

priced_product_idinteger | nullint64

v1.23. On a price_tag: the catalog product the tag prices, from its printed name — on a price image the only link. Null when the name matched no product; a person may set it through a correction. Reporting attaches the price to this product.

size_referenceboolean | null

v1.24. On a product in a tenant that measures: whether this facing shows its true width (frontal, whole) and so may set the scale of its row. Null or absent: not assessed — a box a person drew or moved, a product the classifier did not name, a tenant that does not measure. Server-owned: ignored on input, carried through relabel, cleared by move. The legacy stack's referent_value.

ProvenanceEntry

One actor's contribution — a model release or a person.

FieldTypeNotes
kindreqstringmodel · human
model_namestring | null
model_versionstring | null
user_idstring | null

Firebase uid when kind is human.

atreqstringdate-time

Provenance

The answer to "who detected this and who classified it". The four flags a review UI wants — model-detected, human-detected, model-classified, human-classified — are detected_by.kind and classified_by.kind, with the timestamp and identity beside them so they cannot drift apart.

FieldTypeNotes
detected_byreqProvenanceEntry
classified_byProvenanceEntry

Null for out_of_stock and for objects nobody has labelled.

AnalysisRequest

FieldTypeNotes
firebase_storage_pathreqstring

Cloud Storage object to analyse. Bucket-relative (tenant bucket implied) or a full gs:// URI; must belong to the request's tenant.

model_namestring | null

Override the tenant's configured model. Omit for the default.

model_versionstring | null

Pin a model version. Omit for the tenant's current version.

prioritystringnormal · high
trace_idTraceId

Recovered by onImageUploaded from the Storage object's customMetadata and forwarded. This is the single link that keeps device-side and server-side records on the same trace — if it breaks, everything downstream is orphaned.

AnalysisJob

FieldTypeNotes
job_idreqstring
statereqJobState
tenant_idreqTenantId
task_idreqstring
image_idreqstring
model_namestring | null
model_versionstring | null

Part of the idempotency key (image, model_version).

created_atreqstringdate-time
started_atstring | nulldate-time
finished_atstring | nulldate-time
trace_idTraceId
attemptinteger

Retry counter. The retryFailedAnalyses scheduled function re-enqueues failed jobs and increments this. Retries keep the original trace_id, so the whole retry history stays on one trace.

errorError

Populated only when state is failed.

AnalysisSubmission

An analysis produced by a device or by the vision service. analysis.source must match the caller: server for a service identity, device for a user token; human is rejected.

FieldTypeNotes
analysisreqImageAnalysis
job_idstring | null

Present when this submission fulfils a :analyze job.

client_captured_atstring | nulldate-time

When the device produced this result. Distinct from analysis.analyzed_at for offline submissions that sync later.

trace_idTraceId

The originating capture's trace. An offline device submission may arrive days later and still join its own capture records.

AnalysisSubmissionResult

FieldTypeNotes
acceptedreqboolean

True when the submitted analysis became canonical. False when an existing analysis won reconciliation — the submission is retained with superseded: true and canonical holds the winner.

reasonstring | null

Human-readable explanation when accepted is false.

canonicalreqImageAnalysis
image_stateImageState

CorrectionBatch

A set of human edits applied atomically. Sent as a delta so the individual edits survive as a training signal.

FieldTypeNotes
correctionsreqCorrection[]
corrected_byreqstring

Firebase uid of the operator making the correction.

base_analyzed_atstring | nulldate-time

analyzed_at of the analysis the operator was looking at. When it no longer matches the canonical analysis the server responds 409 rather than applying edits to a version the operator never saw. Null skips the check — "apply to whatever is current".

Compared at millisecond granularity. A Firestore snapshot reaches a browser or an Android client as a Date, which is millisecond-precision, so an operator can never echo back the microseconds a server-side timestamp would carry. The server mints analyzed_at truncated to milliseconds and compares at that granularity; requiring microsecond equality would make every correction from a snapshot-driven UI conflict.

Correction

A single edit. object_index addresses into ImageAnalysis.image_analysis_objects as the operator saw it — the pre-batch array. A remove in the same batch never shifts it.

FieldTypeNotes
opreqstringadd · remove · relabel · move
object_idstring | null

v1.3, preferred. The object to edit. When present it wins over object_index.

object_indexinteger | null

Position in the pre-batch array. Required for remove, relabel and move when object_id is absent; omitted for add. Kept for v1.2 clients; new clients send object_id.

objectImageAnalysisObject

The new or replacement object. Required for add and move. For relabel, only class_id, class_name and detection_class are read.

Product

A SKU. Firestore: tenants/{tenantId}/products/{id}, document id is the stringified id. ImageAnalysisObject.class_id points here.

FieldTypeNotes
idreqintegerint64
namereqstring
brand_idinteger | nullint64
barcodestring | null
skustring | null
descriptionstring | null
image_pathstring | null

Cloud Storage path of the reference pack shot (catalog_images/{tenantId}/products/{productId}.jpg, v1.12). A tenant admin sets it from the web catalog; the catalog rendering of a shelf draws it in place of the detected object.

category_idinteger | nullint64
subcategory_idinteger | nullint64
flavor_idinteger | nullint64
subshape_idinteger | nullint64
shapestring | null
subshapestring | null
categorystring | null

Denormalised category name; category_id is authoritative.

subcategorystring | null

Denormalised subcategory name; subcategory_id is authoritative.

color_codestring | null

Hex colour used to tint this product's boxes in review UIs.

sizesstring[]

Pack sizes this SKU ships in; matched against ImageAnalysisObject.size.

product_codestring | null

Client's own article code; distinct from sku and barcode in some catalogs.

width_cmnumber | null
height_cmnumber | null
depth_cmnumber | null

Physical pack dimensions. Present in the pliva, pampers and violeta catalogs and edited in the web catalog UI; the input to any shelf-measurement feature.

product_node_idstring | null

v1.3. The product-hierarchy leaf for this SKU. brand_id, category_id and friends are denormalised from its ancestors.

write_inboolean

True for entries created ad hoc by an operator rather than imported from the client's master catalog. Excluded from share-of-shelf denominators until curated.

created_atstring | nulldate-time
updated_atstring | nulldate-time

ProductInput

v1.13.0. A product as an admin creates it by hand; the server assigns id, and image_path comes later through the pack-shot upload. Everything not listed here is import-only.

FieldTypeNotes
namereqstring
brand_idinteger | nullint64
category_idinteger | nullint64
subcategory_idinteger | nullint64
barcodestring | null
product_codestring | null
sizesstring[]
color_codestring | null
write_inboolean

CategoryInput

v1.13.0. A category as an admin creates it; the server assigns id.

FieldTypeNotes
namereqstring
colorstring | null

Brand

FieldTypeNotes
idreqintegerint64
namereqstring
expanded_namestring | null

Full legal or marketing name; name is the short display form.

logo_namestring | null

Filename of the logo asset in the brand bucket.

write_inboolean

Category

Shared shape for categories, subcategories, flavors and subshapes — all four are flat id/name lookups.

FieldTypeNotes
idreqintegerint64
namereqstring
colorstring | null
write_inboolean

Location

A store. Firestore tenants/{tenantId}/locations/{id}.

FieldTypeNotes
idreqintegerint64
namereqstring
codestring | null

Client's own store code; the join key for external reporting.

descriptionstring | null
addressstring | null
citystring | null
countrystring | null
country_codestring | null

ISO 3166-1 alpha-2.

kag_namestring | null

Key Account Group — the retail chain this store belongs to.

latitudenumber | nulldouble
longitudenumber | nulldouble
store_repsStoreRep[]
customer_node_idstring | null

v1.3. The customer-hierarchy node this store is (or sits under). kag_name is a denormalised copy of an ancestor's name.

expectedLocationExpectations

v1.24. What this store is supposed to have, for tenants with Tenant.measurement. Null or absent: nothing is expected and no visit here is judged. Written only through PUT …/locations/{locationId}/expected and the import script.

LocationExpectations

v1.24. The space a client pays for at one store (roadmap §12): a length of shelf and counts of positions. Each field may be null, which means "not judged", never zero.

The judgement, the same in every client and in reporting: with an expectation e and an observation o from Task.measurement, o >= e is met and shows nothing, o < e is short and shows a warning. Reaching or passing the expectation is never a warning.

FieldTypeNotes
shelf_cmnumber | nulldouble

Centimetres of shelf the client's own products should occupy.

racksinteger | null

Paid racks. A guerilla rack does not count towards it.

recipe_racksinteger | null
graphicsinteger | null
guerilla_racksinteger | null

Racks the client knows it has here without paying for them.

StoreRep

Client-side contact for a store; recipient of automated reports.

FieldTypeNotes
namereqstring
emailstring | nullemail

Company

An organisation within a tenant — the client itself, a distributor, or an agency supplying field staff.

FieldTypeNotes
idreqintegerint64
namereqstring
expanded_namestring | null
logo_namestring | null
company_typestring | null
pathstring | null

Cloud Storage prefix for this company's assets.

User

Firestore users/{firebase_uid} — global, not tenant-scoped, because the document id must be the Firebase Auth uid. Tenant membership is therefore carried on the document by tenant_id, which is what security rules and the API check.

FieldTypeNotes
idreqstring

Document id; equal to firebase_id.

firebase_idreqstring

Firebase Auth uid.

tenant_idreqTenantId
rolereqUserRole
emailstring | nullemail
namestring | null
titlestring | null
phonestring | null
company_idinteger | nullint64
labeling_rolestring | nulladmin · annotator · null

v1.19.0. Access to ShelfPro Labeling (label.theshelfpro.com), the internal labelling tool, which signs people in with the same Firebase account. admin creates projects and rounds and assigns them; annotator sees only what is assigned to them; null (the default) means no access. Independent of role, which is the tenant role: a platform_admin is a labeling admin whatever this says, and a tenant's contributor may be an annotator. Set by a platform admin; never by the user.

invitation_idstring | null

The invitation this account redeemed, so "who let this person in" resolves to a document with an inviter on it. Replaces the v1.6 access_code, which recorded only the string a user typed.

version_codeinteger | nullint64

Client build the user last signed in from; support diagnostics.

v1.22.0 — who writes it. The installed Android app, to its own document (the rules let a user update their profile outside the server-owned fields), whenever the stored pair differs from the build that is running. Only builds people install write it: a developer's debug build against production does not. With ClientRequirements.min_version_code it answers "who is still on a build we are about to stop accepting".

version_namestring | null

The same build as people read it, e.g. 0.1.2. Written with version_code.

sales_node_idstring | null

v1.3. The sales-hierarchy node this user occupies. Also the root of what they may see in reports.

UserPreferences

Per-user client settings. Firestore tenants/{tenantId}/user_preferences/{uid}. Deliberately open — clients own their own keys.

FieldTypeNotes
updated_atstring | nulldate-time

ImageEventKind

uploadedanalysis.reconciledanalysis.supersededcorrection.appliedstate.changedjob.failed

ImageEvent

One entry of the per-image timeline at images/{imageId}/events/{id}. Append-only, written inside the same transactions that change the image, so a history panel is one listener rather than a join over analyses/ and corrections/.

FieldTypeNotes
idreqstring
kindreqImageEventKind
atreqstringdate-time
actorreqProvenanceEntry
analysis_idstring | null
correction_idstring | null
detailsobject | null

ObjectRecord

The tenant-wide object index at tenants/{tenantId}/objects/{object_id} — one document per object in a canonical analysis, written asynchronously by a functions trigger on analyses/. This is what "every cutout of product X in the last 30 days" queries, and what the reporting fact table is loaded from. Never written on the hot path.

FieldTypeNotes
object_idreqstring
task_idreqstring
image_idreqstring
image_pathreqstring

Firestore path of the image document.

firebase_storage_pathstring | null
location_idinteger | nullint64
user_idstring | null
visible_node_idsstring[]

v1.25. The visit's visible_node_ids, copied by the indexing trigger (and rewritten by the visibility backfill) so a cutout is readable exactly when its visit is: the objects/ rule applies isInVisibleSubtree to the record itself, and a scoped client lists with array-contains on it (or user_id == for its own). Empty on a record indexed before v1.25 until the backfill has run — which is why the backfill goes before the rules in a rollout.

product_idinteger | nullint64

Null means unclassified — the review inbox queries on it. On a price_tag record (v1.23) the field is absent, not null, so product_id == null keeps returning products only: a tag has no product of its own, and must not land in the inbox.

class_namestring | null
detection_classreqDetectionClass
pricePriceReading

v1.23. Copied from the object; the gallery shows the reading on the cutout.

priced_product_idinteger | nullint64

v1.23. Copied from the object. The gallery's "Prices" tab lists a product's tag cutouts by it, newest first — apart from the product's own cutouts, which stay under product_id.

detection_scorenumber | nulldouble
class_scorenumber | nulldouble
normalized_x1numberdouble
normalized_y1numberdouble
normalized_x2numberdouble
normalized_y2numberdouble
provenanceProvenance
observed_atreqstringdate-time

The image's created_at — when the shelf looked like this.

analyzed_atreqstringdate-time
model_versionstring | null
supersededreqboolean

True once a later canonical analysis no longer contains this object.

tagsstring[]

Free tags such as for_training or not_this_product, set from the gallery.

ImageFile

v1.25. tenants/{tenantId}/image_files/{imageId} — the visit a photo file belongs to, for the Storage rules. A capture's object path (image_analysis/{tenantId}/{day}/{imageId}.jpg, its frames and its thumbnail) names the image but not the visit, and Storage rules cannot query; this document is what they get by the image id to ask the one visibility question (user_id, visible_node_ids, the same test as a visit's). Server-owned: written when the image is created, beside the image's own visible_node_ids, and rewritten by the visibility backfill. Readable by any tenant member; it says only where a photo sits, which the photo's own metadata already said.

FieldTypeNotes
image_idreqstring
task_idreqstring
user_idreqstring | null

The capturing user, as on the visit.

visible_node_idsreqstring[]
updated_atstringdate-time

RetentionPolicy

v1.26. What PUT …/retention takes and returns: the one field of Tenant.retention an admin sets.

FieldTypeNotes
images_daysreqinteger | null

Days a shelf photo is kept after its capture; null keeps forever.

AuditAction

v1.26. What happened. A closed vocabulary like LogEvent: the admin page labels each, and a name nobody translated is a bug.

product.createdproduct.updatedcategory.createdlocation.expectations_setcorrection.appliedtask.deletedinvitation.createdinvitation.revokedinvitation.acceptedlabeling_role.setobject.tags_setcapture.reviewedretention.set

AuditActor

Who did it — a signed-in person (their uid, and their name and role as they were at the time), or the server itself.

FieldTypeNotes
kindreqstringuser · server
uidreqstring | null

The person's Firebase uid; null for the server.

namestring | null
roleUserRole

AuditSubject

What it was done to — enough to name it on a screen and to link to it.

FieldTypeNotes
typereqstringproduct · category · location · image · task · invitation · user · object · capture · tenant
idreqstring

The subject's id as a string (a catalog id, a Firestore document id, a uid).

pathstring | null

The Firestore path of the subject when it is a document, so the page can link to it.

labelstring | null

How the subject was called at the time — a product's name, a location's name, a person's email. Kept here because the subject may be renamed or gone.

AuditEvent

v1.26. One line of the tenant's audit log at tenants/{tenantId}/audit/{eventId} (roadmap §13: who changed what — corrections, catalog, expectations, people, retention). Written only by the server: the api's mutating routes record their own changes; a correction is recorded by a functions trigger on the image's correction.applied event, so the vision service stays as it is. Read by tenant admins; nothing writes it from a client, and nothing deletes it (retention removes photos, not the record that somebody changed one).

before and after hold only the fields that changed, as the server saw them — small maps a screen can show side by side, never a whole document.

FieldTypeNotes
idreqstring
atreqstringdate-time
actorreqAuditActor
actionreqAuditAction
subjectreqAuditSubject
sourcereqstringapi · functions

Which server wrote it — the api route, or a trigger reflecting another service's write.

summarystring | null

One line in the server's words, for a log reader; the page shows the translated action, not this.

beforeobject | null
afterobject | null
trace_idstring | null

The capture's trace when the change is image-scoped (a correction), so it joins the trace.

InvitationChannel

How the invitation reaches its recipient.

email sends the link and costs nothing.

manual sends nothing: the server returns the acceptance link once, for an administrator to hand over in person or as a QR code. It exists for the case where email is unreliable or the person is simply in the room, and it keeps the same audit trail.

SMS was considered and dropped (decided 2026-09-06). Firebase has no free tier for phone auth: it bills per message on every plan, rates vary by destination country, and phone-auth endpoints attract SMS-pumping fraud, where an attacker triggers verifications to premium numbers and takes a cut of the termination fee. A billed channel with an attack surface, to serve a case manual already covers for nothing, was not worth it. Reintroducing it is a contract change, deliberately.

emailmanual

InvitationStatus

pendingacceptedrevokedexpired

Invitation

An invitation to join a tenant at a stated role, at tenants/{tenantId}/invitations/{invitationId}.

This replaces the access code, and the difference that matters is not the delivery mechanism: an access code was a bearer secret carrying a privilege, so whoever held the string got the role, and the client copied that role onto its own user document. An invitation is bound to one identity, is consumed once, expires, and is redeemed by the server — the client never writes its own role. Privilege escalation stops being a rule to enforce and becomes a shape that cannot express the attack.

The raw token is never stored. The document holds only a hash; the token exists in the delivered link and nowhere else, so a database disclosure does not yield usable invitations.

FieldTypeNotes
idreqstring
tenant_idreqTenantId
emailreqstringemail

The identity acceptance is checked against. Required on every invitation, including manual — a link that is not bound to somebody is a bearer secret again.

phonestring | null

Contact detail only — seeded onto the user document. It is never an authentication factor: SMS delivery was dropped in v1.7.1.

namestring | null

Display name to seed the user document with.

rolereqUserRole
channelreqInvitationChannel
statusreqInvitationStatus
company_idinteger | nullint64
sales_node_idstring | null

Where in the sales hierarchy the new user sits (v1.3).

invited_byreqstring

Firebase uid of the inviter. "Who let this person in" is answerable.

created_atreqstringdate-time
expires_atreqstringdate-time
accepted_atstring | nulldate-time
accepted_uidstring | null

The account that redeemed it — not necessarily a pre-existing one.

revoked_atstring | nulldate-time
revoked_bystring | null

InvitationInput

Create one invitation. The server sets status, invited_by, created_at, expires_at and the token; supplying them is a 400.

The role is bounded by the inviter's own. A tenant_admin cannot invite a tenant_owner or a platform_admin. The legacy access code had no notion of this at all: the code carried whatever role its author typed, so anyone who could write the collection could mint a superuser.

FieldTypeNotes
emailreqstringemail
phonestring | null

Contact detail only; never an authentication factor.

namestring | null
rolereqUserRole
channelreqInvitationChannel
company_idinteger | nullint64
sales_node_idstring | null
expires_in_daysinteger

InvitationCreated

The created invitation, plus — for manual only — the acceptance link. The link is returned once, at creation, and never again: it embeds the raw token, which the server does not keep.

FieldTypeNotes
invitationreqInvitation
accept_urlstring | null

Present only for channel: manual. Shown once.

InvitationBatchInput

Invite many people at once — a client onboarding a field team. Partial success is the honest outcome: valid rows are created and invalid rows are reported, because refusing fifty invitations over one malformed address helps nobody.

FieldTypeNotes
invitationsreqInvitationInput[]

InvitationBatchError

v1.9.1. One rejected invitation in a batch. Named for the same reason as HierarchyImportError.

FieldTypeNotes
indexreqinteger

Position in the submitted array.

emailstring | null
reasonreqstring

InvitationBatchResult

FieldTypeNotes
createdreqInvitation[]
errorsreqInvitationBatchError[]

InvitationAcceptInput

Redeem an invitation. The caller must already be authenticated: they sign in or create an account first, then present the token. The server checks the authenticated identity against the invited one, so a forwarded link is useless to anyone else.

FieldTypeNotes
tokenreqstring

The raw token from the invitation link. Compared against a stored hash.

HierarchyKind

The three independent business hierarchies. Each is parent-child with unlimited depth — never a fixed set of level columns — so a client can add a level by adding a node.

customersalesproduct

HierarchyNode

A node at tenants/{tenantId}/hierarchies/{kind}/nodes/{id}. level and path are server-derived from the parent chain on every write (and for the whole subtree on a re-parent); clients never compute them. path is materialised as /root/…/parent/self/ so that "every row under this node" is a prefix match.

FieldTypeNotes
idreqstring
parent_idreqstring | null

Null for a root. A hierarchy may have several roots.

codereqstring

The client's own code for this node (SAP, ERP); the import join key.

namereqstring
levelreqinteger
pathreqstring
is_activereqboolean
attributesobject

Open key-value for filters (channel, format, …). Never structure.

valid_fromstring | nulldate

Reserved for as-it-was-then reporting; unused in v1.3.

valid_tostring | nulldate

HierarchyNodeInput

A node as a client may write it. level and path are absent by design — they are derived from parent_id and rewritten for the whole subtree when a node moves.

FieldTypeNotes
parent_idstring | null
codereqstring
namereqstring
is_activeboolean
attributesobject

HierarchyImportRow

FieldTypeNotes
codereqstring
parent_codestring | null

Null marks a root.

namereqstring
is_activeboolean
attributesobject

HierarchyImportRequest

FieldTypeNotes
rowsreqHierarchyImportRow[]
dry_runboolean

Default true — an import that cannot be previewed will not be trusted.

deactivate_missingboolean

Mark nodes absent from the payload inactive rather than leaving them. Never deletes: a node with history behind it stays, so old reports keep resolving.

HierarchyImportError

v1.9.1. One rejected import row. Named rather than inline so a generated client gets a stable class for it: inline objects are numbered by encounter order, and adding a schema elsewhere renamed this one from under reporting.

FieldTypeNotes
codereqstring
reasonreqstring

HierarchyImportResult

FieldTypeNotes
dry_runreqboolean
createdreqinteger
updatedreqinteger
movedreqinteger

Nodes whose parent changed — the expensive ones, since a move rewrites paths beneath.

deactivatedreqinteger
unchangedreqinteger
errorsreqHierarchyImportError[]

Unknown parent_code, a cycle, or a duplicate code. A dry run reports every error; a real import applies nothing when any row errors.

KpiKind

ratio KPIs carry a numerator and denominator so they re-aggregate at every level. Averaging a ratio across children is the classic way to report a wrong number, and the registry makes it impossible.

sumcountcount_distinctratio

KpiDescriptor

FieldTypeNotes
idreqstring
namereqstring

Display label, in English. English is required and is the fallback for every locale, so a KPI always has a label even where no translation exists.

name_i18nobject | null

v1.9. Optional per-locale labels, keyed by language tag.

Localising here rather than in each client is deliberate: the matrix on screen and the Excel export are produced by different code, and a label held client-side drifts from the workbook the moment a KPI is renamed. One source, both surfaces.

descriptionstring | null
description_i18nobject | null

v1.9. Per-locale descriptions; description is the fallback.

kindreqKpiKind
factreqstring

Which fact table it reads.

unitstring | null
formatstring | null

A display hint, e.g. 0.0% or #,##0.

numeratorstring | null

For ratio.

denominatorstring | null
denominator_scopeDenominatorScope
denominator_ignoresHierarchyKind[]

Hierarchies whose filters the denominator drops, when denominator_scope is ignore_hierarchies. Row grouping still applies to the numerator; only the denominator widens.

DenominatorScope

How a ratio's denominator is scoped relative to its numerator.

same_filters — the denominator sees exactly the numerator's filters. Right for rates that are genuinely self-referential: out-of-stock rate is gaps over slots in the same selection.

ignore_hierarchies — the denominator drops filters on the hierarchies named in denominator_ignores. This is what makes a share a share. share_of_shelf ignores the product hierarchy, so filtering to one brand compares that brand against the whole shelf rather than against itself. Under same_filters the same query answers ~100% and looks plausible, which is the dangerous kind of wrong.

same_filtersignore_hierarchies

TimeGrain

The time dimension the spec implies but does not name.

week, quarter, half and year are fiscal: their boundaries come from Tenant.calendar (week_start, fiscal_year_start_month). A tenant that has not configured one gets the Gregorian answer, because the defaults are Monday and January. That is not the same as the grain being Gregorian — a client whose year starts in April sees their Q1, and a report that said otherwise would be compared against their own numbers and disagree.

period is different in kind: it groups by the tenant's named periods (TenantCalendar.periods), which are arbitrary dated ranges rather than a rule. A 4-4-5 retail calendar, a promotional window, a season — anything whose boundaries cannot be derived from a month offset. An observation in no named period is grouped under the reserved key unassigned rather than silently dropped.

dayweekmonthquarterhalfyearperiod

CalendarPeriod

v1.9. One named stretch of time, for the boundaries a month offset cannot express: a 4-4-5 retail period, a promotion, a season, a client's own "Kampanja proljeće".

Both ends are inclusive, which is how a person writing one down means it. Half-open ranges are the right choice inside a query and the wrong one in a form, and this is the form.

FieldTypeNotes
idreqstring

Stable slug; what the matrix reports as the group key. Renaming a period keeps its id, so a saved view does not lose its rows.

namereqstring

Display label, in English. See name_i18n.

name_i18nobject | null

Optional per-locale labels, keyed by language tag. name is the fallback and the only required one.

kindstringquarter · half · year · custom

What the client calls this shape. Purely descriptive — grouping uses period, and the derived quarter/half/year grains come from fiscal_year_start_month, never from this field. It exists so a UI can offer "the quarters" without pattern-matching names.

start_datereqstringdate
end_datereqstringdate

Inclusive. Must not be before start_date.

MatrixAxis

One level of the row or column axis. Exactly one of hierarchy or time.

FieldTypeNotes
hierarchyHierarchyKind
timeTimeGrain

MatrixFilter

Restrict to a subtree, a time window, or an attribute. Filters compose with AND; several nodes of the same hierarchy compose with OR.

FieldTypeNotes
hierarchyHierarchyKind
node_idsstring[]
fromstring | nulldate
tostring | nulldate
attributestring | null
valuesstring[]

MatrixSort

FieldTypeNotes
kpireqstring
directionstringasc · desc

MatrixRequest

The client specification in one object. rows in any order is the "combine hierarchies freely" requirement; expand is which nodes are currently open, so a click costs one bounded query rather than a re-render of everything.

FieldTypeNotes
rowsreqMatrixAxis[]
columnsMatrixAxis[]
kpisreqstring[]
filtersMatrixFilter[]
expandstring[]

Node ids that are open, as full paths from the first row axis (n_zagreb, n_zagreb/n_konzum). Absent means only the top level.

sortMatrixSort
limitinteger

Children returned per expanded node.

cursorstring | null

MatrixExportRequest

FieldTypeNotes
queryreqMatrixRequest
max_depthinteger | null

Null exports the depth the query's expand describes.

filenamestring | null

MatrixCell

FieldTypeNotes
kpireqstring
valuenumber | null

Null means no facts matched — distinct from zero.

numeratornumber | null
denominatornumber | null

Returned for ratio KPIs so a client can re-aggregate a subtotal itself without asking again, and so a share of 0/0 is visibly not a share of 0/100.

MatrixRow

FieldTypeNotes
pathreqstring

Address of this row within the matrix, e.g. n_zagreb/n_konzum.

node_idreqstring
labelreqstring
levelreqinteger

Depth in the row axis, not in the hierarchy.

axisstring | null

Which row axis produced this level — a hierarchy kind or a time grain.

has_childrenboolean
cellsreqMatrixCell[]
column_keystring | null

Set when columns pivot, e.g. 2026-07.

MatrixResponse

A slice, not a tree: the children of every expanded node with their cells. The client already holds the levels it opened.

FieldTypeNotes
rowsreqMatrixRow[]
columnsstring[]

Column keys in display order when the column axis pivots.

kpisreqKpiDescriptor[]

Descriptors for the requested KPIs, so a client can format without a second call.

totalsMatrixCell[]

Grand totals over everything the caller may see, after filters.

generated_atreqstringdate-time
applied_fromstring | nulldate
applied_tostring | nulldate

The time window the query actually ran over. A request that names no bounds gets a default window rather than an unbounded scan, and a reader comparing two numbers needs to know they cover the same period. Never null on a served response.

cachedboolean
next_cursorstring | null
truncatedboolean

True when a node had more children than limit. Never silently partial — a matrix that quietly drops rows is worse than one that says it did.

CaptureKind

referenceon_shelfcategory_sweep

CaptureStatus

pendingapprovedrejectedtrained

CaptureImage

FieldTypeNotes
storage_pathreqstring
anglestring | null

front, back, side, top, on_shelf — free text from the capture flow.

trace_idTraceId

ProductCapture

Images gathered to teach the classifier a new product, or to bootstrap a whole new category, at tenants/{tenantId}/product_captures/{id}. Created by the capture app or the web catalog; approved by an admin; consumed by annotation as a round input source when approved.

FieldTypeNotes
idreqstring
product_idinteger | nullint64

The (possibly write_in) product this documents; null for a category sweep.

proposed_namestring | null
proposed_brandstring | null
barcodestring | null
category_idinteger | nullint64
capture_kindreqCaptureKind
imagesreqCaptureImage[]
captured_byreqstring
location_idinteger | nullint64
statusreqCaptureStatus
reviewed_bystring | null
notesstring | null
created_atreqstringdate-time
updated_atstring | nulldate-time

PlanogramLine

FieldTypeNotes
product_idreqintegerint64
customer_node_idreqstring

Usually a fixture or shelf node; may be a store.

target_facingsreqinteger
positioninteger | null

Left-to-right order on the shelf when known.

Planogram

Target facings per product per customer-hierarchy node, valid for a date range. Compliance is actual facings (from the object index) against these targets, aggregated up the same hierarchy.

FieldTypeNotes
idreqstring
namereqstring
valid_fromreqstringdate
valid_tostring | nulldate
linesreqPlanogramLine[]
created_bystring | null
created_atstring | nulldate-time

ReportView

A saved report configuration at tenants/{tenantId}/users/{uid}/report_views/{id} — the matrix request body plus a name. Owner-writable; readable by the tenant when shared. The request body's schema arrives with the reporting API (v1.4); until then it is an open object.

FieldTypeNotes
idreqstring
namereqstring
owner_uidreqstring
sharedreqboolean
requestreqobject
created_atreqstringdate-time
updated_atstring | nulldate-time

CustomerGrant

Which parts of the customer hierarchy a user may see, at tenants/{tenantId}/users/{uid}/customer_grants/{grantId}.

The sales hierarchy scopes a rep by where they sit in the organisation; this scopes a key-account manager by the customers they hold, which is a different axis and cannot be derived from the first. A user with no grants is limited to their sales subtree — the two widen visibility together, they do not intersect.

Server-written: a user must not be able to widen their own visibility.

FieldTypeNotes
idreqstring
node_idreqstring

A customer-hierarchy node. The grant covers its whole subtree.

granted_byreqstring

Firebase uid of the admin who granted it.

granted_atreqstringdate-time
expires_atstring | nulldate-time

For a temporary cover arrangement. Null never expires.

notestring | null

Tenant

The tenant root document tenants/{tenantId}. Server-owned.

FieldTypeNotes
idreqTenantId
namereqstring
is_activereqboolean
default_bucketstring | null

Overrides the shared bucket when a tenant has its own.

retentionobject

Set by a tenant admin through PUT …/retention (v1.26); the document itself is server-owned.

calendarobject

How this tenant names time.

Two mechanisms, because clients need both. fiscal_year_start_month is a rule: set it to 4 and every quarter, half and year shifts, for every year, past and future, with nothing to maintain. periods is a list: explicit dated ranges with names of the client's choosing, for the boundaries no rule produces.

created_atstring | nulldate-time
visibility_scopedboolean

v1.8. When true, a tenant member may read a visit only if they captured it, they administer the tenant, or their sales_node_id appears in the visit's visible_node_ids. When false, any tenant member may read any visit in the tenant, which is the v1.7 behaviour.

The flag exists because the two alternatives are both wrong. Making the check unconditional locks every existing tenant out of its own data until a backfill finishes. Treating an empty visible_node_ids as "unrestricted" fails open, and a half-populated backfill would then silently widen access while appearing to enforce something.

So the order is explicit: build the hierarchies, run the backfill, verify, then set this. Server-owned.

measurementboolean

v1.24. Switches the shelf-measurement module on for this tenant (roadmap §12): photos carry a subject, locations carry expected, shelf photos are measured and visits roll up into Task.measurement. One build serves every tenant; clients show the module's screens when this is true and nothing of it otherwise. Server-owned. Absent means false.

Summary

Per-user rollup at tenants/{tenantId}/stats/{uid}, maintained transactionally as tasks and images are written. Powers the capture dashboards without fanning out reads.

FieldTypeNotes
total_tasksreqinteger
total_imagesreqinteger
category_countsobject

Keyed by stringified category_id.

brand_countsobject
flavor_countsobject
product_countsobject

ServerStatus

Firestore tenants/{tenantId}/server_status/health — written by the checkServerHealth scheduled function, read by clients to show an offline banner. Mirrors GET /v1/health.

FieldTypeNotes
is_onlinereqboolean
last_checkedreqstringdate-time
statusinteger | null

Last observed HTTP status from the health probe.

errorstring | null

ClientRequirements

v1.22.0. What the platform asks of an installed client, at client_requirements/{platform} — one document per client platform, and android is the only one there is. Outside any tenant: a build is compatible with the platform or it is not, whoever's data it shows.

Server-owned. Staff set it; no client and no service writes it, and any signed-in user reads it. A missing document asks nothing, and a client that cannot read it (offline on a first run, rules not yet deployed) behaves as if it were missing: the gate fails open.

It exists because of what "the contract is the schema" costs on a phone: a web client is replaced by a deploy, an installed app is whatever build its owner last accepted. When a release makes an old build wrong — it would write a shape the rules now refuse, or ask for one the server no longer returns — this is how that build finds out.

FieldTypeNotes
min_version_codereqintegerint64

The oldest build that may be used: Android's versionCode. A build below it shows nothing but "update to continue" and offers the update, so it starts nothing new: no browsing, no capture.

It is not a kill switch. What the build had already queued — a photo taken, a document written offline — still drains, because a photo stranded on a phone is worse than one more write in an old shape. A release that must refuse an old shape refuses it in the rules or the API; this field is what tells the person why.

Raise it only once the build that satisfies it has reached every install (on Play: rolled out on the track people are on, not merely uploaded) — a phone told to update to a build its store does not offer yet can do nothing. User.version_code says who would be stopped.

FailedAnalysis

Retry queue entry at tenants/{tenantId}/failed_analyses/{id}, written when a :analyze call could not be delivered and drained by the retryFailedAnalyses scheduled function.

FieldTypeNotes
task_idreqstring
image_idreqstring
firebase_storage_pathreqstring
failed_atreqstringdate-time
attemptreqinteger
last_errorstring | null
trace_idTraceId

The original capture's trace, so the retry stays on it.

job_idstring | null

The failed job, when one was minted before failure.

HealthStatus

FieldTypeNotes
statusreqstringok · degraded · unavailable
versionreqstring

Deployed service version — the git SHA baked at build. HTTP mapping: ok and degraded (some preload models missing) are 200; unavailable is 503.

models_loadedstring[]

Model identifiers currently resident, for warm-start diagnosis.

uptime_secondsnumber | nulldouble

LogSeverity

Cloud Logging severity levels, used verbatim so that severity filtering and alerting need no mapping layer.

DEBUGINFOWARNINGERRORCRITICAL

LogModule

Which part of the stack emitted the record. Always present.

visionfunctionsmobilewebannotationreporting

LogEvent

A closed vocabulary. This is what makes the logs queryable rather than merely voluminous — adding a name is a deliberate contract change, and the capture funnel only works because the set is fixed.

The funnel, in order: capture.uploaded → analysis.requested → analysis.completed → analysis.reconciled. Counting each per tenant per day localises a failure to a stage without an investigation.

trace.orphaned marks work that arrived with no trace_id; the receiving service mints one and continues. trace.conflict marks two carriers disagreeing (the document wins, and the record names every source); trace.invalid marks a carrier that was present but not a 32-hex id — a client minting malformed ids is a different defect from a client minting none, and the funnel should not conflate them.

v1.10.0 adds the invitation lifecycle: invitation.created (with the channel and whether delivery happened in the payload), invitation.accepted and invitation.revoked. "Who let this person in" is answerable from the document; when, and whether the email went out, is answerable from these.

v1.12.1 adds the onboarding capture's review: capture.reviewed (payload: capture id, the decision, the product it was attached to) and product.created for the provisional product an approval makes real — the two moments a new SKU enters the catalog.

v1.13.0 adds category.created for a category made by hand on the catalog page; product.created now also carries source: catalog when the product was made there rather than by a capture's approval.

v1.13.1 adds retention.applied: the scheduled job's account of what §6.6 removed for a tenant (images past retention.images_days, with their analyses, corrections, events, index rows and Storage objects) — the audit line for a deletion.

v1.15.0 adds thumbnail.generated: the server made the display thumbnail of a capture (storage.image_thumbnail_object in paths.yaml), with the source and thumbnail sizes in the payload; the same event at ERROR says it could not, and the image is then shown from its original.

v1.18.0 adds task.deleted: a visit removed through deleteTask, with who asked and the counts of images, index rows and Storage objects that went with it — the audit line for the one deletion a capturer can cause.

capture.startedcapture.photo_takencapture.queuedcapture.uploadedcapture.failedanalysis.requestedanalysis.startedanalysis.completedanalysis.failedanalysis.retriedanalysis.submittedanalysis.reconciledanalysis.supersededcorrection.appliedcorrection.conflictedauth.signinauth.deniedinvitation.createdinvitation.acceptedinvitation.revokedcapture.reviewedproduct.createdcategory.createdretention.appliedtask.deletedthumbnail.generatedcatalog.importedcapture.submittedcapture.approvedhierarchy.importedobject.indexedreport.generatedsystem.healthtrace.orphanedtrace.conflicttrace.invalid

LogRecord

One structured log line. Every module emits this shape, as a single JSON object — on stdout for Cloud Run and Cloud Functions, which Cloud Logging ingests with no agent and no configuration.

Separation by client and by module is a filter over this one stream, not a separate stream per client. tenant_id and module are therefore mandatory: the cross-cutting questions are the ones that matter, and splitting the stream would make them unanswerable again.

Never put credentials, tokens, image bytes or an access code's value in a log record. Log the id, not the object.

FieldTypeNotes
timestampreqstringdate-time
severityreqLogSeverity
tenant_idreqTenantId

The tenant in scope, or the reserved platform when there is none (health, startup, requests rejected before identity is resolved).

modulereqLogModule
eventreqLogEvent
trace_idTraceId

Present whenever the work descends from a capture.

messagestring | null

Human-readable summary. Never parsed — put anything structured in payload.

task_idstring | null
image_idstring | null
user_idstring | null
location_idinteger | nullint64
model_versionstring | null

Set on analysis.* records, so model quality is trendable by version.

duration_msinteger | null
errorError
payloadobject | null

Module-specific detail; the only part of the record that is not shared vocabulary. vision should break its timings down by stage here — download, detect, classify, write — so that "analysis is slow" resolves to a stage rather than a shrug.

Error

Uniform error body. code is stable and machine-readable; message is for humans and may change.

FieldTypeNotes
codereqstringinvalid_argument · unauthenticated · permission_denied · tenant_mismatch · not_found · conflict · model_unavailable · unavailable · internal

Fixed HTTP mapping — 400 invalid_argument; 401 unauthenticated; 403 tenant_mismatch when the tenant differs, else permission_denied; 404 not_found; 409 conflict; 503 model_unavailable (health) or unavailable (dependency); 500 internal. Body-validation failures are rendered into this shape with the field errors in details, never as a framework 422.

messagereqstring
detailsobject | null
request_idstring | null

Correlates with server logs.