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.
/ v1/ healthService 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.
/ v1/ tenants/ {tenantId}/ tasks/ {taskId}/ images/ {imageId}:analyzeRequest 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 is403. model_nameandmodel_versionmust be omitted (400): which model runs is the tenant's configuration, not the caller's choice.- Any other state answers
409withdetails.reason:not_uploadedforstarted(there are no bytes to read yet),already_analysedforfinishedandwarning(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
409withdetails.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
/ v1/ tenants/ {tenantId}/ analysis-jobs/ {jobId}Poll an analysis job
/ v1/ tenants/ {tenantId}/ tasks/ {taskId}/ images/ {imageId}/ analysisRead 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.
/ v1/ tenants/ {tenantId}/ tasks/ {taskId}/ images/ {imageId}/ analysisSubmit an analysis result (canonical write)
The single canonical write for image_analysis. Used by both inference sources:
source: server— the vision service, after a:analyzejob.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.
/ v1/ tenants/ {tenantId}/ tasks/ {taskId}/ images/ {imageId}/ analysis/ correctionsApply 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_indexvalues refer to the array as the operator saw it. All ops are validated against that array first, then applied in one pass — aremovenever shifts the indices of later ops.relabelsetsclass_id,class_nameanddetection_classand nullsclass_scoreandsize: a human label carries no model confidence.removedeletes the object from the canonical array. The pre-correction analysis survives inanalyses/, so nothing is lost.- The result is a new analysis with
source: human, a freshanalyzed_at,model_name/model_versioninherited, andcorrected: trueon every touched or added object. It becomes canonical unconditionally and moveswarning→finished. base_analyzed_atnull means "apply to whatever is current" — the stale check is skipped.- No canonical analysis yet →
404. - The visit is locked (
Task.locked, v1.17) →403permission_denied: unlocking is a tenant admin's decision. corrected_bymust equal the caller's uid unless the caller istenant_adminor above. Acontributormay correct images in their own tasks;tenant_adminand 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.
/ v1/ tenants/ {tenantId}/ hierarchies/ {kind}/ nodesBrowse 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.
/ 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
/ v1/ tenants/ {tenantId}/ hierarchies/ {kind}:importBulk-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.
/ v1/ tenants/ {tenantId}/ reports/ matrixOne 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
/ v1/ tenants/ {tenantId}/ reports/ matrix:exportThe 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
/ v1/ tenants/ {tenantId}/ kpisThe 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.
/ v1/ tenants/ {tenantId}/ invitationsList invitations
/ v1/ tenants/ {tenantId}/ invitationsInvite 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
/ v1/ tenants/ {tenantId}/ invitations:batchInvite a team at once
Request body: InvitationBatchInput
/ 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.
/ v1/ meThe 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.
/ v1/ users/ {uid}/ labeling-roleGrant 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.
/ v1/ invitations/ {invitationId}:acceptRedeem 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.
/ 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.
/ v1/ tenants/ {tenantId}/ product-captures/ {captureId}:reviewApprove 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.
/ v1/ tenants/ {tenantId}/ productsCreate 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
/ v1/ tenants/ {tenantId}/ categoriesCreate 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
/ 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
/ v1/ tenants/ {tenantId}/ locations/ {locationId}/ expectedSet 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).
/ v1/ tenants/ {tenantId}/ retentionSet 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.
/ internal/ jobs/ {jobId}:runExecute 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.
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.
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.
SubjectSource
v1.24. Who chose Image.subject.
SubjectSuggestion
v1.24. The on-device classifier's proposal for Image.subject.
| Field | Type | Notes |
|---|---|---|
subjectreq | PhotoSubject | |
scorereq | numberdouble | |
model_version | string | 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.
BurstDirection
Assembly
Who lined the frames up. See Image.assembly.
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.
| Field | Type | Notes |
|---|---|---|
cropreq | number[] |
|
offsetreq | number[] |
|
scale | number |
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.
| Field | Type | Notes |
|---|---|---|
x1req | numberdouble | |
y1req | numberdouble | |
x2req | numberdouble | |
y2req | numberdouble |
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.
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.
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.
| Field | Type | Notes |
|---|---|---|
statusreq | PriceReadingStatus | |
value | number | nulldouble | |
old_value | number | nulldouble | |
unit_value | number | nulldouble | |
unit | string | null | kg · l · kom · null What |
currency | string | null | ISO 4217 — HR → |
promo | boolean | |
text | string | null | |
score | number | 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.
AnalysisSource
Where an analysis came from. Drives reconciliation precedence: human > higher model_version > server over device on a tie.
JobState
Task
A capture session at one location — a "visit". Firestore: tenants/{tenantId}/tasks/{taskId}.
| Field | Type | Notes |
|---|---|---|
idreq | string | Firestore document id. |
location_idreq | integerint64 | References |
user_idreq | string | Firebase uid of the capturing user; references |
created_atreq | stringdate-time | |
last_used_at | string | nulldate-time | Last time an image was added; drives "resume visit". |
statereq | TaskState | |
image_count | integer | Denormalised count of the |
analyzed_image_count | integer | Images whose state is |
stats | TaskStats | |
manual_shelf_cm | number | nulldouble | v1.24. The shelf length the rep measured by hand, in centimetres, for a visit in a tenant with |
measurement | TaskMeasurement | v1.24. The visit's shelf length and positions against what the location expects. Server-owned: |
locked | boolean | v1.17. A locked visit is closed to review edits — the legacy app's |
visible_node_ids | string[] | 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 It exists so row-level visibility is one set-membership test in rules ( Maintained by |
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.
| Field | Type | Notes |
|---|---|---|
measured_cmreq | number | nulldouble | The sum of |
manual_cmreq | number | nulldouble | A copy of |
shelf_cmreq | number | nulldouble | The length the visit is judged by: |
shelf_photosreq | integer | |
racksreq | integer |
|
recipe_racksreq | integer | |
graphicsreq | integer | |
guerilla_racksreq | integer |
|
expectedreq | LocationExpectations | The location's |
updated_atreq | stringdate-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.
| Field | Type | Notes |
|---|---|---|
brand_counts | object | |
product_counts | object | |
total_product_count | integer | null | |
graphics_image_count | integer | null | |
total_measured_cm | number | null | |
row_measurements | number[] | Measured shelf length per row, in centimetres. |
manual_shelf_cm | number | 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.
| Field | Type | Notes |
|---|---|---|
idreq | string | |
task_idreq | string | New in v1. Denormalised parent task id, stamped at capture. Redundant with the document path, but required so that |
location_idreq | integerint64 | 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_atreq | stringdate-time | |
name | string | null | Display name, usually the original filename. |
statereq | ImageState | |
firebase_storage_pathreq | string | Cloud Storage object path. The uploader stamps |
firestore_storage_path | string | null | Full Firestore path of this document; mirror of the object metadata. |
media_id | integer | nullint64 | Android MediaStore id; local bookkeeping only. |
local_image_uri | string | null | Device-local |
latitude | number | nulldouble | |
longitude | number | nulldouble | |
trace_id | TraceId | New in v1.1. Minted by the capture client at shutter and written here and to the Storage object's |
image_type | ImageType | |
burst_originals | string[] | For |
burst_direction | BurstDirection | |
result_image_path | string | null | Server-written for |
assembly | Assembly | v1.11. How a multi-frame capture became one picture. |
burst_layout | BurstLayoutFrame[] | v1.11. One entry per |
error_message | string | null | Server-written when |
subject | PhotoSubject | v1.24. What the photo shows, in a tenant with Every photo is analysed the same way whatever its subject, so a change needs no second analysis; the subject decides what counts. A |
subject_source | SubjectSource | v1.24. Whether |
subject_suggestion | SubjectSuggestion | 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. |
guerilla | boolean | v1.24. On a |
count_region | CountRegion | |
tag | Tag | |
image_analysis | ImageAnalysis | Embedded canonical analysis; absent until one is accepted. A copy of the winning document in the |
visible_node_ids | string[] | 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 It exists so row-level visibility is one set-membership test in rules ( Copied from the parent task, so a |
retention_hold | boolean | v1.14. Keep this image past the tenant's |
reviewed_at | string | 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 Review clients show such a photo as checked whatever its boxes say. It changes no state, no counter and no fact row. |
reviewed_by | string | null | v1.21.0. Firebase uid of whoever set |
Tag
Free-form image tag from tenants/{tenantId}/tags/images/tags/{id}, e.g. shelf type or planogram section.
| Field | Type | Notes |
|---|---|---|
idreq | integerint64 | |
namereq | string |
ImageAnalysis
Recognition output for one image. Embedded on the image document under image_analysis.
| Field | Type | Notes |
|---|---|---|
image_analysis_objectsreq | ImageAnalysisObject[] | One entry per detected object. May be empty (nothing on the shelf). |
widthreq | integer | Pixel width of the analysed image. |
heightreq | integer | Pixel height of the analysed image. |
detected_objects | integer | Total objects, all detection classes. Derived from |
detected_products | integer | Objects with |
model_namereq | string | Recognition model identifier, e.g. |
model_versionreq | string | Model version, compared during reconciliation. Exactly one of two formats: semver |
analysis_time | number | nulldouble | Wall-clock seconds spent on inference. |
sourcereq | AnalysisSource | |
analyzed_atreq | stringdate-time | |
superseded | boolean | True when this analysis lost reconciliation and is retained only for audit. The canonical analysis on an image is never |
submission_hash | string | null | Server-derived. SHA-256 of the submission with server-derived fields removed and keys sorted — the idempotency key for |
measurement | ImageMeasurement | 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 |
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.
MeasuredSpan
v1.24. A run of the tenant's own facings standing next to each other on one board, as normalized x coordinates.
| Field | Type | Notes |
|---|---|---|
x1req | numberdouble | |
x2req | numberdouble |
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.
| Field | Type | Notes |
|---|---|---|
cmreq | number | nulldouble | The sum of the spans' lengths. Null with |
methodreq | MeasurementMethod | |
yreq | numberdouble | The board, normalized: where the row's facings stand. |
spansreq | MeasuredSpan[] | Left to right; what a review UI draws as rulers. |
facingsreq | integer | Own bottom-layer facings in the row. |
referencesreq | integer | How many of them set the row's scale. |
ImageMeasurement
v1.24. One photo, measured. Rows run top to bottom.
| Field | Type | Notes |
|---|---|---|
total_cmreq | number | 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. |
rowsreq | MeasuredRow[] |
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.
| Field | Type | Notes |
|---|---|---|
normalized_x1req | numberdouble | |
normalized_y1req | numberdouble | |
normalized_x2req | numberdouble | |
normalized_y2req | numberdouble | |
x1 | integer | null | Pixel coordinates. Derived, not authoritative — a convenience mirror of the normalized box against |
y1 | integer | null | |
x2 | integer | null | |
y2 | integer | null | |
detection_idreq | integer | Detector class index as emitted by the model, kept as provenance. Its meaning is per model: the shared product detector emits |
detection_classreq | DetectionClass | |
detection_scorereq | numberdouble | |
class_id | integer | nullint64 | Classifier result: the numeric id of the recognised |
class_name | string | null | Denormalised product name at analysis time. Display convenience — |
class_score | number | nulldouble | |
size | string | null | Pack size, e.g. |
corrected | boolean | True when a human added, moved or re-labelled this object. Marks high-value training data. Since v1.3 this is derived from |
object_id | string | 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 |
provenance | Provenance | v1.3. Who produced the box and who produced the label. Server filled; ignored on input. |
price | PriceReading | v1.23. The reading of a |
priced_object_ids | string[] | v1.23. On a |
priced_product_id | integer | nullint64 | v1.23. On a |
size_reference | boolean | null | v1.24. On a |
ProvenanceEntry
One actor's contribution — a model release or a person.
| Field | Type | Notes |
|---|---|---|
kindreq | string | model · human |
model_name | string | null | |
model_version | string | null | |
user_id | string | null | Firebase uid when |
atreq | stringdate-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.
| Field | Type | Notes |
|---|---|---|
detected_byreq | ProvenanceEntry | |
classified_by | ProvenanceEntry | Null for |
AnalysisRequest
| Field | Type | Notes |
|---|---|---|
firebase_storage_pathreq | string | Cloud Storage object to analyse. Bucket-relative (tenant bucket implied) or a full |
model_name | string | null | Override the tenant's configured model. Omit for the default. |
model_version | string | null | Pin a model version. Omit for the tenant's current version. |
priority | string | normal · high |
trace_id | TraceId | Recovered by |
AnalysisJob
| Field | Type | Notes |
|---|---|---|
job_idreq | string | |
statereq | JobState | |
tenant_idreq | TenantId | |
task_idreq | string | |
image_idreq | string | |
model_name | string | null | |
model_version | string | null | Part of the idempotency key |
created_atreq | stringdate-time | |
started_at | string | nulldate-time | |
finished_at | string | nulldate-time | |
trace_id | TraceId | |
attempt | integer | Retry counter. The |
error | Error | Populated only when |
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.
| Field | Type | Notes |
|---|---|---|
analysisreq | ImageAnalysis | |
job_id | string | null | Present when this submission fulfils a |
client_captured_at | string | nulldate-time | When the device produced this result. Distinct from |
trace_id | TraceId | The originating capture's trace. An offline device submission may arrive days later and still join its own capture records. |
AnalysisSubmissionResult
| Field | Type | Notes |
|---|---|---|
acceptedreq | boolean | True when the submitted analysis became canonical. False when an existing analysis won reconciliation — the submission is retained with |
reason | string | null | Human-readable explanation when |
canonicalreq | ImageAnalysis | |
image_state | ImageState |
CorrectionBatch
A set of human edits applied atomically. Sent as a delta so the individual edits survive as a training signal.
| Field | Type | Notes |
|---|---|---|
correctionsreq | Correction[] | |
corrected_byreq | string | Firebase uid of the operator making the correction. |
base_analyzed_at | string | nulldate-time |
Compared at millisecond granularity. A Firestore snapshot reaches a browser or an Android client as a |
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.
| Field | Type | Notes |
|---|---|---|
opreq | string | add · remove · relabel · move |
object_id | string | null | v1.3, preferred. The object to edit. When present it wins over |
object_index | integer | null | Position in the pre-batch array. Required for |
object | ImageAnalysisObject | The new or replacement object. Required for |
Product
A SKU. Firestore: tenants/{tenantId}/products/{id}, document id is the stringified id. ImageAnalysisObject.class_id points here.
| Field | Type | Notes |
|---|---|---|
idreq | integerint64 | |
namereq | string | |
brand_id | integer | nullint64 | |
barcode | string | null | |
sku | string | null | |
description | string | null | |
image_path | string | null | Cloud Storage path of the reference pack shot ( |
category_id | integer | nullint64 | |
subcategory_id | integer | nullint64 | |
flavor_id | integer | nullint64 | |
subshape_id | integer | nullint64 | |
shape | string | null | |
subshape | string | null | |
category | string | null | Denormalised category name; |
subcategory | string | null | Denormalised subcategory name; |
color_code | string | null | Hex colour used to tint this product's boxes in review UIs. |
sizes | string[] | Pack sizes this SKU ships in; matched against |
product_code | string | null | Client's own article code; distinct from |
width_cm | number | null | |
height_cm | number | null | |
depth_cm | number | 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_id | string | null | v1.3. The product-hierarchy leaf for this SKU. |
write_in | boolean | 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_at | string | nulldate-time | |
updated_at | string | 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.
| Field | Type | Notes |
|---|---|---|
namereq | string | |
brand_id | integer | nullint64 | |
category_id | integer | nullint64 | |
subcategory_id | integer | nullint64 | |
barcode | string | null | |
product_code | string | null | |
sizes | string[] | |
color_code | string | null | |
write_in | boolean |
CategoryInput
v1.13.0. A category as an admin creates it; the server assigns id.
| Field | Type | Notes |
|---|---|---|
namereq | string | |
color | string | null |
Brand
| Field | Type | Notes |
|---|---|---|
idreq | integerint64 | |
namereq | string | |
expanded_name | string | null | Full legal or marketing name; |
logo_name | string | null | Filename of the logo asset in the brand bucket. |
write_in | boolean |
Category
Shared shape for categories, subcategories, flavors and subshapes — all four are flat id/name lookups.
| Field | Type | Notes |
|---|---|---|
idreq | integerint64 | |
namereq | string | |
color | string | null | |
write_in | boolean |
Location
A store. Firestore tenants/{tenantId}/locations/{id}.
| Field | Type | Notes |
|---|---|---|
idreq | integerint64 | |
namereq | string | |
code | string | null | Client's own store code; the join key for external reporting. |
description | string | null | |
address | string | null | |
city | string | null | |
country | string | null | |
country_code | string | null | ISO 3166-1 alpha-2. |
kag_name | string | null | Key Account Group — the retail chain this store belongs to. |
latitude | number | nulldouble | |
longitude | number | nulldouble | |
store_reps | StoreRep[] | |
customer_node_id | string | null | v1.3. The customer-hierarchy node this store is (or sits under). |
expected | LocationExpectations | v1.24. What this store is supposed to have, for tenants with |
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.
| Field | Type | Notes |
|---|---|---|
shelf_cm | number | nulldouble | Centimetres of shelf the client's own products should occupy. |
racks | integer | null | Paid racks. A guerilla rack does not count towards it. |
recipe_racks | integer | null | |
graphics | integer | null | |
guerilla_racks | integer | null | Racks the client knows it has here without paying for them. |
StoreRep
Client-side contact for a store; recipient of automated reports.
| Field | Type | Notes |
|---|---|---|
namereq | string | |
email | string | nullemail |
Company
An organisation within a tenant — the client itself, a distributor, or an agency supplying field staff.
| Field | Type | Notes |
|---|---|---|
idreq | integerint64 | |
namereq | string | |
expanded_name | string | null | |
logo_name | string | null | |
company_type | string | null | |
path | string | 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.
| Field | Type | Notes |
|---|---|---|
idreq | string | Document id; equal to |
firebase_idreq | string | Firebase Auth uid. |
tenant_idreq | TenantId | |
rolereq | UserRole | |
email | string | nullemail | |
name | string | null | |
title | string | null | |
phone | string | null | |
company_id | integer | nullint64 | |
labeling_role | string | null | admin · annotator · null v1.19.0. Access to ShelfPro Labeling ( |
invitation_id | string | 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 |
version_code | integer | 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 |
version_name | string | null | The same build as people read it, e.g. |
sales_node_id | string | 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.
| Field | Type | Notes |
|---|---|---|
updated_at | string | nulldate-time |
ImageEventKind
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/.
| Field | Type | Notes |
|---|---|---|
idreq | string | |
kindreq | ImageEventKind | |
atreq | stringdate-time | |
actorreq | ProvenanceEntry | |
analysis_id | string | null | |
correction_id | string | null | |
details | object | 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.
| Field | Type | Notes |
|---|---|---|
object_idreq | string | |
task_idreq | string | |
image_idreq | string | |
image_pathreq | string | Firestore path of the image document. |
firebase_storage_path | string | null | |
location_id | integer | nullint64 | |
user_id | string | null | |
visible_node_ids | string[] | v1.25. The visit's |
product_id | integer | nullint64 | Null means unclassified — the review inbox queries on it. On a |
class_name | string | null | |
detection_classreq | DetectionClass | |
price | PriceReading | v1.23. Copied from the object; the gallery shows the reading on the cutout. |
priced_product_id | integer | 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 |
detection_score | number | nulldouble | |
class_score | number | nulldouble | |
normalized_x1 | numberdouble | |
normalized_y1 | numberdouble | |
normalized_x2 | numberdouble | |
normalized_y2 | numberdouble | |
provenance | Provenance | |
observed_atreq | stringdate-time | The image's |
analyzed_atreq | stringdate-time | |
model_version | string | null | |
supersededreq | boolean | True once a later canonical analysis no longer contains this object. |
tags | string[] | Free tags such as |
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.
| Field | Type | Notes |
|---|---|---|
image_idreq | string | |
task_idreq | string | |
user_idreq | string | null | The capturing user, as on the visit. |
visible_node_idsreq | string[] | |
updated_at | stringdate-time |
RetentionPolicy
v1.26. What PUT …/retention takes and returns: the one field of Tenant.retention an admin sets.
| Field | Type | Notes |
|---|---|---|
images_daysreq | integer | 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.
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.
| Field | Type | Notes |
|---|---|---|
kindreq | string | user · server |
uidreq | string | null | The person's Firebase uid; null for the server. |
name | string | null | |
role | UserRole |
AuditSubject
What it was done to — enough to name it on a screen and to link to it.
| Field | Type | Notes |
|---|---|---|
typereq | string | product · category · location · image · task · invitation · user · object · capture · tenant |
idreq | string | The subject's id as a string (a catalog id, a Firestore document id, a uid). |
path | string | null | The Firestore path of the subject when it is a document, so the page can link to it. |
label | string | 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.
| Field | Type | Notes |
|---|---|---|
idreq | string | |
atreq | stringdate-time | |
actorreq | AuditActor | |
actionreq | AuditAction | |
subjectreq | AuditSubject | |
sourcereq | string | api · functions Which server wrote it — the api route, or a trigger reflecting another service's write. |
summary | string | null | One line in the server's words, for a log reader; the page shows the translated action, not this. |
before | object | null | |
after | object | null | |
trace_id | string | 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.
InvitationStatus
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.
| Field | Type | Notes |
|---|---|---|
idreq | string | |
tenant_idreq | TenantId | |
emailreq | stringemail | The identity acceptance is checked against. Required on every invitation, including |
phone | string | null | Contact detail only — seeded onto the user document. It is never an authentication factor: SMS delivery was dropped in v1.7.1. |
name | string | null | Display name to seed the user document with. |
rolereq | UserRole | |
channelreq | InvitationChannel | |
statusreq | InvitationStatus | |
company_id | integer | nullint64 | |
sales_node_id | string | null | Where in the sales hierarchy the new user sits (v1.3). |
invited_byreq | string | Firebase uid of the inviter. "Who let this person in" is answerable. |
created_atreq | stringdate-time | |
expires_atreq | stringdate-time | |
accepted_at | string | nulldate-time | |
accepted_uid | string | null | The account that redeemed it — not necessarily a pre-existing one. |
revoked_at | string | nulldate-time | |
revoked_by | string | 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.
| Field | Type | Notes |
|---|---|---|
emailreq | stringemail | |
phone | string | null | Contact detail only; never an authentication factor. |
name | string | null | |
rolereq | UserRole | |
channelreq | InvitationChannel | |
company_id | integer | nullint64 | |
sales_node_id | string | null | |
expires_in_days | integer |
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.
| Field | Type | Notes |
|---|---|---|
invitationreq | Invitation | |
accept_url | string | null | Present only for |
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.
| Field | Type | Notes |
|---|---|---|
invitationsreq | InvitationInput[] |
InvitationBatchError
v1.9.1. One rejected invitation in a batch. Named for the same reason as HierarchyImportError.
| Field | Type | Notes |
|---|---|---|
indexreq | integer | Position in the submitted array. |
email | string | null | |
reasonreq | string |
InvitationBatchResult
| Field | Type | Notes |
|---|---|---|
createdreq | Invitation[] | |
errorsreq | InvitationBatchError[] |
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.
| Field | Type | Notes |
|---|---|---|
tokenreq | string | 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.
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.
| Field | Type | Notes |
|---|---|---|
idreq | string | |
parent_idreq | string | null | Null for a root. A hierarchy may have several roots. |
codereq | string | The client's own code for this node (SAP, ERP); the import join key. |
namereq | string | |
levelreq | integer | |
pathreq | string | |
is_activereq | boolean | |
attributes | object | Open key-value for filters (channel, format, …). Never structure. |
valid_from | string | nulldate | Reserved for as-it-was-then reporting; unused in v1.3. |
valid_to | string | 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.
| Field | Type | Notes |
|---|---|---|
parent_id | string | null | |
codereq | string | |
namereq | string | |
is_active | boolean | |
attributes | object |
HierarchyImportRow
| Field | Type | Notes |
|---|---|---|
codereq | string | |
parent_code | string | null | Null marks a root. |
namereq | string | |
is_active | boolean | |
attributes | object |
HierarchyImportRequest
| Field | Type | Notes |
|---|---|---|
rowsreq | HierarchyImportRow[] | |
dry_run | boolean | Default true — an import that cannot be previewed will not be trusted. |
deactivate_missing | boolean | 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.
| Field | Type | Notes |
|---|---|---|
codereq | string | |
reasonreq | string |
HierarchyImportResult
| Field | Type | Notes |
|---|---|---|
dry_runreq | boolean | |
createdreq | integer | |
updatedreq | integer | |
movedreq | integer | Nodes whose parent changed — the expensive ones, since a move rewrites paths beneath. |
deactivatedreq | integer | |
unchangedreq | integer | |
errorsreq | HierarchyImportError[] | Unknown |
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.
KpiDescriptor
| Field | Type | Notes |
|---|---|---|
idreq | string | |
namereq | string | 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_i18n | object | 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. |
description | string | null | |
description_i18n | object | null | v1.9. Per-locale descriptions; |
kindreq | KpiKind | |
factreq | string | Which fact table it reads. |
unit | string | null | |
format | string | null | A display hint, e.g. |
numerator | string | null | For |
denominator | string | null | |
denominator_scope | DenominatorScope | |
denominator_ignores | HierarchyKind[] | Hierarchies whose filters the denominator drops, when |
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.
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.
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.
| Field | Type | Notes |
|---|---|---|
idreq | string | 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. |
namereq | string | Display label, in English. See |
name_i18n | object | null | Optional per-locale labels, keyed by language tag. |
kind | string | quarter · half · year · custom What the client calls this shape. Purely descriptive — grouping uses |
start_datereq | stringdate | |
end_datereq | stringdate | Inclusive. Must not be before |
MatrixAxis
One level of the row or column axis. Exactly one of hierarchy or time.
| Field | Type | Notes |
|---|---|---|
hierarchy | HierarchyKind | |
time | TimeGrain |
MatrixFilter
Restrict to a subtree, a time window, or an attribute. Filters compose with AND; several nodes of the same hierarchy compose with OR.
| Field | Type | Notes |
|---|---|---|
hierarchy | HierarchyKind | |
node_ids | string[] | |
from | string | nulldate | |
to | string | nulldate | |
attribute | string | null | |
values | string[] |
MatrixSort
| Field | Type | Notes |
|---|---|---|
kpireq | string | |
direction | string | asc · 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.
| Field | Type | Notes |
|---|---|---|
rowsreq | MatrixAxis[] | |
columns | MatrixAxis[] | |
kpisreq | string[] | |
filters | MatrixFilter[] | |
expand | string[] | Node ids that are open, as full paths from the first row axis ( |
sort | MatrixSort | |
limit | integer | Children returned per expanded node. |
cursor | string | null |
MatrixExportRequest
| Field | Type | Notes |
|---|---|---|
queryreq | MatrixRequest | |
max_depth | integer | null | Null exports the depth the query's |
filename | string | null |
MatrixCell
| Field | Type | Notes |
|---|---|---|
kpireq | string | |
value | number | null | Null means no facts matched — distinct from zero. |
numerator | number | null | |
denominator | number | 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
| Field | Type | Notes |
|---|---|---|
pathreq | string | Address of this row within the matrix, e.g. |
node_idreq | string | |
labelreq | string | |
levelreq | integer | Depth in the row axis, not in the hierarchy. |
axis | string | null | Which row axis produced this level — a hierarchy kind or a time grain. |
has_children | boolean | |
cellsreq | MatrixCell[] | |
column_key | string | null | Set when columns pivot, e.g. |
MatrixResponse
A slice, not a tree: the children of every expanded node with their cells. The client already holds the levels it opened.
| Field | Type | Notes |
|---|---|---|
rowsreq | MatrixRow[] | |
columns | string[] | Column keys in display order when the column axis pivots. |
kpisreq | KpiDescriptor[] | Descriptors for the requested KPIs, so a client can format without a second call. |
totals | MatrixCell[] | Grand totals over everything the caller may see, after filters. |
generated_atreq | stringdate-time | |
applied_from | string | nulldate | |
applied_to | string | 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. |
cached | boolean | |
next_cursor | string | null | |
truncated | boolean | True when a node had more children than |
CaptureKind
CaptureStatus
CaptureImage
| Field | Type | Notes |
|---|---|---|
storage_pathreq | string | |
angle | string | null | front, back, side, top, on_shelf — free text from the capture flow. |
trace_id | TraceId |
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.
| Field | Type | Notes |
|---|---|---|
idreq | string | |
product_id | integer | nullint64 | The (possibly |
proposed_name | string | null | |
proposed_brand | string | null | |
barcode | string | null | |
category_id | integer | nullint64 | |
capture_kindreq | CaptureKind | |
imagesreq | CaptureImage[] | |
captured_byreq | string | |
location_id | integer | nullint64 | |
statusreq | CaptureStatus | |
reviewed_by | string | null | |
notes | string | null | |
created_atreq | stringdate-time | |
updated_at | string | nulldate-time |
PlanogramLine
| Field | Type | Notes |
|---|---|---|
product_idreq | integerint64 | |
customer_node_idreq | string | Usually a fixture or shelf node; may be a store. |
target_facingsreq | integer | |
position | integer | 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.
| Field | Type | Notes |
|---|---|---|
idreq | string | |
namereq | string | |
valid_fromreq | stringdate | |
valid_to | string | nulldate | |
linesreq | PlanogramLine[] | |
created_by | string | null | |
created_at | string | 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.
| Field | Type | Notes |
|---|---|---|
idreq | string | |
namereq | string | |
owner_uidreq | string | |
sharedreq | boolean | |
requestreq | object | |
created_atreq | stringdate-time | |
updated_at | string | 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.
| Field | Type | Notes |
|---|---|---|
idreq | string | |
node_idreq | string | A customer-hierarchy node. The grant covers its whole subtree. |
granted_byreq | string | Firebase uid of the admin who granted it. |
granted_atreq | stringdate-time | |
expires_at | string | nulldate-time | For a temporary cover arrangement. Null never expires. |
note | string | null |
Tenant
The tenant root document tenants/{tenantId}. Server-owned.
| Field | Type | Notes |
|---|---|---|
idreq | TenantId | |
namereq | string | |
is_activereq | boolean | |
default_bucket | string | null | Overrides the shared bucket when a tenant has its own. |
retention | object | Set by a tenant admin through |
calendar | object | How this tenant names time. Two mechanisms, because clients need both. |
created_at | string | nulldate-time | |
visibility_scoped | boolean | v1.8. When true, a tenant member may read a visit only if they captured it, they administer the tenant, or their 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 So the order is explicit: build the hierarchies, run the backfill, verify, then set this. Server-owned. |
measurement | boolean | v1.24. Switches the shelf-measurement module on for this tenant (roadmap §12): photos carry a |
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.
| Field | Type | Notes |
|---|---|---|
total_tasksreq | integer | |
total_imagesreq | integer | |
category_counts | object | Keyed by stringified |
brand_counts | object | |
flavor_counts | object | |
product_counts | object |
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.
| Field | Type | Notes |
|---|---|---|
is_onlinereq | boolean | |
last_checkedreq | stringdate-time | |
status | integer | null | Last observed HTTP status from the health probe. |
error | string | 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.
| Field | Type | Notes |
|---|---|---|
min_version_codereq | integerint64 | The oldest build that may be used: Android's 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. |
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.
| Field | Type | Notes |
|---|---|---|
task_idreq | string | |
image_idreq | string | |
firebase_storage_pathreq | string | |
failed_atreq | stringdate-time | |
attemptreq | integer | |
last_error | string | null | |
trace_id | TraceId | The original capture's trace, so the retry stays on it. |
job_id | string | null | The failed job, when one was minted before failure. |
HealthStatus
| Field | Type | Notes |
|---|---|---|
statusreq | string | ok · degraded · unavailable |
versionreq | string | Deployed service version — the git SHA baked at build. HTTP mapping: |
models_loaded | string[] | Model identifiers currently resident, for warm-start diagnosis. |
uptime_seconds | number | nulldouble |
LogSeverity
Cloud Logging severity levels, used verbatim so that severity filtering and alerting need no mapping layer.
LogModule
Which part of the stack emitted the record. Always present.
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.
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.
| Field | Type | Notes |
|---|---|---|
timestampreq | stringdate-time | |
severityreq | LogSeverity | |
tenant_idreq | TenantId | The tenant in scope, or the reserved |
modulereq | LogModule | |
eventreq | LogEvent | |
trace_id | TraceId | Present whenever the work descends from a capture. |
message | string | null | Human-readable summary. Never parsed — put anything structured in |
task_id | string | null | |
image_id | string | null | |
user_id | string | null | |
location_id | integer | nullint64 | |
model_version | string | null | Set on |
duration_ms | integer | null | |
error | Error | |
payload | object | null | Module-specific detail; the only part of the record that is not shared vocabulary. |
Error
Uniform error body. code is stable and machine-readable; message is for humans and may change.
| Field | Type | Notes |
|---|---|---|
codereq | string | invalid_argument · unauthenticated · permission_denied · tenant_mismatch · not_found · conflict · model_unavailable · unavailable · internal Fixed HTTP mapping — 400 |
messagereq | string | |
details | object | null | |
request_id | string | null | Correlates with server logs. |