5.1 The install token#
Install mints one store token per install (role: service, note
MODULE - {id}, metadata: {module: id, version}, revealable: false
— no readable copy exists anywhere) with
access = intersectAccess(manifest.access, MODULE_MAX_ACCESS)— the ceiling withholdsadmin.deletealtogether (a module deletes through/admin/commit {op: 'delete'}under the per-path fence, §5.6), and never admitssecure.access,system,gen/generative(a module never spends a wallet),auth,promo, or themodulecontrol-plane group (the one exception — the provider events routesmodule.shipment.events,module.invoice.{events, pending},module.payment.events,module.order.events— is granted by the install handler per declaredprovideskind, §2.4, never frommanifest.access: a manifest's ownaccess.moduleis stripped before the intersect).admin.create/admin.addonorders/products/customersare refused at runtime (§5.4) — a manifest pairing them withdata.entity_fieldsgets themanifest_admin_entity_writewarning at check, not an error;scope = manifest.scope, validated as aScopeDatatree and against the deny list below;customerunset — a module never spends a wallet; every paid route answers400.
The raw token goes into custody (the platform's secret store under the
system parent, main project, unreachable to any token) and to the module
in handshake stage token; token_id lands on the install doc. The module
uses it exactly like any store token: DropCore.start({store, access_token, target}) and the admin / public / secure facades. Store scoping and
the path scope are enforced by the core on every call.
Hard deny list (validateManifest, on top of MODULE_MAX_ACCESS):
scopemay not grantsetupbeyond whatsetup/modulesderives (setup/hooks,setup/smtp,setup/payments,setup/analyticsare never module-readable), normembers, nor$storewrite, nor anything under$promo(promo/incoreis the platform's wallet store).accessmay not namesecure(exceptsecure.secret),system,mailing.sendwithplatformFallback, oradmin.uploadunlessmanifest.datalists arecordscollection (files a module writes land underfiles/modules/{id}/**).
/secure/access refuses op: 'update' / 'delete' on a token whose note
starts with MODULE - (409 managed_token); managed tokens change only
through install / update / reroll / uninstall.
Tier gate (the tier catalog decides). The store's resolved tier
(Tier.resolve: the effective plan from store/incore/customers/{sid} —
subscription ⊕ platform grant — folded with the catalog
store/incore/setup/limits and the store's overrides.{sid},
../plans/console/tier-limits-gating.md r5) answers two questions, in this order:
features.modules—none/connected/hosted(hostedadmitsconnected). The committed default:tier_0none,tier_1connected,tier_2/tier_3hosted. Not admitted →403 {error: 'tier_required', plan, hosting, feature}— before any count, before any handshake.plans.*.modules_max— the per-store install cap (null= unlimited; default 10 on every plan that may install).count >= cap→403 {error: 'modules_limit', plan, limit, count}. The count is the store's code-module installs — elements modules are not in it.
An elements module (hosting: 'none') answers neither question: every
features.modules value admits it (hostingAllowedByFeature, so the
catalog marks it admitted on every plan) and it counts against its own cap,
plans.*.element_modules_max (absent = the committed default, null =
unlimited — the default on every tier), over the store's hosting == 'none'
installs only → 402 {error: 'element_modules_limit', plan, limit, count}.
A platform-owned package published with visibility: 'platform' skips
step 1 and never meets a cap below the committed default; the count still
binds. master is not exempt — the console is master, so the gate is
real. The gate is always hard: the catalog's enforce switch governs the
metered dimensions (Limits.guard), not this feature gate. A catalog edit
(a row, or a store override such as overrides.{sid}.modules: 'hosted')
takes effect on the next request — the storeWritten dispatcher bumps the
tier cache.
Per call, too. The gateway re-checks features.modules on every call
(Gateway.assertTier): a store whose plan lapses below the install's
hosting stops calling it (403 tier_required, a platform-side refusal like
module_disabled — callbacks take their fallback). The count cap is an
install-time rule only; a downgrade never uninstalls. The install doc's
tier_exempt stamp means this needs no registry read; a tier read
failure fails open here (logged) and closed at install.
In the catalog. POST /api/module/catalog items carry admitted
(false = listed, not installable on this plan) and visibility; the
console shows Needs a higher plan instead of Install.
5.2 Secrets — module_{id}_* only#
A module's own credentials are what its secret config fields hold. The
console writes them write-only through POST /api/module/configure {id, config, secrets} → the store's ordinary secret store as
module_{id}_{field}; the response carries has_secrets: {[field]: true},
never values. The module reads them itself with its token through
POST /secure/secret when the manifest grants secure.secret — and
that grant is fenced: when the caller is a module token, key must match
^module_{id}_, key: '*' is refused (403 module_secret_scope) and a
list reply is filtered to that prefix. Without this one rule the deny
list's own exception would hand a module every credential the store holds
(Stripe, GA4, SMTP). Secrets are never on the envelope and never sent to
verify; the platform's custody of the install token sits in a different,
unreachable parent.
5.3 What rides every envelope: config#
The install's non-secret config map is on every envelope body (§3.2)
— a module never reads its own install doc per call, and a configure in
the console reaches the module on its next call. Public fields
(public: true) are additionally exposed to the storefront as the derived
doc store/{sid}/setup/modules = {public: true, [moduleId]: {publicConfig}}, read through POST /public/setup {name: 'modules'}, so
element JS reads widget keys without a round trip.
5.4 Fields on core entities: {entity}.modules.{id}#
orders.modules.{id}, products.modules.{id}, customers.modules.{id} are
the module's own annotation space on a core entity (the "app-owned
metafield" idea). Enforced in crud.update: when the caller is a module
token, every top-level key of the update must be a dotted path
modules.{moduleId}.… (or exactly modules.{moduleId}, or timestamp) and
the path must be store/{sid}/{orders|products|customers}/{id} for an
entity the manifest lists in data.entity_fields; otherwise 403 Module tokens may only write modules.{id} (dotted field paths). Write dotted
keys, never a nested object: {"modules.echo.erp_id": "A1"} is accepted,
{"modules": {"echo": {"erp_id": "A1"}}} is refused — Firestore's update()
patches a dotted path in place but replaces a top-level map wholesale, so the
nested form would wipe every other module's field on the same document.
create / add / delete on those three collections are refused for
module tokens outright. The one exception is module-owned products. A commit create of a type: 'service' product whose id falls under one of the manifest's prefix: scope grants on products ({"products": {"prefix:reservation-": true}}) is allowed with name / code / status / availability / tags / categories / modules.{id}, and the core stamps owner_module. That module may then update those keys (not type) on the products it created. A product the merchant made, even one inside the prefix, stays at modules.{id}. This lets a booking module create and price the products it sells (functions/docs/api.md).
The order form renders modules.* under a
collapsed, read-only Modules section. A module never writes
order.shipping — a carrier's data reaches the core through the callbacks.
5.5 Private state and records#
- State —
store/{sid}/modules/{id}/state/{key}: a KV the module owns (cursors, ERP mappings, watermarks), reachable through/admin/read|update|create|deletewith the install token whosescopehasmodules: {[id]: true}. Writes here fire no events. Keys starting with_are the platform's —_schedule(theschedulepoint's bookkeeping) and_cache_{sha256}(the response cache above) live here too; a module reads them but must not write them, and every other key is its own. - Records —
store/{sid}/modules/{id}/{collection}/{docId}: a records collection is a direct subcollection of the install doc (six segments;state,outboxandlogsare reserved names — a records collection named like one is refused at check,collection_reserved, and a write there fires nothing). Transactional, unlocalized rows the module writes through/admin/updateor/admin/commit(declared asdata.collections[].kind: 'records'; the subtree appears on the first write). Writes fire themodule_recordsevent (§6).uninstall {purge}removes every subcollection present under the install doc, declared or not. The console's generic records view lists them (/store/:sid/modules/:id/records/:collection). - Content —
data.collections[].kind: 'content': ordinary localized collections (data/{locale}/{collection}) registered at install withDataCollection.module = idand a schema. They share the store's data and schema namespaces with the store's own collections and every other module's, so each is named with the module prefix,{id}_{name}(or the bare{id}; e.g.reservations_resources,reservations_services) — a module never "blocks" a common name likeservices. Refused at check otherwise,collection_prefix. They are listed and edited in the console's collection view, read by the storefront's/content. A module token reaches them only wherescopegrants the collection explicitly. - Files — under
files/modules/{id}/**, and only with arecordscollection declared (theadmin.uploadrule above).
5.6 Atomic writes: POST /admin/commit#
POST /api/admin/commit
{writes: [{path, op: 'create' | 'set' | 'update' | 'delete', value?,
precondition?: {exists?: boolean; update_time?: string /* ISO 8601 */}}]} // ≤ 100 writes
→ 200 {ok: true, results: [{path, update_time}]}
→ 409 {error: 'precondition_failed', index, code: 'ALREADY_EXISTS' | 'FAILED_PRECONDITION'}
→ 403 (any path outside store/{sid}/… or denied by scope)One Firestore batch, all-or-nothing: create fails when the doc exists;
set with {exists: false} is a create; update / delete with
update_time require the doc's last update time to match (a compare-and-set
over a value read with POST /api/admin/batch {…, meta: true}, which returns
_meta: {[path]: {update_time}} beside the docs). Every path must sit under
the request's store and pass scope for write. Any token may use it; it
exists for modules that sync against an external system and must not
half-apply. SDK: admin.data.commit(writes).