Skip to content

ADR 0001 — Appwrite integration architecture

Purpose: the single authoritative record of the cross-repo architecture decisions for the Appwrite migration. Every sibling ticket cites this ADR instead of re-deriving or re-litigating the choices.

  • Status: Accepted
  • Date: 2026-06-25
  • Deciders: terminales engineering + architecture (human sign-off, issue #101)
  • Repos: Multi — terminales-api (NestJS), terminales (Android/Kotlin), terminales-web (Next.js)
  • Related: architecture-decisions (the rationale table this ADR formalizes), appwrite, ../business-rules/auth-and-multitenancy.md

The migration to Appwrite as the primary backend makes several architectural choices that span all three client/service repos. They were decided up front (no spikes) so they could be baked into the backlog tickets rather than rediscovered per ticket. This ADR records each decision, its rationale, and the alternatives that were considered and rejected.

Phase context: the system is pre-production (no live users), and the legacy stack stays live until cutover — both facts shape several decisions below (notably feature flags, migrations, and the legacy stack itself). Decisions are recorded as Accepted; do not re-open without a superseding ADR.

Use the Appwrite SDKs as the primary access path: the Web SDK on Next.js and the Kotlin SDK on Android. REST is a fallback only. GraphQL is not used.

Rationale: the SDKs are first-class and the smallest maintenance surface; this also standardizes the Android configuration, which previously carried both SDK and GraphQL endpoints.

  • GraphQL → dropped. Extra surface to maintain, and it was redundantly dual-configured on Android.
  • REST as the primary CRUD path (a server proxy) → rejected; clients talk to Appwrite directly under RLS (see Server boundary).

Data strategy — offline-first Android, hybrid Web

Section titled “Data strategy — offline-first Android, hybrid Web”

The Android POS terminal is offline-first: a local cache, queued writes, and clientId-idempotent sync. The Web admin is online/hybrid (built mock-first, then wired to Appwrite).

Rationale: a POS terminal must keep selling without a network; admin tooling does not have that constraint.

  • Online-only terminal → rejected; unacceptable for retail — the terminal cannot stop selling when connectivity drops.
  • Offline-first Web admin → rejected; needless complexity for a tool that is always operated online.
Section titled “Auth sessions — JWT (Android/API) + cookie (Web SSR)”

Android mints an Appwrite JWT, validated by the NestJS guard for native and server-to-server calls. Next.js uses a secure cookie session for SSR. PIN login is the one custom flow: NestJS verifies the PIN and mints an Appwrite custom token the client exchanges for a session.

Rationale: cookies fit server-side rendering; JWTs fit native apps and server-to-server validation.

  • BetterAuth → replaced; Appwrite Account now owns identity, sessions, MFA, and recovery.
  • Cookie-only sessions → rejected; do not fit the native Android client.
  • TOTP / authenticator MFA → rejected for now; MFA uses email/SMS factors only (SMS needs a configured provider per env).

Multitenancy — Store = Team, single shared DB, document RLS

Section titled “Multitenancy — Store = Team, single shared DB, document RLS”

Store = an Appwrite Team (the tenant boundary; membership roles cashier / supervisor / manager). Merchant = a merchants collection grouping stores via stores.merchantId. All data lives in one shared database; isolation is enforced by document-level permissions (RLS) — Role.team(storeTeamId[, role]). Labels (Role.label(...)) are reserved for cross-merchant platform staff.

Rationale: Teams give native per-store roles; the merchant is flat organizational data (Appwrite teams cannot nest); RLS is the single enforcement point, so isolation is a data-layer guarantee, not UI filtering.

  • Database-per-merchant → rejected; operational cost and it forecloses cross-store reporting. (Portability tiers that could split DB/project later without redesign are documented in ../business-rules/roles-permissions-and-labels.md.)
  • Merchant = Team → rejected; Appwrite teams are flat and cannot nest stores under a merchant.
  • pos_roles / pos_permissions collections → rejected; the role→permission mapping is a code matrix (src/auth/roles/pos-roles.ts), not data.

See also the Appwrite multi-tenancy guidance, which prescribes exactly this Teams + Role.team(...) model.

Appwrite Functions are deployed via CI/CD (appwrite push in GitHub Actions) per environment; the CLI is used for local development.

Rationale: consistent, reproducible, auditable dev → staging → prod deploys.

  • Manual console deploys → rejected; not reproducible or auditable across environments.

Schema-as-code — appwrite.json, no migrations (pre-prod)

Section titled “Schema-as-code — appwrite.json, no migrations (pre-prod)”

appwrite.json is the single declaration of databases, collections, attributes, indexes, relationships, buckets, and functions, applied per environment via appwrite push. Type generation (appwrite types) feeds the API and Web so types never drift from the schema. No migration scripts before production.

Rationale: reproducible environments and drift-free types; with no live data pre-production, appwrite push is the right substitute for migrations.

  • Migration scripts before production → rejected; there is no live data to migrate yet, so they would be premature ceremony. (Introduced at production cutover.)
  • Hand-written types → rejected; they drift from the schema.

No feature flags pre-production. Every Appwrite ticket ships feature_flag: "". At cutover, the Epic G production-flags ticket introduces runtime appwrite_* flags (toggled without redeploy, evaluated from a feature_flags collection + Realtime) plus a kill-switch.

Rationale: there are no live users to protect, so gating no-op code behind flags adds cost without benefit. Future agents must not “fix” this by adding flags prematurely.

  • Flags from day one → rejected; gating code with no users to protect is wasted complexity in the pre-production phase.

Clients call Appwrite directly for simple reads/writes under RLS. NestJS is not a CRUD proxy. NestJS/DBOS keeps only complex, durable, or server-authority logic: settlement, PSP capture/retry, fiscal stamping, token validation, PIN custom-token minting, user/team admin, and Storage token issuance.

Rationale: less code and lower latency; RLS is the access choke point, so pass-through controllers add nothing.

  • NestJS pass-through controllers for CRUD → rejected; they duplicate the SDK, add latency, and bypass nothing because RLS already enforces access. (The generic server “repository abstraction” ticket was removed for the same reason.)

See nestjs-dbos for the precise server-side surface.

TypeORM + BetterAuth stay in service for legacy (non-POS) domains until the Epic G decommission ticket. The POS Product is a new Appwrite collection in its own namespace — the legacy loan Product is untouched.

Rationale: migrate safely and reversibly; the migration can be rolled back at cutover because the legacy stacks remain until flags are ramped.

  • Big-bang migration → rejected; too risky without a fallback during the transition.
  • Renaming legacy Product to free the name → rejected; a risky FK rewrite, made unnecessary because the POS Product is a separate Appwrite collection.

These are decided and binding but owned by other docs — pointers, not duplicates:

  • Roles via team membership; Labels for platform. POS role = store-team membership; role→permission map is the pos-roles.ts code matrix; Labels reserved for platform staff. Full catalog + permission matrix: ../business-rules/roles-permissions-and-labels.md.
  • Employees = profile, not identity. Identity/credentials/MFA live in Appwrite Auth users; employees is a POS profile (pinHash, storeTeamId) linked 1:1 by userId. See ../business-rules/auth-and-multitenancy.md.
  • 2FA via email/SMS factors only (no TOTP). See Auth sessions.
  • Presence via the Appwrite Presences API (GA 2026-05-25), not a custom heartbeat collection.
  • Deferral — no feature flags until production. See Feature flags.
  • Deferral — no database migrations until production. See Schema-as-code.

Future agents must not “fix” the two deferrals by adding flags or migration scripts prematurely. Living detail: open-questions-and-todos.