# AutoFolio > Costs, service, documents, value and sale of every car you had, have or want. In English, Portuguese and German, in any currency. AutoFolio is a lifelong digital garage for current, sold and wished-for cars. Public documentation contains no account, vehicle or organization data. Authenticated tools apply the same organization permissions as the app. ## Public pages - [Developer docs](https://autofolio.its-live.dev/docs): Setup, architecture and development guides. - [Changelog](https://autofolio.its-live.dev/docs/changelog): Changes to the template. - [Overview](https://autofolio.its-live.dev/index.md): What AutoFolio does, how it works and its plans, from the website. - [How we protect your cars](https://autofolio.its-live.dev/privacy.md): AutoFolio's privacy principles. - [Terms of service](https://autofolio.its-live.dev/terms.md): The template's terms and replacement notice. - [Full documentation](https://autofolio.its-live.dev/llms-full.txt): Public pages, demo content and the agent connection guide. ## Developer docs Developer documentation is available in English only. UI language does not translate these documents. - [Architectural decisions](https://autofolio.its-live.dev/docs/adr.md): Read the original decisions, their constraints and their recorded evidence. - [Backend shape](https://autofolio.its-live.dev/docs/architecture/backend-shape.md): Why every backend feature has the same files, why there is no barrel and no repository layer, and where Effect runs. - [Client/server boundary](https://autofolio.its-live.dev/docs/architecture/client-server-boundary.md): Keep server capabilities out of browser and native module graphs. - [Effect at the edges](https://autofolio.its-live.dev/docs/architecture/effect.md): Use stable Effect APIs for owned server work without changing public contracts. - [Architecture](https://autofolio.its-live.dev/docs/architecture.md): One Worker owns web rendering, API requests and scheduled execution. - [Observability and tracing](https://autofolio.its-live.dev/docs/architecture/observability.md): Read structured errors, durable audit records and Cloudflare spans as separate signals. - [Request lifecycle](https://autofolio.its-live.dev/docs/architecture/request-lifecycle.md): Follow authority and database lifetime from the incoming Request to the response. - [Changelog](https://autofolio.its-live.dev/docs/changelog.md): New features, architectural changes and improvements to AutoFolio. - [Deployment and rollback](https://autofolio.its-live.dev/docs/deploy.md): GitHub Actions deploy isolated previews and the production Worker with reviewed migrations. - [Backend conventions](https://autofolio.its-live.dev/docs/development/backend-conventions.md): The rules for code in packages/api, each with its reason, an example, the anti-pattern and what enforces it. - [Conventions](https://autofolio.its-live.dev/docs/development/conventions.md): Use the existing owners and enforce boundaries in code and tooling. - [Writing documentation](https://autofolio.its-live.dev/docs/development/docs.md): Author repository MDX, reuse architectural decisions and curate release notes. - [Enforcement](https://autofolio.its-live.dev/docs/development/enforcement.md): Which rule is checked by what: type checks, lint, tests, build and deploy gates, scripts, and the rules that still rely on review. - [Environment and secrets](https://autofolio.its-live.dev/docs/development/environments.md): Keep Node, Worker and native configuration aligned without exposing credentials. - [Review checklist](https://autofolio.its-live.dev/docs/development/review-checklist.md): Review or self-review a change against the repository's rules, with the command or test that checks each item. - [Runbooks](https://autofolio.its-live.dev/docs/development/runbooks.md): Symptom, command and verification for the operational procedures the template supports. - [Workspace scripts](https://autofolio.its-live.dev/docs/development/scripts.md): Run the repository’s existing entry points from the workspace root. - [Local setup](https://autofolio.its-live.dev/docs/development/setup.md): Configure a clone without overwriting an existing project’s identity or credentials. - [Testing and verification](https://autofolio.its-live.dev/docs/development/testing.md): Verify domain behavior against real Postgres and the deployed runtime shape. - [Web conventions](https://autofolio.its-live.dev/docs/development/web-conventions.md): The rules for code in apps/web/src, each with its reason, an example, the anti-pattern and what enforces it. - [Account preference](https://autofolio.its-live.dev/docs/features/account-preference.md): How a User reads and changes their locale, main currency, units and reminder channels, and why the data region is read-only. - [Acquisition](https://autofolio.its-live.dev/docs/features/acquisition.md): How a car's purchase (M-Compra) is saved as an append-only header and ledger entries, why filling in a blank is not a correction, how the amount financed opens a Loan, and why a replay never saves twice. - [Administration](https://autofolio.its-live.dev/docs/features/admin.md): Current platform authority protects User management, audits, flags and diagnostics. - [Agent-readable pages](https://autofolio.its-live.dev/docs/features/agent-pages.md): Public Markdown and discovery derive from the same content developers read. - [AI extras](https://autofolio.its-live.dev/docs/features/ai-extras.md): A sale listing from the passport, search across a car's documents and services, translation of a foreign document beside its original, and a maintenance plan read from the owner's manual. - [AI foundation](https://autofolio.its-live.dev/docs/features/ai.md): How every AI feature gets its model from one registry on TanStack AI, its flag, the plan quota it counts against, and the scrubber on everything it sends. - [oRPC conventions](https://autofolio.its-live.dev/docs/features/api.md): Share Zod contracts and typed clients while keeping authorization on the server. - [Appearance](https://autofolio.its-live.dev/docs/features/appearance.md): How Profile → Appearance colours the app Monochrome, by the current car's cover colour or Warm, where the cover colour comes from, and what stays neutral. - [AI assistant and tools](https://autofolio.its-live.dev/docs/features/assistant.md): TanStack AI streams a private conversation through one authorized tool catalog. - [Authentication and Organizations](https://autofolio.its-live.dev/docs/features/auth.md): Better Auth owns identity, sessions, Organizations, platform roles and OAuth. - [Data export and erasure](https://autofolio.its-live.dev/docs/features/data-export.md): How a Member exports the household's data as CSV and PDF, and what deleting an account erases, when. - [Database and migrations](https://autofolio.its-live.dev/docs/features/database.md): Drizzle manages one Postgres schema through reviewed, journaled SQL migrations. - [Sale](https://autofolio.its-live.dev/docs/features/disposal.md): How closing a sale (M-Fechar-Venda) ends a car's ownership period without deleting anything, pays off its Loan, and shows the deal result and the economic result apart. - [Documents](https://autofolio.its-live.dev/docs/features/documents.md): Convert supported files to Document Text in the browser, index it from cron and read it through the shared tool catalog. - [Email](https://autofolio.its-live.dev/docs/features/email.md): One transport boundary serves verification, recovery, invitations and security notices. - [Expense](https://autofolio.its-live.dev/docs/features/expense.md): How an expense is saved into the ledger with its odometer reading, fuel and receipt, how a receipt photo or a spoken sentence becomes a draft to confirm, why a likely duplicate is a warning, how a correction works, why a replay never saves twice, and how costs add up by category and period. - [Family garage](https://autofolio.its-live.dev/docs/features/family-garage.md): How M-Familia shares a household's cars with a preset or an area matrix per car: vehicle_member grants (until a date, with approvals), invite links and QR, the activity line, handing a car to another Member, the row-level security that hides ungranted cars and areas, and the permissions procedures enforce. - [Feature flags](https://autofolio.its-live.dev/docs/features/feature-flags.md): Typed code defaults with database overrides and separate capability gates. - [Files and uploads](https://autofolio.its-live.dev/docs/features/files.md): Send files directly to R2, confirm durable upload intent and recover interrupted multipart uploads. - [Garage and catalog](https://autofolio.its-live.dev/docs/features/garage.md): How a Member adds a Vehicle from the NHTSA catalog or by hand, edits it, and reads the Organization's garage, with the Free plan limit. - [History transfer](https://autofolio.its-live.dev/docs/features/history-transfer.md): How a seller hands a car's technical history to the next owner's AutoFolio account: the materialized offer, both consents, the system path the buyer reads it through, and what never travels. - [Internationalization](https://autofolio.its-live.dev/docs/features/i18n.md): Portuguese, English and German UI with request-scoped SSR translation. - [Features and flows](https://autofolio.its-live.dev/docs/features.md): Trace each feature through contracts, authority, persistence and presentation. - [Insights](https://autofolio.its-live.dev/docs/features/insights.md): How M-Insights computes a car's total cost of ownership and cost per km or mile for a period, compares it across the garage's cars, and how proactive insights, last month's summary and the dashboard photo work. - [Loan](https://autofolio.its-live.dev/docs/features/loan.md): How a car's financing (M-Financiamento) keeps its balance as an append-only history, books interest and fees to the ledger, never counts principal as an expense, and opens or edits a Loan from the app. - [MCP and agent connections](https://autofolio.its-live.dev/docs/features/mcp.md): OAuth-authenticated agents use the existing Worker and the shared tool catalog. - [Mobile app](https://autofolio.its-live.dev/docs/features/mobile.md): Expo Router uses the shared domain API with native navigation and scoped local state. - [Moments and share cards](https://autofolio.its-live.dev/docs/features/moments.md): How the garage's Moments are found in records the app already has, announced each morning, and shared as a card made on the phone that is never uploaded. - [Open Graph images](https://autofolio.its-live.dev/docs/features/og-images.md): Takumi renders typed React templates to PNG on Node and workerd. - [Partner workshops and dealers](https://autofolio.its-live.dev/docs/features/partners.md): How M-Oficinas and the partner portal D-Parceiros let an approved workshop verify a service and a dealer hand over a car, with the owner's consent and an audit trail. - [Passports](https://autofolio.its-live.dev/docs/features/passport.md): How Prepare sale (M-Vender) shares a car as a field-by-field snapshot behind a hashed link, how the buyer opens it on the web without signing up, and how Passports (M-Passaporte) shows visits, moves an expiry and revokes a link. - [Payments](https://autofolio.its-live.dev/docs/features/payments.md): Stripe, Polar and RevenueCat feed one server-verified entitlement model. - [Privacy centre](https://autofolio.its-live.dev/docs/features/privacy.md): P-Central, what I share with Revoke, consents per purpose and the right of access: what the app shows, where the counts come from, and how a no to AI turns every AI feature off. - [Recurring expense](https://autofolio.its-live.dev/docs/features/recurring-expense.md): How a scheduled cost (insurance, a loan payment, parking, a tax) becomes an expected entry the owner confirms into the ledger or skips, shown in M-Gastos and in the alerts. - [Document scanner](https://autofolio.its-live.dev/docs/features/scanner.md): How a car document photographed on the phone becomes a draft of its type, grounded in the text read, and is saved only when the person confirms. - [Scheduled jobs](https://autofolio.its-live.dev/docs/features/scheduled-jobs.md): One cron tick settles six concurrent jobs, then indexes Document Text within the same deadline and scoped pool. - [Services and reminders](https://autofolio.its-live.dev/docs/features/service.md): How a service is recorded into the ledger with its items, reading and private receipt, how a correction keeps its history, and how reminders are due by date or distance with a forecast from the car's readings. - [Showcase links](https://autofolio.its-live.dev/docs/features/showcase.md): How a public showcase of one car or the garage is built from the passport's machinery, how Live mode keeps it current, and how the page at /g/:token shows it without plate, VIN or prices. - [Sign-in and security](https://autofolio.its-live.dev/docs/features/sign-in-security.md): P-Entrar's passkey-first sign-in, Apple and Google setup, P-Seguranca, new sign-in alerts, the app switcher cover and the household's 2FA requirement for sharing. - [Spreadsheet import](https://autofolio.its-live.dev/docs/features/spreadsheet-import.md): How an owner's old spreadsheet (CSV) becomes ledger entries: the mapping and preview on the device, the leased import job, RF10 duplicates and an undo that deletes nothing. - [Cinematic Studio](https://autofolio.its-live.dev/docs/features/studio.md): How an ordinary photo of the car gets a cinematic background and light while the car's own pixels are checked against the original, its plate blurred and the original kept. - [Vehicle documents](https://autofolio.its-live.dev/docs/features/vehicle-documents.md): How a car's private documents are filed from the template's uploads, listed by type with their status, opened through a five-minute signed link, and how a due date becomes a reminder. - [History](https://autofolio.its-live.dev/docs/features/vehicle-history.md): How the History of a car and of the collection (M-Historico) is derived from purchases, sales, services, upgrades, documents, valuations and flagged odometer readings, sold cars included. - [Vehicle photos](https://autofolio.its-live.dev/docs/features/vehicle-photos.md): How a car's gallery is filled from the template's uploads, how one photo is the cover the garage and the profile show, and the Free plan's 50 photos per car. - [Webhooks and notifications](https://autofolio.its-live.dev/docs/features/webhooks.md): Durable outboxes own delivery and retries across interruptions. - [Developer documentation](https://autofolio.its-live.dev/docs.md): Build, run and extend AutoFolio, the lifelong garage for current, sold and wished-for cars. - [Libraries and version policy](https://autofolio.its-live.dev/docs/libraries.md): Major dependencies, their declared versions, owners and upgrade constraints. - [ads](https://autofolio.its-live.dev/docs/packages/ads.md): Optional native advertising: responsibilities, supported exports and extension points. - [api](https://autofolio.its-live.dev/docs/packages/api.md): Authorized operations and shared contracts: responsibilities, supported exports and extension points. - [app-update](https://autofolio.its-live.dev/docs/packages/app-update.md): Native update policy: responsibilities, supported exports and extension points. - [auth](https://autofolio.its-live.dev/docs/packages/auth.md): Identity and request scope: responsibilities, supported exports and extension points. - [config](https://autofolio.its-live.dev/docs/packages/config.md): Tooling configuration: responsibilities, supported exports and extension points. - [db](https://autofolio.its-live.dev/docs/packages/db.md): Postgres schema and durable state: responsibilities, supported exports and extension points. - [email](https://autofolio.its-live.dev/docs/packages/email.md): Transactional email delivery: responsibilities, supported exports and extension points. - [env](https://autofolio.its-live.dev/docs/packages/env.md): Validated environment contracts: responsibilities, supported exports and extension points. - [errors](https://autofolio.its-live.dev/docs/packages/errors.md): Stable error vocabulary: responsibilities, supported exports and extension points. - [flags](https://autofolio.its-live.dev/docs/packages/flags.md): Feature registry and overrides: responsibilities, supported exports and extension points. - [Packages](https://autofolio.its-live.dev/docs/packages.md): The workspace contains 17 packages with explicit ownership and supported import paths. - [intl](https://autofolio.its-live.dev/docs/packages/intl.md): Request-scoped language: responsibilities, supported exports and extension points. - [project-setup-cli](https://autofolio.its-live.dev/docs/packages/project-setup-cli.md): Project identity and provisioning workflow: responsibilities, supported exports and extension points. - [purchases](https://autofolio.its-live.dev/docs/packages/purchases.md): Native purchase coordination: responsibilities, supported exports and extension points. - [react-utils](https://autofolio.its-live.dev/docs/packages/react-utils.md): Small shared React helpers: responsibilities, supported exports and extension points. - [storage](https://autofolio.its-live.dev/docs/packages/storage.md): Private file gateway: responsibilities, supported exports and extension points. - [ui](https://autofolio.its-live.dev/docs/packages/ui.md): Shared web primitives: responsibilities, supported exports and extension points. - [widgets](https://autofolio.its-live.dev/docs/packages/widgets.md): The next-service home-screen widget: responsibilities, supported exports and extension points. - [ADR-0001 — Stored file keys keep the user's filename](https://autofolio.its-live.dev/docs/adr/0001-stored-file-keys-keep-the-user-s-filename.md): The repository's architectural decision, rationale and consequences. - [ADR-0002 — Server-capable modules have no barrel](https://autofolio.its-live.dev/docs/adr/0002-no-barrels-on-server-capable-modules.md): The repository's architectural decision, rationale and consequences. - [Organizations are the tenant](https://autofolio.its-live.dev/docs/adr/0003-organizations-are-the-tenant.md): The repository's architectural decision, rationale and consequences. - [Payments: one entitlement read model, three provider modules](https://autofolio.its-live.dev/docs/adr/0004-payments-providers.md): The repository's architectural decision, rationale and consequences. - [Effect stays at the server edges](https://autofolio.its-live.dev/docs/adr/0005-effect-boundary.md): The repository's architectural decision, rationale and consequences. - [Todo imports and the PersistedQueue gate](https://autofolio.its-live.dev/docs/adr/0006-effect-persisted-queue.md): The repository's architectural decision, rationale and consequences. - [AI assistant and one tool catalog](https://autofolio.its-live.dev/docs/adr/0007-ai-assistant-and-tool-catalog.md): The repository's architectural decision, rationale and consequences. - [Documents and direct uploads](https://autofolio.its-live.dev/docs/adr/0008-documents-and-large-uploads.md): The repository's architectural decision, rationale and consequences. - [Row-level security isolates Organizations](https://autofolio.its-live.dev/docs/adr/0009-row-level-security.md): The repository's architectural decision, rationale and consequences. - [Procedures run one Effect program at their edge](https://autofolio.its-live.dev/docs/adr/0010-procedures-run-one-effect-program.md): The repository's architectural decision, rationale and consequences. - [Backend features have a fixed file shape](https://autofolio.its-live.dev/docs/adr/0011-backend-feature-shape.md): The repository's architectural decision, rationale and consequences. - [Web features mirror the backend shape](https://autofolio.its-live.dev/docs/adr/0012-web-features-mirror-the-backend-shape.md): The repository's architectural decision, rationale and consequences. - [Mobile features mirror the backend shape](https://autofolio.its-live.dev/docs/adr/0013-mobile-features-mirror-the-backend-shape.md): The repository's architectural decision, rationale and consequences. - [AutoFolio on the template](https://autofolio.its-live.dev/docs/adr/0014-autofolio-on-the-template.md): The repository's architectural decision, rationale and consequences. - [The AutoFolio data model](https://autofolio.its-live.dev/docs/adr/0015-autofolio-data-model.md): The repository's architectural decision, rationale and consequences. - [ADR-0016: Appearance themes and car colour](https://autofolio.its-live.dev/docs/adr/0016-appearance-and-car-colour.md): The repository's architectural decision, rationale and consequences. - [AI features: one model registry, a flag per feature, plan quotas](https://autofolio.its-live.dev/docs/adr/0017-ai-model-registry-and-quotas.md): The repository's architectural decision, rationale and consequences. - [ADR-0018: The copilot draws typed tool results as fixed cards (Plan B), not OpenUI](https://autofolio.its-live.dev/docs/adr/0018-copilot-generative-ui.md): The repository's architectural decision, rationale and consequences. - [Erasure after a grace period](https://autofolio.its-live.dev/docs/adr/0019-erasure-after-a-grace-period.md): The repository's architectural decision, rationale and consequences. - [Connect your agent](https://autofolio.its-live.dev/docs/connect-your-agent.md): Connect an external agent through MCP OAuth and enable the optional assistant. - [Setup, storage and localization](https://autofolio.its-live.dev/docs/changelog/2026-06-10-setup-storage-and-i18n.md): The early template gains repeatable configuration, object storage and language support. - [Administration and data workflows](https://autofolio.its-live.dev/docs/changelog/2026-07-13-admin-and-data-workflows.md): Admin operations and server-backed tables extend the starter application. - [Native interface foundations](https://autofolio.its-live.dev/docs/changelog/2026-07-24-native-foundation.md): Expo screens adopt the native UI direction. - [An enforced client/server boundary](https://autofolio.its-live.dev/docs/changelog/2026-07-30-client-boundary.md): Shared constants stop pulling server capabilities into browser bundles. - [Cloudflare email and shared UI](https://autofolio.its-live.dev/docs/changelog/2026-08-13-email-and-ui.md): Transactional delivery and the shared interface foundation expand. - [Agent connections and native account security](https://autofolio.its-live.dev/docs/changelog/2026-08-24-agent-connections-and-account-security.md): OAuth-backed MCP connections and mobile security flows expand the template. - [Typed domain operations and a real dashboard](https://autofolio.its-live.dev/docs/changelog/2026-08-25-typed-domain-and-dashboard.md): Dashboard data, Todo state and administrative writes become more explicit. - [oRPC and Drizzle upgrades](https://autofolio.its-live.dev/docs/changelog/2026-09-03-typed-api-upgrade.md): The API and database stack advance with compatibility work across their consumers. - [Platform dependency alignment](https://autofolio.its-live.dev/docs/changelog/2026-09-24-platform-dependencies.md): React, Drizzle, files and test tooling move onto verified compatible versions. - [Authentication foundations and performance](https://autofolio.its-live.dev/docs/changelog/2026-09-25-auth-and-performance.md): Auth state has clearer ownership on web and mobile, with production build checks. - [Runtime and recovery hardening](https://autofolio.its-live.dev/docs/changelog/2026-10-03-runtime-and-recovery.md): Template fixes tighten persisted work and production-runtime behavior. - [Payments, native screens and a new landing page](https://autofolio.its-live.dev/docs/changelog/2026-10-12-payments-and-native-experience.md): Optional billing providers, a refreshed native experience and clearer product presentation land together. - [TanStack Start 1.0, OG images and durable imports](https://autofolio.its-live.dev/docs/changelog/2026-10-13-start-og-and-imports.md): The web runtime advances, social images render in the Worker, and imports gain bounded queue execution. - [Trace and code block primitives](https://autofolio.its-live.dev/docs/changelog/2026-10-14-trace-and-code-blocks.md): Shared shadcn components support readable execution traces and highlighted code. - [AI assistant and one tool catalog](https://autofolio.its-live.dev/docs/changelog/2026-10-15-assistant-and-agent-tools.md): The assistant, MCP and agent-readable pages now share the application’s authorized operations. - [Dependency round, i18next fix and ReUI controls](https://autofolio.its-live.dev/docs/changelog/2026-10-15-dependency-round-and-reui.md): Eligible packages advance under the two-day quarantine, a reachable i18next ReDoS is patched and six free ReUI controls join the UI package. - [Developer documentation and changelog](https://autofolio.its-live.dev/docs/changelog/2026-10-15-developer-docs-and-changelog.md): The Worker now serves searchable developer guides, imported ADRs and a curated changelog at /docs. - [Documents and large uploads](https://autofolio.its-live.dev/docs/changelog/2026-10-15-documents-and-large-uploads.md): Files upload directly to R2, including multipart uploads up to 5 GiB, and supported documents become searchable text for the assistant and MCP. - [Landing page redesign](https://autofolio.its-live.dev/docs/changelog/2026-10-15-landing-redesign.md): The public landing uses a Taste v2 typographic direction with real interface captures and docs links. - [Landing sections and project skills](https://autofolio.its-live.dev/docs/changelog/2026-10-15-landing-sections-and-project-skills.md): The landing’s features, stack and agents sections become visual compositions, and new project skills cover this round’s features. - [Auth and tenant security fixes](https://autofolio.its-live.dev/docs/changelog/2026-10-16-auth-and-security-fixes.md): Isolation gaps found before row-level security are closed, client IPs are trusted only where the deployment vouches for them, and failed sign-ins are counted. - [Database, Worker and web performance](https://autofolio.its-live.dev/docs/changelog/2026-10-16-performance-and-placement.md): Tenant statements cost one round trip, the Worker runs next to the database, oRPC calls are batched and pages load their data on the server. - [Row-level security per Organization](https://autofolio.its-live.dev/docs/changelog/2026-10-16-row-level-security.md): PostgreSQL now enforces tenant isolation on every table. The template requires PostgreSQL 16 or newer, on any host, and verifies RLS on every migration. - [One folder per backend feature, tagged errors and written rules](https://autofolio.its-live.dev/docs/changelog/2026-10-17-backend-structure.md): packages/api moves to features and platform folders with fixed file names, two pilot features run one Effect program per procedure, and every rule now names what enforces it. - [One folder per mobile feature, and the API aliases are gone](https://autofolio.its-live.dev/docs/changelog/2026-10-17-mobile-structure.md): apps/mobile moves from components, hooks and lib to feature and platform folders that mirror the backend, routes only return screens, and the four legacy API subpaths are removed. - [The web app is organized by feature, like the backend](https://autofolio.its-live.dev/docs/changelog/2026-10-17-web-structure.md): apps/web/src moves to routes, features and platform with fixed role names, server-only modules carry a .server suffix, and the rules are checked by a boundary test. - [Agent skills from upstream and the /pr template](https://autofolio.its-live.dev/docs/changelog/2026-10-19-agent-skills-update.md): Matt Pocock's skills and pstack come from their upstream sources through the official installer, /pr joins the skills, and pull requests open with its Summary, Evidence and Merge Danger sections. - [Ponytail from the official plugin](https://autofolio.its-live.dev/docs/changelog/2026-10-19-ponytail-plugin.md): The default working style is the official ponytail plugin, enabled by the project settings, and new apps get a step for their own verification skill. - [Account preference](https://autofolio.its-live.dev/docs/changelog/2026-10-22-account-preference.md): Signed-in Users read and change their locale, main currency, units and reminder channels; the data region is set once and shown read-only. - [Purchase of a car](https://autofolio.its-live.dev/docs/changelog/2026-10-22-acquisition.md): Members record a car's purchase price, date, costs, down payment and amount financed, fill them in later, and correct them with a reason; the amount financed opens the car's Loan. - [Expenses and odometer readings](https://autofolio.its-live.dev/docs/changelog/2026-10-22-expense.md): Members save expenses into the ledger with odometer, fuel and receipt, are warned about likely duplicates, correct them with a reason, and can replay a save from an offline queue without saving twice. - [AutoFolio garage and catalog API](https://autofolio.its-live.dev/docs/changelog/2026-10-22-garage-and-catalog.md): Members search the NHTSA vPIC catalog, add a car from it or by hand, edit it and read the whole garage, with the Free plan's one active vehicle. - [Garage and Add vehicle in the app](https://autofolio.its-live.dev/docs/changelog/2026-10-22-garage-screens.md): The mobile Garage tab lists current, sold and wished-for cars with an instant filter, and Add vehicle saves a car from the catalog or typed in. - [Profile & data screen](https://autofolio.its-live.dev/docs/changelog/2026-10-22-profile-screen.md): The mobile Profile tab shows the plan, language, main currency, units, reminders, Face ID lock, privacy and the read-only data region. - [Services and reminders](https://autofolio.its-live.dev/docs/changelog/2026-10-22-service-reminders.md): Members record services with items, odometer and a private receipt, correct them with a reason and a visible history, and get reminders due by date or distance with a forecast from the car's readings. - [Site QA fixes](https://autofolio.its-live.dev/docs/changelog/2026-10-22-site-qa-fixes.md): A monochrome cookie bar, a draft legal privacy and cookie policy at /legal/privacy, a mobile menu, a skip link, pt-BR as the page language and smooth scrolling that respects Reduce Motion. - [AutoFolio welcome and sign-in](https://autofolio.its-live.dev/docs/changelog/2026-10-22-welcome-and-sign-in.md): Signed-out launches open on the welcome (M-Inicio); sign-in and sign-up offer Apple, Google and email, and a new garage lands on adding the first car. - [AutoFolio: the garage, AI, social sharing and appearance](https://autofolio.its-live.dev/docs/changelog/2026-10-23-autofolio-build.md): Everything that landed on 23 October: the v1 boards and the later-release ones, the AI and social features behind their flags, three appearance themes, the QA and audit rounds, performance, the landing page and docs that describe AutoFolio. ## Agent connection - [MCP endpoint](https://autofolio.its-live.dev/api/mcp): OAuth 2.1 authenticated MCP; start with tools/list to discover available tools. - [OAuth protected resource](https://autofolio.its-live.dev/.well-known/oauth-protected-resource): Resource metadata for MCP authorization. - [OAuth authorization server](https://autofolio.its-live.dev/.well-known/oauth-authorization-server): Authorization and token endpoints. - [API reference](https://autofolio.its-live.dev/api/rpc/api-reference): Session-authenticated oRPC API documentation. - [OpenAPI specification](https://autofolio.its-live.dev/api/rpc/api-reference/spec.json): Session-authenticated API schema. - [Sitemap](https://autofolio.its-live.dev/sitemap.xml): Public URLs only. Markdown pages support ?lang=pt, ?lang=en and ?lang=de. This index and the full document are in English. # Your garage. For life. The cars you drive, the ones you sold, the next one you dream of. Their costs, care and stories, together in AutoFolio. ## Keep every chapter. 1. **Add your car from the catalog**: Make, model, year and mileage. Specs and service plan come with it. 2. **Log in two taps**: Form, receipt photo or voice. Nothing is saved until you confirm. 3. **Sell with the story attached**: A revocable link shows the buyer only what you chose. ## Honest numbers What your cars may be worth. What you still owe. What a sale actually returned. Separate figures, with a source for every estimate. ## Pricing - Free: 1 active vehicle, costs, service, documents, sold and wishlist - Plus (Monthly price coming soon, Yearly price coming soon): Unlimited vehicles, full net worth and TCO, photo and voice, passport, advanced export Regional pricing will be confirmed before launch. Feature availability and AI limits depend on the plan and rollout. [Privacy](/privacy) · AutoFolio · A garage for life. # How we protect your cars Cars reveal wealth, routines and addresses. That is why AutoFolio is private by default. ## Nothing is public There is no mandatory public profile. A public collection is optional and never shows plate, VIN or location. ## Documents locked away Private storage, encryption in transit and at rest, access links that expire within minutes. ## Sharing means choosing The passport is a copy of only what you ticked. Revoking deletes that copy at once. ## Your data stays in your region European accounts in the EU. Full export and deletion at any time (GDPR, CCPA, LGPD). ## AI that doesn’t decide for you Receipts and voice are read on the phone where possible. Everything becomes a draft for you to confirm. ## No data sales Partner offers, if they ever exist, will be opt-in and labelled. Specific legal review before commercial launch. # Terms of Service Last updated October 12, 2026 > Draft pending legal review: This text is a draft for AutoFolio. A lawyer will review it before the commercial launch, so it may change. The placeholders in brackets will be filled in then. It is not legal advice. These terms govern your use of the app operated by [Company name]. By creating an account you agree to them. ## Your account Give accurate information, keep your password and passkeys safe, and tell us at [privacy@your-domain.example] if you think your account was compromised. You are responsible for activity under your account. ## Acceptable use Do not break the law, infringe the rights of others, upload malware, probe or overload the service, or try to access data you are not allowed to see. ## Your content You keep ownership of what you upload. You give us the permission we need to store, process and display it in order to provide the service. Organization admins can manage content shared in their organization. ## Paid features If paid plans are offered, prices, billing periods and cancellation terms are shown before you buy and become part of these terms. ## Availability and changes We work to keep the service available but cannot guarantee it will be uninterrupted. We may change or discontinue features and will give notice of material changes. ## Ending your use You can delete your account at any time in Settings. We may suspend or close accounts that break these terms. ## Liability The service is provided as is. To the extent the law allows, [Company name] is not liable for indirect or consequential damages. Nothing in these terms limits liability that the law does not allow us to limit. ## Governing law These terms are governed by the laws of [Country or state]. ## Contact Questions about these terms go to [privacy@your-domain.example]. # Architectural decisions Read the original decisions, their constraints and their recorded evidence. The pages below render the Markdown files in `docs/adr` directly. The decision records are the source of truth; feature guides explain how to work within them. Each record starts with a status line: the status, the date it was accepted and, where it applies, the record that amends or supersedes it. An accepted decision changes only through a new record or a dated clarification. | ADR | Decision | Status | | ---------------------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------- | | [0001](/docs/adr/0001-stored-file-keys-keep-the-user-s-filename) | Stored file keys keep the user's filename | accepted · 2026-07-13 | | [0002](/docs/adr/0002-no-barrels-on-server-capable-modules) | Server-capable modules have no barrel | accepted · 2026-07-30 | | [0003](/docs/adr/0003-organizations-are-the-tenant) | Organizations are the tenant | accepted · 2026-09-17 | | [0004](/docs/adr/0004-payments-providers) | Payments: one entitlement read model, three provider modules | accepted · 2026-10-12 | | [0005](/docs/adr/0005-effect-boundary) | Effect stays at the server edges | accepted · 2026-10-12 · amended by 0010 | | [0006](/docs/adr/0006-effect-persisted-queue) | Todo imports and the PersistedQueue gate | accepted · 2026-10-13 | | [0007](/docs/adr/0007-ai-assistant-and-tool-catalog) | AI assistant and one tool catalog | accepted · 2026-10-14 | | [0008](/docs/adr/0008-documents-and-large-uploads) | Documents and direct uploads | accepted · 2026-10-15 | | [0009](/docs/adr/0009-row-level-security) | Row-level security isolates Organizations | accepted · 2026-10-16 | | [0010](/docs/adr/0010-procedures-run-one-effect-program) | Procedures run one Effect program at their edge | accepted · 2026-10-17 · amends 0005 | | [0011](/docs/adr/0011-backend-feature-shape) | Backend features have a fixed file shape | accepted · 2026-10-17 · amended by 0013 | | [0012](/docs/adr/0012-web-features-mirror-the-backend-shape) | Web features mirror the backend shape | accepted · 2026-10-17 | | [0013](/docs/adr/0013-mobile-features-mirror-the-backend-shape) | Mobile features mirror the backend shape | accepted · 2026-10-17 · amends 0011 | | [0014](/docs/adr/0014-autofolio-on-the-template) | AutoFolio on the template | accepted · 2026-10-22 | | [0015](/docs/adr/0015-autofolio-data-model) | The AutoFolio data model | accepted · 2026-10-22 | | [0016](/docs/adr/0016-appearance-and-car-colour) | Appearance themes and car colour | accepted · 2026-10-23 · amends DESIGN.md §2 | | [0017](/docs/adr/0017-ai-model-registry-and-quotas) | AI features: one model registry, a flag per feature, plan quotas | accepted · 2026-10-23 | | [0018](/docs/adr/0018-copilot-generative-ui) | The copilot draws typed tool results as fixed cards, not OpenUI | accepted · 2026-10-23 | | [0019](/docs/adr/0019-erasure-after-a-grace-period) | Deleting an account waits 30 days; cars others use leave as History Transfers | accepted · 2026-10-23 | # Backend shape Why every backend feature has the same files, why there is no barrel and no repository layer, and where Effect runs. The server code in `packages/api` follows one idea: **every feature has the same shape, and dependencies flow one way**. Once you have read one feature, you can read all of them. ## Features, not layers [#features-not-layers] A feature is a folder, `packages/api/src/features//`. Inside it, each file's name states its role: the contract clients import, the procedures oRPC exposes, the service that does the work, the errors it can fail with, the jobs the cron tick runs and the raw HTTP edges. The [backend conventions](/docs/development/backend-conventions) list the roles and what each may import. Grouping by feature instead of by layer keeps one capability in one place. You can follow a change without hopping between `routers/`, `services/` and `lib/`, and you can delete a feature as a folder. Shared building blocks that belong to no feature (procedure builders, the request context, the Effect edge, the job runner) live in `packages/api/src/platform/`. ## Boundaries the build can check [#boundaries-the-build-can-check] Three boundaries matter, and each one is enforced by a check rather than by discipline: - **Client and server.** A client imports only a leaf contract, an enum or a type. A barrel is how server code once reached the browser, so the package has none; its public surface is an enumerated `exports` map ([ADR-0002](/docs/adr/0002-no-barrels-on-server-capable-modules)). - **Tenant and system.** Procedures read and write through `context.db`, which runs as `app_tenant`. Row-level security, not a layer of code, keeps Organizations apart ([ADR-0009](/docs/adr/0009-row-level-security)). That is why there is no repository layer: it would forward calls to the same Drizzle handle and add a second place for every query without adding isolation. - **Feature and feature.** A feature uses another feature's contract, service or errors, not its internals. Only the app router imports procedures, and only the job runner imports jobs. The [enforcement matrix](/docs/development/enforcement) shows what checks each boundary. ## Errors and Effect [#errors-and-effect] A service describes what can go wrong as tagged errors. The procedure turns them into the stable codes clients localize, once, in one mapper. Services that a migration has reached are Effect programs: the procedure runs one program per call, so typed failures, deadlines and the rule that a started write is never abandoned have a single owner. Contracts stay Zod, routing stays oRPC and queries stay Drizzle ([ADR-0010](/docs/adr/0010-procedures-run-one-effect-program), [ADR-0005](/docs/adr/0005-effect-boundary)). ## Smells [#smells] You have drifted from this shape when you see any of these: - a procedure file full of queries in a migrated feature; - a service that imports `@orpc/*`; - a contract that imports a Drizzle table or a service; - a feature importing another feature's queue or lease module; - a list endpoint that pages differently from its neighbours; - a new error that is a string instead of a tagged error. Start a new feature with the `domain-feature` skill, which walks through these steps in order. # Client/server boundary Keep server capabilities out of browser and native module graphs. ## Import a leaf [#import-a-leaf] A server-capable module must not re-export a neighbor's convenience constant. A client that imports one enum from a Drizzle table module can pull the entire table graph into its bundle. That happened here and led to [ADR-0002](/docs/adr/0002-no-barrels-on-server-capable-modules). ```ts import { PRIORITIES } from "@autofolio/db/schema/todo.enums"; import type { AppRouterClient } from "@autofolio/api/client"; ``` Domain enums live in dependency-free `*.enums.ts` modules. Public Zod contracts live in `api/src/features/*/*.contract.ts`, importing only safe contracts where necessary. A `LEAF MODULE` header means no imports may be added to that module. The API client entry exports types only. ## Enforcement [#enforcement] 1. Nested Oxlint configs reject value imports of server modules from web, mobile and UI. Type-only imports are allowed. 2. Server package export maps enumerate supported subpaths. Add a specific entry deliberately; do not widen it to `./*`. 3. The web build scans emitted client assets for server-only markers with `assert-client-bundle-clean.mjs`. ```sh pnpm exec oxlint pnpm --filter web build ``` The Drizzle schema aggregate is the documented exception: the relations builder needs all tables, and clients cannot reach it. The existing web oRPC adapter also uses a framework-stripped server branch for SSR. Do not copy that exception into an ordinary component. ## Server-only modules in the web app [#server-only-modules-in-the-web-app] Inside `apps/web/src`, a module that only the server may load ends in `.server.ts` or `.server.tsx` ([ADR-0012](/docs/adr/0012-web-features-mirror-the-backend-shape)). TanStack Start's import protection denies `**/*.server.*` files to the client environment by default. In `vite build`, such an import that is still in the client graph after the compiler strips `.server()` branches fails the build; in `vite dev` it becomes a mock that logs an error when used. The same protection denies `**/*.client.*` on the server, so no web file uses `.client.` in its name. Client code reaches a `.server` module only through two isomorphic bridges, which use it inside `createIsomorphicFn().server()` so the compiler strips the branch from the client build: - the session query, [`platform/auth/session-query.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/platform/auth/session-query.ts); - the docs page loader, [`features/docs/docs.page-loader.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/features/docs/docs.page-loader.ts). The oRPC client, [`platform/rpc/orpc.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/platform/rpc/orpc.ts), is the exception named above: it imports no `.server` file, but value-imports the backend router for in-process SSR inside its own `.server()` branch, and the web lint config carves it out of the boundary by path. The web has no `createServerFn`. Server routes (`routes/api/**`, `routes/[.]well-known/**`, and handler-only resource routes such as `docs[.]md.ts` or `sitemap[.]xml.tsx`) and `server.ts` import `.server` modules directly; the boundary test ([`scripts/web-feature-boundaries.test.mjs`](https://github.com/CarlosZiegler/autofolio/blob/main/scripts/web-feature-boundaries.test.mjs), rule W3 in [web conventions](/docs/development/web-conventions)) refuses any other importer. ## Extending a contract [#extending-a-contract] Put the shared value in a leaf first. Make the table and schema import the leaf. Add its explicit package export, then import that address from consumers. Do not rely on tree-shaking to hide server side effects. Documentation articles render on the server so examples of server-only names stay out of executable client assets too. The browser receives trusted, repository-authored HTML and small navigation metadata. ## Key files [#key-files] - [`packages/config/oxlint-client-boundary.json`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/config/oxlint-client-boundary.json) - [`apps/web/src/.oxlintrc.json`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/.oxlintrc.json) - [`scripts/web-feature-boundaries.test.mjs`](https://github.com/CarlosZiegler/autofolio/blob/main/scripts/web-feature-boundaries.test.mjs) - [`apps/web/scripts/assert-client-bundle-clean.mjs`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/scripts/assert-client-bundle-clean.mjs) - [`packages/api/src/client.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/client.ts) - [`packages/db/src/schema/todo.enums.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/src/schema/todo.enums.ts) # Effect at the edges Use stable Effect APIs for owned server work without changing public contracts. Effect coordinates scheduled execution and outbound webhook, push, RevenueCat and email transports. It also powers the diagnostic probe. Zod remains the contract validator, oRPC the request layer, Better Auth the identity system and Drizzle the database interface. ## One execution boundary [#one-execution-boundary] Jobs return `Effect`; the cron entry runs the composed program once. Promise consumers call thin facades through `runEdge`. Convert typed errors once with `catchTags` where the program meets HTTP, persisted outcomes or an external result union. Do not catch interruption manually. Use `tryPromise` when a rejection must enter the typed error channel. Use `drained` for promises that own database state: it requests cancellation and waits for settlement before closing the pool. Scope cleanup can outlive an Effect deadline when external I/O does not settle; the query timeout is part of the shutdown design. ## Retries and deadlines [#retries-and-deadlines] | Policy | Production value | | --- | --- | | Whole scheduled tick | 25 seconds | | Import admission budget | 15 seconds | | Import source GET | 10 seconds | | Job pool query timeout | 2 seconds | | Job pool SQL statement timeout | 5 seconds | `idempotentRead` retries eligible reads twice with jitter and a budget. A short Retry-After can be honored inside the round; a longer delay returns to persistent policy. A webhook POST or email send is not retried inside that round. ## Stable API boundary [#stable-api-boundary] Read the installed Effect `AGENTS.md` and source before editing Effect code. Only APIs marked stable are approved. Native PersistedQueue failed the workerd lost-acknowledgement shutdown gate; production imports use stable Queue/Stream/Schedule plus Drizzle leases. Do not replace that queue or bump its pins without repeating [ADR-0006](/docs/adr/0006-effect-persisted-queue)'s fault gate. TanStack AI owns its Promise/SSE lifecycle under the narrow exception in [ADR-0007](/docs/adr/0007-ai-assistant-and-tool-catalog). Adding Layers or shared services is a new architectural decision, not a prerequisite for a small transport. ## Key files [#key-files] - [`packages/api/src/platform/effect/edge.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/platform/effect/edge.ts) - [`packages/api/src/platform/effect/retry.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/platform/effect/retry.ts) - [`packages/api/src/platform/jobs/run.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/platform/jobs/run.ts) - [`docs/adr/0005-effect-boundary.md`](https://github.com/CarlosZiegler/autofolio/blob/main/docs/adr/0005-effect-boundary.md) # Architecture One Worker owns web rendering, API requests and scheduled execution. The production web app is a TanStack Start application built with the Cloudflare Vite plugin. Its Worker handles SSR, Better Auth, oRPC, the files gateway, MCP, AI streaming, OG images and cron. Expo calls the same HTTP API. There is no separate API deployment. ## Runtime and infrastructure [#runtime-and-infrastructure] | Component | Responsibility | | --- | --- | | TanStack Start | Routes, SSR, hydration and request handlers | | Worker fetch | Carries Cloudflare waitUntil into the request runtime before dispatch | | Hyperdrive | Pools connections to Postgres; query caching must stay disabled | | Postgres, hosted on Neon | Users, Organizations, domain records, leases, outboxes and approvals | | R2 | Private objects, accessed through the files-sdk fetch adapter | | Worker scheduled | Runs the six jobs in one scoped Effect program | The default build targets Cloudflare. `DEPLOY_TARGET=node` selects Nitro for a Node deployment. `vite dev` always uses Nitro, with a shim for `cloudflare:workers` and direct `DATABASE_URL` access. The Node path is useful locally; the built Worker remains the compatibility test for production. ## Read the implementation [#read-the-implementation] - [Backend shape](/docs/architecture/backend-shape) explains why every feature has the same files. - [Request lifecycle](/docs/architecture/request-lifecycle) follows one authenticated request. - [Client/server boundary](/docs/architecture/client-server-boundary) explains which imports are safe. - [Effect at the edges](/docs/architecture/effect) covers deadlines, cancellation and retries. - [Observability](/docs/architecture/observability) distinguishes application logs from Worker traces. Shared code belongs in a package when web and mobile need the same contract or behavior. The [package reference](/docs/packages) lists actual exports. Adding a feature usually adds a folder under `packages/api/src/features/` and extends the existing Worker and schema, rather than provisioning another service. ## Key files [#key-files] - [`apps/web/src/server.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/server.ts) - [`apps/web/vite.config.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/vite.config.ts) - [`apps/web/wrangler.jsonc`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/wrangler.jsonc) - [`packages/db/src/connection.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/src/connection.ts) # Observability and tracing Read structured errors, durable audit records and Cloudflare spans as separate signals. ## Application errors and logs [#application-errors-and-logs] The web middleware integrates evlog. Shared error codes and localized messages come from `packages/errors`; its server handler logs unexpected failures while the client resolver returns a safe user message. During development, inspect `.evlog/logs/` when available. Do not copy credentials, prompts or private payloads into logs. Each RPC request has a request ID: `cf-ray` when Cloudflare set a well-formed one, otherwise a UUID. It is `context.requestId` on the server, the `x-request-id` header on every `/api/rpc` response (including the 500 when building the context fails), and a field of the error log. The MCP context derives its own `requestId` for the logs of its document tools; MCP responses do not carry the header. It serves only to correlate logs, never to decide anything: outside Cloudflare (local `wrangler dev`, Node) a client can send a well-formed `cf-ray` and it is used as is, and [Cloudflare's header reference](https://developers.cloudflare.com/fundamentals/reference/http-headers/) does not say whether a client-sent value is replaced. The error handler writes expected 4xx failures at debug level and unexpected ones (5xx, unknown codes, non-oRPC errors) as errors, both with the request ID, so a user's quoted header finds the matching line ([error handling](https://github.com/CarlosZiegler/autofolio/blob/main/docs/error-handling.md)). An Audit Event is a durable domain record, not a log line. The Audit Log is the admin table; an Activity Timeline is one User's security history. Release notes belong to the public [changelog](/docs/changelog). ## Worker traces [#worker-traces] Traces and invocation logs are off in `apps/web/wrangler.jsonc`. Both store each request's URL, and the buyer's passport link carries its token in the path (`/p/`): a logged URL would open the passport, while the database keeps only the token's hash. Cloudflare applies these settings to the whole Worker, not to one path. Application logs stay on: evlog scrubs the token, plates, VINs, emails and amounts from them (`packages/errors/src/log-scrub.ts`). The scheduled handler still passes `ctx.tracing` into `runScheduledJobs`; `workersTracer` adapts Effect spans to Cloudflare spans. It records scalar attributes, failures and interruption. Turning traces back on needs the token out of the URL first (for example, in the URL fragment, which no server receives). The native API cannot assign an explicit parent to each Effect span. Those spans appear as siblings under `jobs.scheduled`, alongside platform fetch and Hyperdrive spans. Node execution runs without this tracer. Local successful jobs do not prove that production custom spans are visible in the Cloudflare dashboard. ## Diagnose a failure [#diagnose-a-failure] Trace the entry point, transport result and durable row together. For an import, inspect its state and persisted failure reason before assuming the queue lost it. For a webhook, inspect the delivery lease and next attempt. For a client error, keep the stable error code and correlation context rather than exposing an exception message. Bundle and fault experiments are recorded in `docs/measurements`. Their local timings are not production latency or billing measurements. Add a dated report when changing runtime ownership, deadlines or a heavy dependency. ## Key files [#key-files] - [`packages/api/src/platform/rpc/request-id.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/platform/rpc/request-id.ts) - [`packages/errors/src/server.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/errors/src/server.ts) - [`packages/api/src/platform/effect/tracer.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/platform/effect/tracer.ts) - [`packages/db/src/audit.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/src/audit.ts) - [`apps/web/wrangler.jsonc`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/wrangler.jsonc) # Request lifecycle Follow authority and database lifetime from the incoming Request to the response. ## Entry and request scope [#entry-and-request-scope] `apps/web/src/server.ts` wraps the TanStack handler in `runWithRequestRuntime`. This preserves `waitUntil` without opening a database connection for every public page. When a protected operation needs auth, `createRequestScope(request)` supplies one database and one Better Auth instance. The oRPC context also derives the request ID (`cf-ray` or a UUID), which the RPC route returns as `x-request-id`, also on the 500 it answers when building the context itself fails, and which the error log records. It correlates logs only; since a client outside Cloudflare can supply the `cf-ray`, nothing authorizes or deduplicates by it. The scope uses AsyncLocalStorage across asynchronous continuations and a WeakMap for the same Request object. SSR prefetch and cloned Requests can reuse the current scope. A different HTTP request gets a different Worker pool because a Worker socket belongs to the invocation that opened it. Node development reuses its long-lived pool instead. ## Authentication and authorization [#authentication-and-authorization] The oRPC context calls `getAuthorizedSession`, which bypasses cookie caching and rejects an active Ban. `orgProcedure` additionally looks up current membership in the Session's Active Organization, reading the Member's Org role in the same statement; `requireOrgRole(...)` can then restrict a procedure to some roles. `adminProcedure` re-reads the authoritative Session before checking the platform role. ```ts // Server router: orgProcedure supplies a membership-checked organizationId. const list = orgProcedure.handler(async ({ context }) => { return context.db.query.todo.findMany({ where: { organizationId: context.organizationId }, }); }); ``` Mobile requests can carry the expected user/session/organization scope. The server refuses a changed scope rather than answering a request under a new identity. A client abort is honoured only before a procedure's program starts. After that, a client that goes away does not interrupt it: the write and its Audit Event commit and the response goes unread, so cancelling the wait never undoes or half-applies a mutation. ## Commit and cleanup [#commit-and-cleanup] Audited writes enqueue an Audit Event in the same database transaction as the domain change, then attempt publication. The durable audit job recovers unpublished records. AI streams and scheduled jobs own separately bounded pools and explicitly close them when their work settles. Do not cache a request-bound auth instance, database client or Layer at module scope. Public docs and discovery routes read compiled public content and do not require a Session. ## Key files [#key-files] - [`packages/auth/src/request-scope.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/auth/src/request-scope.ts) - [`packages/auth/src/server-session.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/auth/src/server-session.ts) - [`packages/api/src/platform/rpc/context.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/platform/rpc/context.ts) - [`packages/api/src/platform/rpc/procedures.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/platform/rpc/procedures.ts) - [`packages/db/src/index.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/src/index.ts) # Changelog New features, architectural changes and improvements to AutoFolio. Release notes are curated from repository history. Dates reflect integration into the main branch; grouped entries are not semantic-version tags. Each entry links to its implementation and current developer guide. ## AutoFolio: the garage, AI, social sharing and appearance 2026-10-23 Everything that landed on 23 October: the v1 boards and the later-release ones, the AI and social features behind their flags, three appearance themes, the QA and audit rounds, performance, the landing page and docs that describe AutoFolio. On 22 and 23 October the template became AutoFolio, a lifelong garage for current, sold and wished-for cars, in Portuguese (Brazil), English and German. The handoff in `handoff/` sets the product and design; [ADR-0014](/docs/adr/0014-autofolio-on-the-template) maps it onto the template and [ADR-0015](/docs/adr/0015-autofolio-data-model) records the data model: integer money in an append-only ledger, corrections as new entries, the Organization as the household under row-level security, and plate and VIN encrypted ([data model](https://github.com/CarlosZiegler/autofolio/commit/5979dbf)). This entry gathers the day's changes; each feature's guide holds the detail. ## The garage [#the-garage] - **Vehicle profile, gallery, documents, history and collection.** M-Veiculo shows the car's value with source, date and confidence, its costs over 12 months, the next service and the loan balance ([garage](/docs/features/garage), [commit](https://github.com/CarlosZiegler/autofolio/commit/b1e540f)). M-Galeria keeps up to 50 photos on Free with a cover, and Specs shows plate and VIN masked until the owner asks ([vehicle photos](/docs/features/vehicle-photos), [commit](https://github.com/CarlosZiegler/autofolio/commit/66e8f34)). M-Documentos files private documents behind five-minute links ([vehicle documents](/docs/features/vehicle-documents), [commit](https://github.com/CarlosZiegler/autofolio/commit/189d188)). M-Historico derives a car's timeline from its records ([history](/docs/features/vehicle-history), [commit](https://github.com/CarlosZiegler/autofolio/commit/670b288)). M-Colecao counts every car owned, and the wishlist never counts toward Free's one car ([commit](https://github.com/CarlosZiegler/autofolio/commit/c1b5ebf)). - **Profile.** Data export (a ZIP of CSVs and a PDF per car, 7 days), account deletion that erases the household's records at once, and the Face ID lock ([data export and erasure](/docs/features/data-export), [commit](https://github.com/CarlosZiegler/autofolio/commit/e255246)). - **Privacy centre.** One Privacy row opens what we hold (counts per category), what I share with Revoke and Revoke all links, and a consent per purpose recorded with the policy version; a no to AI turns every AI feature off for that User ([privacy centre](/docs/features/privacy)). - **Delete account with 30 days to change your mind.** Behind `account.deletion-grace`: scheduling signs out every device and revokes passports, showcases and invites at once, each car other people use is deleted with the account or handed to one of them as a History Transfer, signing in shows only Keep my account or Delete now, and the cron tick erases the account after 30 days ([data export and erasure](/docs/features/data-export#deletion-with-a-grace-period), [ADR-0019](/docs/adr/0019-erasure-after-a-grace-period), [commits](https://github.com/CarlosZiegler/autofolio/commit/587ba4b), [5bcf546](https://github.com/CarlosZiegler/autofolio/commit/5bcf546)). - **Scheduled deletion on the web too.** Settings → Danger zone schedules the deletion with the same per-car choices, a scheduled account signed in on the web sees only Keep my account, Delete now and Sign out, and Members who used the cars are emailed when an account is deleted at once ([data export and erasure](/docs/features/data-export#deletion-with-a-grace-period), [ADR-0019](/docs/adr/0019-erasure-after-a-grace-period)). - **Sign-in and security.** P-Entrar signs in with a passkey first, then Apple and Google (hidden until the server has their credentials), then email; P-Seguranca gathers passkeys, two-step verification, linked accounts, devices, an email alert for a sign-in on a new device or country, the Face ID lock and a cover for the app switcher; a household can require a passkey or two-step verification before anyone shares ([sign-in and security](/docs/features/sign-in-security), [commits](https://github.com/CarlosZiegler/autofolio/commit/63bbd4e), [1b384a1](https://github.com/CarlosZiegler/autofolio/commit/1b384a1)). - **Family garage.** Per-car roles (view only, can log expenses, owner) and row-level security that hides ungranted cars ([family garage](/docs/features/family-garage), [commit](https://github.com/CarlosZiegler/autofolio/commit/07a6bbf)). - **People permissions.** What a person may do with each car is a preset (Co-owner, Can log expenses, Driver, Workshop, Accountant, View only) or a custom matrix of six areas with See, Log and Edit (G-Papeis, G-Permissoes); selling and removing the car stay the owner's. Row-level security hides every area the grant leaves out, so a Driver sees only the amounts they logged; the old roles and the documents switch became presets (#76, [family garage](/docs/features/family-garage)). - **Temporary access, approvals, handing a car over.** A grant can end at a date (lend the car for three days; a job removes it and tells both sides), what a Member logs can wait for the owner's approval out of every total (rejecting is a correction with the reason), and a car's owner can hand it to another Member, who accepts and becomes its owner (G-Temporario, G-Aprovacao, G-Transferir; #77, [family garage](/docs/features/family-garage)). ## Money [#money] - **Add expense** with the duplicate warning (RF10), a reason for a lower odometer (RN07) and an offline queue that never saves twice; a receipt photo or a voice note only fills a draft ([expense](/docs/features/expense), [commit](https://github.com/CarlosZiegler/autofolio/commit/8489910), [commit](https://github.com/CarlosZiegler/autofolio/commit/8eb5c49)). - **Finances, loan, net worth.** M-Gastos totals by category and period; M-Financiamento keeps every balance, and principal is never an expense (RN05); M-Patrimonio shows value minus debt, and a rise is an estimate, never a profit ([loan](/docs/features/loan), [commits](https://github.com/CarlosZiegler/autofolio/commit/fc03a15), [0064c1d](https://github.com/CarlosZiegler/autofolio/commit/0064c1d), [b1529a8](https://github.com/CarlosZiegler/autofolio/commit/b1529a8)). - **Purchase with a trade-in**, recurring expenses confirmed into the ledger, and spreadsheet import with undo ([acquisition](/docs/features/acquisition), [recurring expense](/docs/features/recurring-expense), [spreadsheet import](/docs/features/spreadsheet-import), [commits](https://github.com/CarlosZiegler/autofolio/commit/1f31491), [8cb58f3](https://github.com/CarlosZiegler/autofolio/commit/8cb58f3), [84811cd](https://github.com/CarlosZiegler/autofolio/commit/84811cd)). - **M-Insights.** A car's TCO per km or mile (depreciation, running costs, interest and fees) and the cars compared; the same running-cost figure on Finances, the profile and Insights ([insights](/docs/features/insights), [commits](https://github.com/CarlosZiegler/autofolio/commit/d42a93b), [6c7c3d4](https://github.com/CarlosZiegler/autofolio/commit/6c7c3d4)). - **Reference values.** A provider seam per country and a daily revaluation job; no provider is licensed, so values stay manual ([garage](/docs/features/garage#reference-values-rf38), [commit](https://github.com/CarlosZiegler/autofolio/commit/2dd7fc8)). ## Service [#service] The Service tab lists reminders by date or distance with the basis of each forecast, records services with items and receipt, suggests a maintenance plan (generic, never called the manufacturer's without its data), and pushes and emails due alerts once per window ([services and reminders](/docs/features/service), [commits](https://github.com/CarlosZiegler/autofolio/commit/97e1038), [8a9b69a](https://github.com/CarlosZiegler/autofolio/commit/8a9b69a), [6de0306](https://github.com/CarlosZiegler/autofolio/commit/6de0306)). The home-screen widget shows the next service ([commit](https://github.com/CarlosZiegler/autofolio/commit/7d2a43a)). Partner workshops verify services, dealers hand over cars by a one-time code, and the partner portal lives at `/partners` ([partners](/docs/features/partners), [commit](https://github.com/CarlosZiegler/autofolio/commit/cf5ee55)). ## Sale and sharing [#sale-and-sharing] - **Close a sale.** The deal result and the economic result apart, the loan paid off, the car moved to Sold; nothing deleted ([sale](/docs/features/disposal), [commit](https://github.com/CarlosZiegler/autofolio/commit/3007b04)). - **Passports.** M-Vender and M-Passaporte create field-by-field snapshots behind a 256-bit token; the buyer opens `/p/:token`, revoked links look like unknown ones, and revoking deletes the snapshot ([passports](/docs/features/passport), [commits](https://github.com/CarlosZiegler/autofolio/commit/ebf737f), [feb93ac](https://github.com/CarlosZiegler/autofolio/commit/feb93ac)). - **History transfer.** The technical history, without money, plate or VIN, offered to the next owner's email ([history transfer](/docs/features/history-transfer), [commit](https://github.com/CarlosZiegler/autofolio/commit/9ccc0a7)). ## AI [#ai] Each AI feature is behind its own flag, off by default, resolves its model from one registry on TanStack AI, counts against the plan's monthly quota, and sends nothing with a plate, VIN or email ([AI foundation](/docs/features/ai), [ADR-0017](/docs/adr/0017-ai-model-registry-and-quotas), [commit](https://github.com/CarlosZiegler/autofolio/commit/8f819a5)). A model fills drafts the person confirms and never computes a figure the app shows. - **Cost assistant and copilot cards.** M-Assistente answers through the shared tool catalog; with `ai.genui`, each result is a card that names its source and opens its screen, and an expense draft is saved only in Add expense ([assistant](/docs/features/assistant), [ADR-0018](/docs/adr/0018-copilot-generative-ui), [commits](https://github.com/CarlosZiegler/autofolio/commit/bccc664), [9043d8c](https://github.com/CarlosZiegler/autofolio/commit/9043d8c)). - **Document scanner.** Fuel receipts, workshop invoices, policies, tax notices, purchase invoices and loan contracts read on the phone into grounded drafts; Shoebox mode queues many ([document scanner](/docs/features/scanner), [commit](https://github.com/CarlosZiegler/autofolio/commit/37a62b3)). - **Cinematic Studio.** A new scene and light for an ordinary photo, the car's pixels checked against the original, plates blurred, the original kept ([Cinematic Studio](/docs/features/studio), [commit](https://github.com/CarlosZiegler/autofolio/commit/f92204b)). - **Proactive insights.** A real change on a car (fuel economy, spending, insurance, a service due), last month's summary, and the odometer read from a dashboard photo ([insights](/docs/features/insights#proactive-insights-ai-spec-4), [commit](https://github.com/CarlosZiegler/autofolio/commit/55e2ee8)). - **Extras.** A sale listing from the passport, search across a car's records, translation beside the original, and a plan read from the owner's manual ([AI extras](/docs/features/ai-extras), [commit](https://github.com/CarlosZiegler/autofolio/commit/92cbee9)). ## Social [#social] - **Moments and share cards.** Facts the garage already holds (a new car, an anniversary, a distance milestone, a sale, a Studio picture, the collection) become cards made on the phone and shared as files, never uploaded; the launch backlog is listed as seen, never pushed ([moments](/docs/features/moments), [commits](https://github.com/CarlosZiegler/autofolio/commit/727a31e), [42b4c1f](https://github.com/CarlosZiegler/autofolio/commit/42b4c1f)). - **Showcase links.** A public page of a car or the garage at `/g/:token`, with photos, years and milestones, never prices, plate or VIN; optionally live ([showcase](/docs/features/showcase), [commit](https://github.com/CarlosZiegler/autofolio/commit/4f111f4)). - **Shared garage.** Invite by a one-time link or QR with the cars and roles chosen first, the people header and the activity line ([family garage](/docs/features/family-garage#invite-by-link-or-qr), [commit](https://github.com/CarlosZiegler/autofolio/commit/e5cb83f)). The flags `social.moments`, `social.showcase` and `social.invite-link` are on in the dev seed and off in production until QA passes. ## Appearance [#appearance] Profile → Appearance offers Monochrome (the default), Car colour (tinted by the current car's cover colour, read once on the server) and Warm, in the app and the signed-in web; the passport stays neutral and the showcase takes the colour only if the owner asks ([appearance](/docs/features/appearance), [ADR-0016](/docs/adr/0016-appearance-and-car-colour), [commit](https://github.com/CarlosZiegler/autofolio/commit/9a3a3ed)). ## Quality rounds [#quality-rounds] - **QA rounds 2–5** fixed what only a device shows: Hermes lacks `formatToParts` and writes numbers in the device's region, so money, numbers and dates now come from per-locale tables in `@autofolio/domain/format`, and a Hermes guard keeps device code off what it lacks; forms open above the tabs; Dynamic Type and VoiceOver fixes on every board ([commits](https://github.com/CarlosZiegler/autofolio/commit/add298a), [cff2fdb](https://github.com/CarlosZiegler/autofolio/commit/cff2fdb), [493c616](https://github.com/CarlosZiegler/autofolio/commit/493c616), [1bed7c0](https://github.com/CarlosZiegler/autofolio/commit/1bed7c0), [aa00bd0](https://github.com/CarlosZiegler/autofolio/commit/aa00bd0), [2cdfa6f](https://github.com/CarlosZiegler/autofolio/commit/2cdfa6f), [c075345](https://github.com/CarlosZiegler/autofolio/commit/c075345)). - **Audits.** Authorization between cars and nothing identifying in logs, uploads or provider calls; one strict money parser and a queue that keeps unsent writes through a forced sign-out; jobs that page at scale and tests that do not read the wall clock ([observability](/docs/architecture/observability), [commits](https://github.com/CarlosZiegler/autofolio/commit/9bd087f), [f219335](https://github.com/CarlosZiegler/autofolio/commit/f219335), [8f8320c](https://github.com/CarlosZiegler/autofolio/commit/8f8320c)). - **QA round 6.** The remove-member freeze, the Studio call timeout, the Copilot's sell answer, the scanner's currency and the flows' timing; then the boards' layouts, Dynamic Type at AX5, German and Portuguese copy and VoiceOver labels on the AI, social and appearance screens ([commits](https://github.com/CarlosZiegler/autofolio/commit/7436c4e), [3a51dcc](https://github.com/CarlosZiegler/autofolio/commit/3a51dcc)). - **QA round 7.** The Studio keeps faithful scenes again: the fidelity check scores the car's edges and details instead of its brightness, so a relit car passes and another car fails, and a request with no kept scene ends after one round instead of spending three more calls ([Cinematic Studio](/docs/features/studio), [commit](https://github.com/CarlosZiegler/autofolio/commit/4c68463)). Pickers and confirms open only once the sheet before them is gone, P-Excluir pins its pills above the tabs, and a transfer offer names its car ([commit](https://github.com/CarlosZiegler/autofolio/commit/30a1875)). Dev links reuse the seed session, Your garages switches households and a new sign-in reopens the last one, the P- and G- boards match their drawings, and Dynamic Type at AX5 stacks instead of breaking words ([commit](https://github.com/CarlosZiegler/autofolio/commit/2d05d33)). ## Performance [#performance] - **Mobile.** A 21 % smaller JS bundle, lists on FlashList and the language read before the first frame ([commit](https://github.com/CarlosZiegler/autofolio/commit/b2e1c7c)). Gallery and Moments tiles load a stored, upright 384 px preview instead of the original photo (a 12 MP tile goes from 3.26 MB to 194 KB). Measured on the simulator: FlashList stays, because LegendList mounted 20–50 ms faster but showed blank frames, slower frames and more memory on long lists; inlineRequires stays off, because it did not shorten the start ([`docs/measurements/2026-10-23-mobile-lists-startup.md`](https://github.com/CarlosZiegler/autofolio/blob/main/docs/measurements/2026-10-23-mobile-lists-startup.md), [vehicle photos](/docs/features/vehicle-photos), [commit](https://github.com/CarlosZiegler/autofolio/commit/e5144e5)). - **Web.** The entry JS drops from 172 to 106 KB gzip; Lighthouse mobile with real throttling goes from 93 to 98 on the site and from 79 to 94–99 on the dashboard ([commit](https://github.com/CarlosZiegler/autofolio/commit/75a40c1)). - **API round trips.** Insights, Moments, the garage, net worth, recurring expenses and the copilot's "upcoming" read once for the whole household instead of per car or in series, and a read that finds nothing new writes nothing. M-Insights asks once (`insights.overview`), and the morning Moments and insights jobs keep their place for the day instead of rescanning every household each tick. Per-screen numbers are in [`docs/measurements/2026-10-23-api-round-trips.md`](https://github.com/CarlosZiegler/autofolio/blob/main/docs/measurements/2026-10-23-api-round-trips.md) (#72). ## Web and docs [#web-and-docs] A new product landing page at `/` tells the garage's story with the new AI, social and appearance features, in Portuguese, English and German ([commit](https://github.com/CarlosZiegler/autofolio/commit/e4b70a6)). The site, privacy page, auth and account pages use the AutoFolio style, and the template's Todos, posts and profile demo pages are gone; the task API and MCP tools remain ([commits](https://github.com/CarlosZiegler/autofolio/commit/edde7d5), [7c666c6](https://github.com/CarlosZiegler/autofolio/commit/7c666c6)). The README, [`CONTEXT.md`](https://github.com/CarlosZiegler/autofolio/blob/main/CONTEXT.md) and these docs now describe AutoFolio: the glossary covers the AI, social and appearance words, the [features index](/docs/features) lists every guide by area, the [decisions](/docs/adr) list ADR-0014 to 0018, and the AI and social specs say what is built and what is deferred. Template-only plans and examples moved to [`docs/archive/template`](https://github.com/CarlosZiegler/autofolio/blob/main/docs/archive/template/README.md). Deployment and CI stay documented but unused while the project runs locally ([local setup](/docs/development/setup)). A review of about 30 competitors ranks 15 feature opportunities and the ones to avoid ([`docs/research/2026-10-competitors.md`](https://github.com/CarlosZiegler/autofolio/blob/main/docs/research/2026-10-competitors.md)). ## Account preference 2026-10-22 Signed-in Users read and change their locale, main currency, units and reminder channels; the data region is set once and shown read-only. The API gains `accountPreference.get` and `accountPreference.update` for the AutoFolio profile. The first read creates the User's row from the device locale (Portuguese → BRL, km, l, region br; German → EUR, km, l, region eu; anything else → USD, mi, gal, region us), once, even when two reads race. An update changes only the choices it names and refuses the data region, which moving is a support process. - [Account preference](/docs/features/account-preference) ## Purchase of a car 2026-10-22 Members record a car's purchase price, date, costs, down payment and amount financed, fill them in later, and correct them with a reason; the amount financed opens the car's Loan. The API gains `acquisition.get` and `acquisition.save` for M-Compra. Saving writes the purchase header, the price and cost ledger entries, the Loan the amount financed opens, the invoice and the ownership period's start in one transaction. Every field may be filled in later without a reason; changing a recorded value needs one and adds correction rows, never an update. The domain package adds `timeOwned` for "Time owned: 3 years, 4 months", and the dev seed leaves the Corolla Cross without a purchase so the garage shows its next step. - [Acquisition](/docs/features/acquisition) ## Earlier updates - 2026-10-22: [Expenses and odometer readings](/docs/changelog/2026-10-22-expense) - 2026-10-22: [AutoFolio garage and catalog API](/docs/changelog/2026-10-22-garage-and-catalog) - 2026-10-22: [Garage and Add vehicle in the app](/docs/changelog/2026-10-22-garage-screens) - 2026-10-22: [Profile & data screen](/docs/changelog/2026-10-22-profile-screen) - 2026-10-22: [Services and reminders](/docs/changelog/2026-10-22-service-reminders) - 2026-10-22: [Site QA fixes](/docs/changelog/2026-10-22-site-qa-fixes) - 2026-10-22: [AutoFolio welcome and sign-in](/docs/changelog/2026-10-22-welcome-and-sign-in) - 2026-10-19: [Agent skills from upstream and the /pr template](/docs/changelog/2026-10-19-agent-skills-update) - 2026-10-19: [Ponytail from the official plugin](/docs/changelog/2026-10-19-ponytail-plugin) - 2026-10-17: [One folder per backend feature, tagged errors and written rules](/docs/changelog/2026-10-17-backend-structure) - 2026-10-17: [One folder per mobile feature, and the API aliases are gone](/docs/changelog/2026-10-17-mobile-structure) - 2026-10-17: [The web app is organized by feature, like the backend](/docs/changelog/2026-10-17-web-structure) - 2026-10-16: [Auth and tenant security fixes](/docs/changelog/2026-10-16-auth-and-security-fixes) - 2026-10-16: [Database, Worker and web performance](/docs/changelog/2026-10-16-performance-and-placement) - 2026-10-16: [Row-level security per Organization](/docs/changelog/2026-10-16-row-level-security) - 2026-10-15: [AI assistant and one tool catalog](/docs/changelog/2026-10-15-assistant-and-agent-tools) - 2026-10-15: [Dependency round, i18next fix and ReUI controls](/docs/changelog/2026-10-15-dependency-round-and-reui) - 2026-10-15: [Developer documentation and changelog](/docs/changelog/2026-10-15-developer-docs-and-changelog) - 2026-10-15: [Documents and large uploads](/docs/changelog/2026-10-15-documents-and-large-uploads) - 2026-10-15: [Landing page redesign](/docs/changelog/2026-10-15-landing-redesign) - 2026-10-15: [Landing sections and project skills](/docs/changelog/2026-10-15-landing-sections-and-project-skills) - 2026-10-14: [Trace and code block primitives](/docs/changelog/2026-10-14-trace-and-code-blocks) - 2026-10-13: [TanStack Start 1.0, OG images and durable imports](/docs/changelog/2026-10-13-start-og-and-imports) - 2026-10-12: [Payments, native screens and a new landing page](/docs/changelog/2026-10-12-payments-and-native-experience) - 2026-10-03: [Runtime and recovery hardening](/docs/changelog/2026-10-03-runtime-and-recovery) - 2026-09-25: [Authentication foundations and performance](/docs/changelog/2026-09-25-auth-and-performance) - 2026-09-24: [Platform dependency alignment](/docs/changelog/2026-09-24-platform-dependencies) - 2026-09-03: [oRPC and Drizzle upgrades](/docs/changelog/2026-09-03-typed-api-upgrade) - 2026-08-25: [Typed domain operations and a real dashboard](/docs/changelog/2026-08-25-typed-domain-and-dashboard) - 2026-08-24: [Agent connections and native account security](/docs/changelog/2026-08-24-agent-connections-and-account-security) - 2026-08-13: [Cloudflare email and shared UI](/docs/changelog/2026-08-13-email-and-ui) - 2026-07-30: [An enforced client/server boundary](/docs/changelog/2026-07-30-client-boundary) - 2026-07-24: [Native interface foundations](/docs/changelog/2026-07-24-native-foundation) - 2026-07-13: [Administration and data workflows](/docs/changelog/2026-07-13-admin-and-data-workflows) - 2026-06-10: [Setup, storage and localization](/docs/changelog/2026-06-10-setup-storage-and-i18n) # Deployment and rollback GitHub Actions deploy isolated previews and the production Worker with reviewed migrations. ## Production [#production] A push to `main` triggers `deploy-prod.yml`. Its concurrency group serializes production deployment without cancelling an in-flight run. The workflow checks out the revision, installs the frozen lockfile, applies pending database migrations, builds the Worker and deploys it with Wrangler. `pnpm deploy` is the explicit local deployment path. It uses configured credentials and can change production state. Routine development and bundle measurement should use a build and `wrangler deploy --dry-run` instead. ## Pull request previews [#pull-request-previews] The preview workflow uses `scripts/preview.mjs` to derive and validate an isolated Worker/database target. It ensures the preview branch/configuration and, when production uses Hyperdrive, a Hyperdrive of the PR's own (named after the preview Worker, query caching disabled, pointing at the PR's database branch) so the preview takes the production connection path. It applies migrations through the named preview migration URL, builds, and deploys the preview with its secrets in the same version (`wrangler deploy --secrets-file`): the env schema validates when the Worker loads, so a new Worker must never start without them. Then it checks the runtime. Closing the PR deletes the Worker, its Hyperdrive and its database branch from trusted default-branch code. The CI token needs Hyperdrive edit permission. Preview isolation is a correctness boundary: a preview must never silently reuse production auth, storage or database resources. Read the workflow and isolation tests before changing naming, credentials or teardown. ## Configuration and secrets [#configuration-and-secrets] Cloudflare API credentials authorize the workflow; Worker runtime secrets are separate. Database access, Better Auth, R2/files and optional providers must be configured for the target environment. `VITE_SITE_URL` is a public build value, not a secret. Setup and the deployment skill describe the concrete secret names and provider steps. Hyperdrive points to Postgres with query caching disabled: cached reads are keyed by query text and parameters, not by the tenant role and settings row-level security uses. `pnpm run setup --hyperdrive-id ` disables it (`wrangler hyperdrive update --caching-disabled`), and the production deploy refuses to publish when `node packages/project-setup-cli/src/hyperdrive.mjs check` finds caching on. R2 uses the environment’s intended bucket. The Worker owns fetch and scheduled entry points; cron runs the existing job program. Adding a feature does not require another Worker by default. ## Migrations and rollback [#migrations-and-rollback] Migrations run before the new code is deployed. Review compatibility with the previous Worker because that version can still serve traffic while the database changes. Prefer additive changes and staged removal over a destructive migration coupled to one deploy. To roll back code, identify the known-good Worker deployment/version in Cloudflare and use the supported Wrangler rollback command for the exact target. Verify runtime routes and scheduled behavior afterward. A Worker rollback does **not** reverse Postgres migrations, restore deleted records or undo external deliveries. Database recovery requires its own reviewed plan and backup/restore procedure. ## Verification [#verification] Build and run the produced Worker locally, then validate the preview URL and its discovery/auth behavior before merging. Check workflow logs for migration and target selection, and production traces for job execution after deployment. Never treat a green Node dev server as workerd compatibility evidence. ## Key files [#key-files] - [`.github/workflows/deploy-prod.yml`](https://github.com/CarlosZiegler/autofolio/blob/main/.github/workflows/deploy-prod.yml) - [`.github/workflows/pr-preview.yml`](https://github.com/CarlosZiegler/autofolio/blob/main/.github/workflows/pr-preview.yml) - [`scripts/preview.mjs`](https://github.com/CarlosZiegler/autofolio/blob/main/scripts/preview.mjs) - [`.agents/skills/deploy-cloudflare/SKILL.md`](https://github.com/CarlosZiegler/autofolio/blob/main/.agents/skills/deploy-cloudflare/SKILL.md) # Backend conventions The rules for code in packages/api, each with its reason, an example, the anti-pattern and what enforces it. These rules apply to `packages/api`. Each one has the same shape: **rule**, **why**, **example**, **anti-pattern** and **enforcement**. The enforcement level is one of `[type]`, `[lint]`, `[test]`, `[build]`, `[deploy]` or `[convention]`. A `[convention]` rule depends on review alone, so it is the first candidate for a stronger check. The reasons behind the larger rules are recorded in [ADR-0011](/docs/adr/0011-backend-feature-shape) (feature shape) and [ADR-0010](/docs/adr/0010-procedures-run-one-effect-program) (Effect in procedures). ## One folder per feature, one name per role [#one-folder-per-feature-one-name-per-role] **Rule.** A feature lives in `packages/api/src/features//`. Its files are named `..ts`: | Role | File | Required | | --- | --- | --- | | Contract | `.contract.ts` | when the feature has procedures | | Procedures | `.procedures.ts` | when the feature is exposed through oRPC | | Service | `.service.ts` | in migrated features | | Errors | `.errors.ts` | when the feature has domain failures | | Rules, enums, policy | `.rules.ts`, `.enums.ts`, `.policy.ts` | optional, no I/O | | Jobs | `.jobs.ts` | when the feature runs in the cron tick | | Raw HTTP edge | `.http.ts` | when a Worker route sits outside oRPC | | Internals | `..ts` | as needed | | Tests | `..test.ts`; procedure tests are `.procedures.test.ts` | as needed | Code that belongs to no feature lives in `packages/api/src/platform/` (`rpc`, `effect`, `jobs`, `http`, `data-table`). **Why.** The name of a file tells you what it may import and who may import it. A feature can be read, moved or deleted as one folder. **Example.** ```text features/documents/ documents.contract.ts documents.procedures.ts documents.service.ts documents.errors.ts documents.policy.ts documents.jobs.ts documents.queue.ts documents.chunk.ts documents.lifecycle-plugin.ts ``` **Anti-pattern.** A sub-folder per layer (`documents/services/`), a second name for the same role (`router.ts` next to `procedures.ts`), or queries written inside the procedures file of a migrated feature. **Enforcement.** `[test]` `scripts/feature-boundaries.test.mjs` (in `pnpm test`) · `[lint]` direction overrides in `.oxlintrc.json`. ## The contract is a leaf [#the-contract-is-a-leaf] **Rule.** `.contract.ts` starts with the `LEAF MODULE` header. It imports only `zod`, other leaves (`db/schema/*.enums`, `*.enums.ts`, other contracts) and `import type` from anywhere. **Why.** Web and mobile import the contract. One value import of a server module puts server code in a browser bundle or an app binary ([ADR-0002](/docs/adr/0002-no-barrels-on-server-capable-modules)). **Example.** ```ts // LEAF MODULE: imported by web and mobile. Only zod, leaves and `import type`. import { PRIORITIES } from "@autofolio/db/schema/todo.enums"; import { z } from "zod"; export const todoCreateInput = z.object({ text: z.string().min(1), priority: z.enum(PRIORITIES) }); ``` **Anti-pattern.** Importing a constant from a Drizzle table module, or from a service, "because it is already there". **Enforcement.** `[test]` `scripts/leaf-modules.test.mjs`, which also fails when a `features/*/*.contract.ts` lacks the header · `[lint]` `packages/config/oxlint-client-boundary.json` · `[build]` `assert-client-bundle-clean.mjs`. ## No barrel; the public surface is `exports` [#no-barrel-the-public-surface-is-exports] **Rule.** No `index.ts` re-exports a feature. Other packages and apps import an enumerated subpath of `@autofolio/api`. Canonical subpaths are `.//`, for example `./todo/contract` and `./billing/http`. Web and mobile use the canonical subpaths only; the earlier `./routers/*.schema` aliases were removed once the mobile app moved to them. **Why.** A barrel is how server code reached the browser. An enumerated map makes every new cross-package import a reviewed decision. **Example.** `import { todoListInput } from "@autofolio/api/todo/contract";` **Anti-pattern.** A wildcard `"./*"` export, or a `./routers/*` subpath brought back. **Enforcement.** `[test]` `packages/api/src/exports.test.ts`: every target exists, no `./routers/*` subpath comes back, nothing exports a procedure, job or service beyond the listed server entries, and every subpath web and mobile import exists. A removed subpath also fails `tsc` in the app that imports it. ## Imports flow one way [#imports-flow-one-way] **Rule.** - Only `platform/rpc/app-router.ts` and the agent tool catalog import `*.procedures`. - Only `platform/jobs/run.ts` imports `*.jobs`. - `platform/**` does not import features, except those two files and two declared edges: `platform/rpc/procedures.ts` reads `features/admin/admin.roles` (the admin guard) and `platform/rpc/context.ts` builds `features/admin/admin.api` (the `adminApi` capability). - A feature imports another feature's contract, enums, service, errors, policy or rules. Any other cross-feature import is declared in the boundary test. - In a migrated feature, `*.procedures.ts` imports no `drizzle-orm` and no table module (enums are fine): queries live in the service. The lint rule refuses value imports of `drizzle-orm`, the schema barrel `@autofolio/db/schema` and the per-table modules `@autofolio/db/schema/` (enum leaves and `import type` pass). A relational query through `context.db.query.*` needs no import, so the rule cannot see it; that one is left to review. - There are no import cycles in `packages/api/src` and `packages/db/src`. **Why.** Each rule keeps a dependency the reader can predict. A domain module that imports a router or a job is the first sign the layering has rotted. **Example.** `todo.service.ts` imports `enqueueUserWebhooks` from `webhooks.events.ts`, a declared edge, to queue deliveries inside its transaction. **Anti-pattern.** A service that imports another feature's queue or lease module to "reuse a query". **Enforcement.** `[lint]` `no-restricted-imports` overrides in `.oxlintrc.json`: D3 (procedures), D4 (jobs), D5 (platform) and D8 (queries in migrated procedures, listed per feature) · `[test]` `scripts/feature-boundaries.test.mjs` (D6; a declared edge that disappears also fails, so the list only shrinks) · `[test]` `scripts/import-cycles.test.mjs` (D7). The cycle check runs in `pnpm test`, not in `pnpm lint`, because oxlint's `import` plugin made the whole lint about 3.7 times slower. ## Tenant data goes through `context.db` [#tenant-data-goes-through-contextdb] **Rule.** A procedure, tool or gateway reads and writes tenant data through `context.db`, which runs as `app_tenant` for the session's User and Active Organization. The `organizationId` filter stays in every query. Crossing tenants is possible only through `asSystem(db, reason, fn)` with a listed `SystemReason`, and the work happens inside `fn`. **Why.** Row-level security enforces isolation in the database; the filters are defence in depth ([ADR-0009](/docs/adr/0009-row-level-security)). **Example.** ```ts export const listTodos = Effect.fn("todo.list")(function* (db: Database, organizationId: string, input: TodoListInput) { return yield* query(() => db.select().from(todo).where(eq(todo.organizationId, organizationId))); }); ``` **Anti-pattern.** Opening a pool with `createDb` in a feature. Returning the handle from `asSystem`. Network I/O inside `withTenant`. **Enforcement.** `[lint]` the pool-import restriction and `check-system-call-sites.mjs` · `[test]` RLS isolation, catalog and matrix suites · `[convention]` no network I/O inside `withTenant`. ## Org roles are checked only where a product rule needs them [#org-roles-are-checked-only-where-a-product-rule-needs-them] **Rule.** Membership in the Active Organization is the default authorization for Organization data (`orgProcedure`). When a product rule limits an operation to some Org roles, chain `requireOrgRole` from `platform/rpc/require-org-role.ts` after `orgProcedure`. Do not compare role strings inside a service. **Why.** `orgProcedure` already reads the Member's role in its membership statement, so the check costs no extra round trip, and a missing or unknown role fails closed with `FORBIDDEN`. Row-level security checks membership, not roles, so the role check belongs in the procedure ([ADR-0009](/docs/adr/0009-row-level-security)). **Example.** ```ts remove: orgProcedure .use(requireOrgRole("owner", "admin")) .input(z.object({ id: z.number() })) .handler(({ context, input, signal }) => runProcedure(deleteTodo(context.db, context.organizationId!, input.id), todoRpcError, { signal, requestId: context.requestId, }), ), ``` Roles are not hierarchical: `requireOrgRole("admin")` refuses an `owner`, so list every role that may act. Chain it after `orgProcedure`, which reads the role: the middleware requires `organizationRole` in its input context, so `protectedProcedure.use(requireOrgRole(...))` is a type error. The middleware exists and is tested (owner, admin and `admin,member` pass; member and unknown roles are refused), but no procedure applies it yet: today every Member may do everything in their Organization. **Anti-pattern.** Reading `member.role` again in a service, or treating the platform role (`user`/`admin`) as an Org role. **Enforcement.** `[test]` `platform/rpc/require-org-role.test.ts` · `[convention]` where it is applied. ## Domain failures are tagged errors, mapped once [#domain-failures-are-tagged-errors-mapped-once] **Rule.** A failure a caller can act on is a `Schema.TaggedError` in `.errors.ts`. The feature exports one mapper, a `switch` on `_tag` ending in `satisfies never`, that turns each failure into an `ORPCError` with an `APP_ERRORS` code. Services never import `@orpc/*`. **Why.** The failures of an operation become a closed type, the mapper is exhaustive by construction, and the client keeps seeing the stable codes it localizes ([error handling](https://github.com/CarlosZiegler/autofolio/blob/main/docs/error-handling.md)). **Example.** ```ts // abridged from packages/api/src/features/documents/documents.errors.ts export class DocumentNotFound extends Schema.TaggedError()("DocumentNotFound", {}) {} export type DocumentError = DocumentNotFound | DocumentSourceChanged; export function documentRpcError(error: DocumentError) { switch (error._tag) { case "DocumentNotFound": return new ORPCError("NOT_FOUND"); case "DocumentSourceChanged": return new ORPCError("CONFLICT", { message: "The original document changed" }); default: return error satisfies never; } } ``` Keep the code and message the client saw before a migration. `documents.errors.ts` repeats the messages the service used to throw; `todo.errors.ts` passes `APP_ERRORS.NOT_FOUND.message`, because the handler used to throw `errors.NOT_FOUND()`. A `switch` over a union with one member ends in `return error._tag satisfies never`. **Anti-pattern.** `throw new ORPCError("CONFLICT")` inside a service. A string error. A new code that is missing from a locale. **Enforcement.** `[type]` the exhaustive mapper · `[lint]` D2 in `.oxlintrc.json`: no `@orpc/*` in the services, rules and policies of migrated features, listed by path, with the unmigrated features named in the override's comment · `[test]` characterization tests in `packages/api/src/testing/characterization/`, locale tests. ## A procedure runs one program [#a-procedure-runs-one-program] **Rule.** In a migrated feature, the handler calls `runProcedure` once with the service program, the feature mapper, the request `signal` and the request ID. The `signal` is honoured only before the program starts: an already aborted request is refused, but once the program runs, a client that goes away does not interrupt it, so the write and its Audit Event commit and the response goes unread. A service is an `Effect.fn` that receives `db` and `organizationId` as arguments. Database work uses `query` and `transaction` from `platform/effect/db.ts`. Both drain: if the program itself is interrupted (a timeout or deadline inside it), the statement or transaction settles before the program ends. `query(run)` hands `run` that interruption signal, not the client's, which a service may pass to an external read (the documents pilot passes it to the storage `HEAD`). **Why.** There is one place where typed failures become transport errors and where cancellation is handled. An audited procedure runs inside the transaction `withAudit` opens around the handler, so a handler that rejected because the client left would roll back the write and its audit; finishing the started program prevents that (`todo.abort.test.ts`, `documents.abort.test.ts`). A running transaction is drained instead of abandoned ([ADR-0010](/docs/adr/0010-procedures-run-one-effect-program), [ADR-0005](/docs/adr/0005-effect-boundary)). **Example.** ```ts source: orgProcedure.input(documentIdentityInput).handler(({ context, input, signal }) => runProcedure(documentSource(context.db, context.organizationId!, input.fileId), documentRpcError, { signal, requestId: context.requestId, }), ), ``` **Anti-pattern.** - `Effect.runPromise` inside a service. - A second `run*` in the same call. - A `Layer`, `Context.Service` or `ManagedRuntime` that holds a pool, whether at module scope or per request. - Handling interruption by hand. **Enforcement.** `[test]` `platform/rpc/procedure-edge.test.ts` and `platform/effect/db.test.ts` · `[lint]` D8 keeps queries out of migrated procedures · `[convention]` one `runProcedure` per handler, no `Layer` or runtime. ## External I/O has a deadline; only reads retry [#external-io-has-a-deadline-only-reads-retry] **Rule.** A service that calls storage or a provider sets a timeout. It retries only idempotent reads, with `idempotentRead`. A PUT, a provider POST, a webhook delivery or an email send makes one attempt. **Why.** A repeated write can duplicate a side effect. The outbox and lease policy decides when to try again. **Example.** `idempotentRead` wraps the RevenueCat subscriber `GET`, Expo `getReceipts` and the import source reads. The storage `HEAD` that confirms Document Text (`documents.service.ts`) has a 10 s timeout, `retries: 0` and the program's abort signal, and runs before any lock or transaction; the documents pilot kept that behavior instead of adding a retry. The signed `PUT` the browser performs is never retried by the server. **Anti-pattern.** `Effect.retry` around a POST. **Enforcement.** `[convention]` · `[test]` retry tests with `TestClock`. ## Configuration is declared once [#configuration-is-declared-once] **Rule.** Every environment variable is declared in `packages/env` and read through it, including runtime switches such as `DB_TENANT_PIPELINE` and `TRUSTED_PROXIES`. **Why.** The schema is the list of what the deploy must provide. A missing or malformed server secret fails validation when the server environment loads. The runtime switches in `@autofolio/env/runtime-flags` are read on every call instead: an invalid value never becomes a different setting, but falls back to the safe behavior and is logged once per isolate ([runtime switches](/docs/development/environments#runtime-switches)). **Anti-pattern.** `process.env.SOMETHING` in a feature. **Enforcement.** `[lint]` `node/no-process-env` for `packages/**` in `.oxlintrc.json` and for `apps/web/src` in its nested config. `NODE_ENV` and `EXPO_OS` are allowed because bundlers replace them. Named exceptions: `packages/env/**`, tests and `packages/api/src/testing/**`, CLIs (`packages/*/scripts/**`, `project-setup-cli`, `db/src/seed.ts`) and the test-only provider switch in `assistant.provider.ts`. Runtime switches go through `@autofolio/env/runtime-flags`, which reads them on every call. ## Logs separate expected from unexpected [#logs-separate-expected-from-unexpected] **Rule.** An expected failure (a mapped 4xx) is not logged as an error; the server error handler writes it at debug level. An unexpected failure (a defect, a 500) is logged once by the server error handler, with the request ID that the response also returns in `x-request-id`. Logs carry identifiers, never credentials, prompts or private payloads. **Why.** Error logs then mean something needs attention, and a user can quote one ID that leads to the log line. **Anti-pattern.** `console.error` in a service for a `NOT_FOUND`. **Enforcement.** `[test]` the server error handler tests · `[convention]` for payload content. ## Lists page one way per endpoint [#lists-page-one-way-per-endpoint] **Rule.** Use the pagination rule in [oRPC conventions](/docs/features/api#pagination). Use a cursor for lists that grow without bound. Use offset only where the interface needs a total or page numbers. Every query has a total order with an `id` tie-breaker. **Enforcement.** `[test]` list tests · `[convention]` the choice of strategy. ## Key files [#key-files] - [`packages/api/src/platform/rpc/procedures.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/platform/rpc/procedures.ts) - [`packages/api/src/platform/rpc/procedure-edge.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/platform/rpc/procedure-edge.ts) - [`packages/api/src/platform/effect/db.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/platform/effect/db.ts) - [`packages/api/src/features/todo/todo.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/todo/todo.service.ts) - [`packages/api/src/features/documents/documents.errors.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/documents/documents.errors.ts) - [`.oxlintrc.json`](https://github.com/CarlosZiegler/autofolio/blob/main/.oxlintrc.json) # Conventions Use the existing owners and enforce boundaries in code and tooling. ## Hooks and components [#hooks-and-components] Hooks coordinate asynchronous state, mutations, subscriptions, error presentation and navigation. Components render that state and compose primitives. A small static component does not need an artificial hook; a screen should not own a second session authority. ## Module boundaries [#module-boundaries] Backend rules (feature shape, errors, Effect, tenancy, configuration, logging) are on [Backend conventions](/docs/development/backend-conventions), and the rules for `apps/web/src` (routes, features and platform, the `.server` suffix, hooks versus components) are on [Web conventions](/docs/development/web-conventions), each with its reason and enforcement. Before opening a pull request, use the [review checklist](/docs/development/review-checklist). - No new barrels on server-capable modules. Use safe leaves for values needed by clients. - No dynamic `import()` in new implementation. Use static imports and framework-supported build splitting. - Enumerate cross-package exports deliberately. - Do not use `window.confirm`; use the shared accessible confirmation dialog. - Validate external payloads once at their boundary with Zod. Keep internal state typed. - Preserve Organization scope, current authorization and transactional audit requirements. ## UI and language [#ui-and-language] Read `apps/web/PRODUCT.md` before UI work. shadcn is the visual authority; neutral semantic tokens make rebranding inexpensive. Use existing Base UI primitives and the component catalog. Authenticated screens follow the product’s operating interface; marketing has separate design guidance. Add chrome strings to the Portuguese, English and German catalogs together. Preserve keyboard navigation, visible focus, reduced-motion preferences and meaningful labels. Use semantic links for destinations and buttons for actions. ## Changes and evidence [#changes-and-evidence] Use logical Conventional Commits. Keep generated migration SQL and relevant tests with their feature. Record measurements for runtime or bundle changes, including exact commands and limitations. Read the relevant ADR before altering an established boundary; historical measurements describe their captured revision, not a guarantee about the current tree. ## Key files [#key-files] - [`AGENTS.md`](https://github.com/CarlosZiegler/autofolio/blob/main/AGENTS.md) - [`apps/web/PRODUCT.md`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/PRODUCT.md) - [`packages/config/oxlint-client-boundary.json`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/config/oxlint-client-boundary.json) # Writing documentation Author repository MDX, reuse architectural decisions and curate release notes. ## Add a page [#add-a-page] Create `apps/web/content/docs/section/page.mdx` with `title` and `description` frontmatter. Add its slug to that section’s `meta.json` order. Link to canonical `/docs/...` paths. Use fenced code with a language for build-time highlighting. ```md --- title: "A useful title" description: "The decision or task this page explains." --- ## Start with the task Explain the owner, the change and how to verify it. ``` Supported components include `Callout`, `Steps`/`Step`, `Tabs`/`Tab` (supply a unique `id`), `Cards`/`Card`, `FileTree` and `Diagram`. Tabs use native radio inputs so server-rendered articles remain keyboard-operable without article hydration. ## One source of truth [#one-source-of-truth] ADRs and the agent connection guide are imported directly from `docs/`, not copied. Relative repository links are rewritten at compile time. Processed Markdown powers the copy button, `.md` routes and agent discovery. English content remains English when navigation changes language. The collection is synchronous and Vite imports compiled MDX eagerly on the server. Client route data contains rendered public HTML and navigation metadata. The browser does not import the article modules or a Shiki highlighter. Search uses Fumadocs’ server index at `/api/docs/search`. ## Release notes [#release-notes] Add one dated MDX file under `content/docs/changelog/YYYY-MM-DD-slug.mdx` with title, description and an ISO date string. Describe observable changes and link to their guides and commits. The newest three entries render in full; older entries remain readable individually. The RSS feed is `/docs/changelog/rss.xml`. ```sh node scripts/changelog-draft.mjs ``` The script prints a draft from merges and Conventional Commits since the latest entry date. A maintainer chooses what belongs in the release, checks dates and edits the prose before saving it. Historical entries are curated groups of changes, not claims of tagged semantic versions. ## Diagrams [#diagrams] Use the diagram-design workflow, keep its HTML sources and export static SVG. Docs diagrams use the app’s neutral palette and font. Local scrolling preserves readable labels on narrow screens. Update both theme exports and their descriptive text when the underlying flow changes. ## Key files [#key-files] - [`apps/web/source.config.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/source.config.ts) - [`apps/web/src/features/docs/docs.source.server.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/features/docs/docs.source.server.ts) - [`apps/web/src/features/docs/docs.mdx-components.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/features/docs/docs.mdx-components.tsx) # Enforcement Which rule is checked by what: type checks, lint, tests, build and deploy gates, scripts, and the rules that still rely on review. A rule that nothing checks decays. This page lists the repository's rules, where each one is stated and what fails when it is broken. Use it to decide how much to trust a rule, and to find the next rule that should get a check. ## Levels [#levels] | Level | Meaning | | -------------- | --------------------------------------------------------------------- | | `[type]` | `pnpm check-types` rejects the violation. | | `[lint]` | `pnpm lint` (oxlint plus repository checks) rejects it. | | `[test]` | A test in `pnpm test`, the integration suite or the RLS matrix fails. | | `[build]` | `pnpm --filter web build` fails. | | `[deploy]` | A preview or production workflow refuses to publish. | | `[tool]` | A repository script refuses the action. | | `[manual]` | A verification script exists, but you run it yourself; CI does not. | | `[convention]` | Only review holds it. These are the fragile ones. | ## Coverage [#coverage] | Rule | Stated in | Enforcement | | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Clients import leaves, contracts or types only | [ADR-0002](/docs/adr/0002-no-barrels-on-server-capable-modules) | `[lint]` `packages/config/oxlint-client-boundary.json` (server-only specifiers, including `@autofolio/api/mcp/*`), extended by `apps/web/src`, `apps/mobile/src`, `packages/ui/src` · `[test]` `scripts/leaf-modules.test.mjs` (D1) · `[build]` `apps/web/scripts/assert-client-bundle-clean.mjs` | | A feature contract carries the `LEAF MODULE` header | [ADR-0011](/docs/adr/0011-backend-feature-shape) | `[test]` `scripts/leaf-modules.test.mjs` | | Package exports are enumerated and documented | [ADR-0002](/docs/adr/0002-no-barrels-on-server-capable-modules), [ADR-0011](/docs/adr/0011-backend-feature-shape) | `[test]` `packages/api/src/exports.test.ts` · `[test]` the package-exports check in [`apps/web/src/features/docs/docs.source.server.test.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/features/docs/docs.source.server.test.ts) | | Clients import canonical API subpaths; no `./routers/*` subpath comes back | [ADR-0013](/docs/adr/0013-mobile-features-mirror-the-backend-shape) | `[test]` `packages/api/src/exports.test.ts` · `[type]` a removed subpath fails `tsc` | | Mobile layers, cross-feature edges, platform variants, the route list and data access only in hooks | [ADR-0013](/docs/adr/0013-mobile-features-mirror-the-backend-shape) | `[test]` `scripts/mobile-feature-boundaries.test.mjs`, `scripts/import-cycles.test.mjs` (a test, not lint: see the note below) · `[lint]` `max-lines` in `apps/mobile/src/.oxlintrc.json` | | No hard-coded user-facing strings in `apps/mobile/src/{app,features,design}` (RNF11): JSX text and literals given to `title`, `label`, `placeholder`, `accessibilityLabel` and similar props | `handoff/data/en/nfrs.json` | `[test]` `scripts/user-facing-strings.test.mjs` (explicit allowlist with reasons) | | Only the app router and the tool catalog import `*.procedures` (D3) | [ADR-0011](/docs/adr/0011-backend-feature-shape) | `[lint]` `.oxlintrc.json`, override for `packages/api/src/**` | | Only the job runner imports `*.jobs` (D4) | [ADR-0011](/docs/adr/0011-backend-feature-shape) | `[lint]` `.oxlintrc.json`, override for `packages/api/src/**` | | `platform/**` does not import features, except `admin.roles` and `admin.api` (D5) | [ADR-0011](/docs/adr/0011-backend-feature-shape) | `[lint]` `.oxlintrc.json`, override for `packages/api/src/platform/**` | | Features import each other through public roles or declared edges (D6) | [ADR-0011](/docs/adr/0011-backend-feature-shape) | `[test]` `scripts/feature-boundaries.test.mjs` | | No import cycles in `packages/api/src` and `packages/db/src` (D7) | [ADR-0011](/docs/adr/0011-backend-feature-shape) | `[test]` `scripts/import-cycles.test.mjs`, run by `pnpm test` | | Migrated procedures hold no queries (D8) | [ADR-0011](/docs/adr/0011-backend-feature-shape) | `[lint]` `.oxlintrc.json`, override listing `todo` and `documents` procedures: refuses `drizzle-orm`, the schema barrel `@autofolio/db/schema` and `@autofolio/db/schema/
` · `[convention]` `context.db.query.*`, which needs no import | | Web imports flow routes → features → platform (W1) | [Web conventions](/docs/development/web-conventions) | `[test]` [`scripts/web-feature-boundaries.test.mjs`](https://github.com/CarlosZiegler/autofolio/blob/main/scripts/web-feature-boundaries.test.mjs) | | Web features meet through public roles (queries, hooks, …) or a declared edge (W2) | [Web conventions](/docs/development/web-conventions) | `[test]` `scripts/web-feature-boundaries.test.mjs` | | Web server-only code is named `.server`; only server surfaces and the two isomorphic bridges import it (W3) | [Web conventions](/docs/development/web-conventions) | `[test]` `scripts/web-feature-boundaries.test.mjs` · `[build]` TanStack Start import protection · `[build]` `assert-client-bundle-clean.mjs` | | No `createServerFn` in the web (W4) | [Web conventions](/docs/development/web-conventions) | `[test]` `scripts/web-feature-boundaries.test.mjs` · `[build]` `apps/web/scripts/assert-server-fn-manifest.mjs` | | Web components hold no data access; hooks do (W5) | [Web conventions](/docs/development/web-conventions) | `[test]` `scripts/web-feature-boundaries.test.mjs` (no exemptions) | | No `.client.` file names in the web (W6) | [Web conventions](/docs/development/web-conventions) | `[test]` `scripts/web-feature-boundaries.test.mjs` | | No new dynamic `import()` in the web (W7) | [Web conventions](/docs/development/web-conventions) | `[test]` `scripts/web-feature-boundaries.test.mjs` | | No barrel in the web (W8) | [Web conventions](/docs/development/web-conventions) | `[test]` `scripts/web-feature-boundaries.test.mjs` | | `vi.mock`, test `import()` and `new URL` specifiers resolve (W9) | [Web conventions](/docs/development/web-conventions) | `[test]` `scripts/web-feature-boundaries.test.mjs` | | Web feature folders are flat and prefixed (W10) | [Web conventions](/docs/development/web-conventions) | `[test]` `scripts/web-feature-boundaries.test.mjs` | | No import cycles in `apps/web/src` (W11) | [Web conventions](/docs/development/web-conventions) | `[test]` `scripts/web-feature-boundaries.test.mjs` (W11 case: oxlint `import/no-cycle` with the web `tsconfig`), run by `pnpm test` | | Web files stay short (W12) | [Web conventions](/docs/development/web-conventions) | `[lint]` `max-lines` warning in [`apps/web/src/.oxlintrc.json`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/.oxlintrc.json) | | Nothing server-only in the web client (W13) | [Web conventions](/docs/development/web-conventions) | `[lint]` client boundary, with server files carved out by path in `apps/web/src/.oxlintrc.json` · `[build]` `assert-client-bundle-clean.mjs` | | New web locale keys use their feature's prefix | [Web conventions](/docs/development/web-conventions) | `[convention]` · `[test]` catalog parity in `apps/web/src/locales/locales.test.ts` | | Tenant data only through `context.db` | [ADR-0009](/docs/adr/0009-row-level-security) | `[test]` `packages/api/src/platform/rpc/rls.isolation.test.ts`, `packages/db/src/rls-catalog.test.ts`, `test:rls-matrix` (CI job `rls-matrix`) | | RLS survives restores and host moves | [ADR-0009](/docs/adr/0009-row-level-security) | `[deploy]` `db:verify-rls` at the end of `db:apply-pending` | | Cross-tenant paths are listed | [ADR-0009](/docs/adr/0009-row-level-security) | `[lint]` `scripts/check-system-call-sites.mjs` against `packages/db/src/system-call-sites.txt`; pool-import restriction in `.oxlintrc.json` | | No network I/O inside `withTenant` | `AGENTS.md` | `[convention]` | | Isolation holds on a real deploy | [ADR-0009](/docs/adr/0009-row-level-security) | `[manual]` `scripts/rls-preview-proof.mjs` against the preview | | Hyperdrive query caching stays off | [ADR-0009](/docs/adr/0009-row-level-security) | `[deploy]` `packages/project-setup-cli/src/hyperdrive.mjs check` in `.github/workflows/deploy-prod.yml` | | `TENANCY_PROBE` only on previews | [ADR-0009](/docs/adr/0009-row-level-security) | `[deploy]` `scripts/check-production-config.mjs` | | Domain failures are tagged and mapped once | [ADR-0010](/docs/adr/0010-procedures-run-one-effect-program) | `[type]` the `satisfies never` mapper in each `.errors.ts` · `[lint]` D2 in `.oxlintrc.json`: no `@orpc/*` in services, rules and policies of migrated features · `[test]` `packages/api/src/testing/characterization/*.client-errors.test.ts` | | Every error code has a message in every language | [error handling](https://github.com/CarlosZiegler/autofolio/blob/main/docs/error-handling.md) | `[type]` `satisfies` in `packages/errors/src/keys.ts` · `[test]` `packages/errors/src/locales.test.ts`, `apps/web/src/locales/locales.test.ts` | | The browser loads only the page's locale catalog | [Internationalization](/docs/features/i18n) | `[build]` `apps/web/scripts/assert-locale-chunks.mjs` | | Effect never reaches a client | [ADR-0005](/docs/adr/0005-effect-boundary) | `[build]` `apps/web/scripts/assert-client-bundle-clean.mjs` | | A procedure runs one program; a client abort after it starts does not roll back a write; database work drains | [ADR-0010](/docs/adr/0010-procedures-run-one-effect-program) | `[test]` `todo.abort.test.ts`, `documents.abort.test.ts`, `packages/api/src/platform/rpc/procedure-edge.test.ts`, `packages/api/src/platform/effect/db.test.ts` | | One `run*` per entry point, no module-scope I/O runtime | [ADR-0005](/docs/adr/0005-effect-boundary), [ADR-0010](/docs/adr/0010-procedures-run-one-effect-program) | `[convention]` | | Only idempotent reads retry | [ADR-0005](/docs/adr/0005-effect-boundary) | `[test]` `packages/api/src/platform/effect/retry.test.ts` · `[convention]` for new call sites | | Deadlines use production values | [ADR-0005](/docs/adr/0005-effect-boundary) | `[test]` `@effect/vitest` with `TestClock`, e.g. `documents.jobs.test.ts`, `imports.jobs.test.ts` | | Queue persistence changes pass the fault gate | [ADR-0006](/docs/adr/0006-effect-persisted-queue) | `[manual]` `scripts/test-persisted-queue-spike.mjs` | | Configuration is declared in `packages/env` | [Backend conventions](/docs/development/backend-conventions) | `[lint]` `node/no-process-env` in `.oxlintrc.json` (`packages/**`) and `apps/web/src/.oxlintrc.json`, with named exceptions | | Runtime switches fail safe on an invalid value | [Environment and secrets](/docs/development/environments#runtime-switches) | `[test]` `packages/env/src/runtime-flags.test.ts` | | Every RPC response carries `x-request-id`, and the error log records the ID | [Error handling](https://github.com/CarlosZiegler/autofolio/blob/main/docs/error-handling.md) | `[test]` `packages/api/src/platform/rpc/request-id.test.ts`, `apps/web/src/routes/api/rpc/$.test.ts` | | Input validation answers HTTP 400 with `INPUT_VALIDATION` | [Error handling](https://github.com/CarlosZiegler/autofolio/blob/main/docs/error-handling.md) | `[test]` `packages/errors/src/server.test.ts` (each `APP_ERRORS` status in `errorStatusMap`), `apps/web/src/routes/api/rpc/$.test.ts` (400 on the wire), characterization tests | | Org role checks fail closed and only follow `orgProcedure` | [Backend conventions](/docs/development/backend-conventions) | `[type]` the middleware's input context requires `organizationRole` · `[test]` `packages/api/src/platform/rpc/require-org-role.test.ts` (including `@ts-expect-error` on other procedures) · `[convention]` where it is applied | | Expected failures are not logged as errors | [Backend conventions](/docs/development/backend-conventions) | `[test]` `packages/errors/src/server.test.ts` | | Schema changes are migrations, never pushes | [Database](/docs/features/database) | `[tool]` `db:push` runs `packages/db/scripts/refuse-push.mjs` · `[test]` CI applies migrations twice | | Optional features can be removed cleanly | [Optional features](https://github.com/CarlosZiegler/autofolio/blob/main/docs/optional-features.md) | `[manual]` `scripts/verify-optional-client-removal.mjs` | | Dependencies respect the release quarantine and exact pins | [Library reference](/docs/libraries) | `[test]` `scripts/tooling-tests.mjs` | | Formatting | [Testing and verification](/docs/development/testing) | `[lint]` `pnpm format:check` (`scripts/check-format.mjs`) | | Docs links, ADR pages and package exports pages are valid | [Writing documentation](/docs/development/docs) | `[test]` [`apps/web/src/features/docs/docs.source.server.test.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/features/docs/docs.source.server.test.ts) | | Runtime and bundle changes are measured | [Conventions](/docs/development/conventions) | `[convention]` | | Pagination strategy per endpoint | [Backend conventions](/docs/development/backend-conventions) | `[convention]` | ## Notes on the lint configuration [#notes-on-the-lint-configuration] - **An oxlint override replaces a rule's options; it does not merge them.** Every `no-restricted-imports` override in `.oxlintrc.json` therefore repeats the system-pool paths of the base rule, and the most specific files come last. A ban added in one override disappears for files a later override matches unless it is repeated there. For the same reason, the mobile app's direction rules are a test (`scripts/mobile-feature-boundaries.test.mjs`): as lint rules they would have to repeat the whole client boundary inside `apps/mobile/src/.oxlintrc.json`. - **D2 and D8 list the migrated features by path.** A feature that moves to tagged errors and an Effect service adds its `*.service.ts`, `*.rules.ts`, `*.policy.ts` and `*.procedures.ts` to those overrides in the same change. The comment above D2 names the features still to migrate; that list only shrinks. - **Import cycles are a test, not a lint rule.** `import/no-cycle` needs oxlint's `import` plugin, whose module graph made `pnpm lint` about 3.7 times slower. `scripts/import-cycles.test.mjs` runs the rule on the two server trees and `apps/mobile/src` inside `pnpm test`. - **The web direction rules are a test for the same reason.** `apps/web/src/.oxlintrc.json` extends the client boundary; a direction rule in one of its overrides would replace that boundary in the files it covers. W1–W11 live in `scripts/web-feature-boundaries.test.mjs`; its W11 case runs `import/no-cycle` on `apps/web/src` the way `scripts/import-cycles.test.mjs` does for the server trees. The web config keeps only the boundary, its server carve-out and the `max-lines` warnings ([ADR-0012](/docs/adr/0012-web-features-mirror-the-backend-shape)). - **Nothing in the root overrides matches `apps/mobile`**, whose lint output stays unchanged. ## Next rules to automate [#next-rules-to-automate] - **No network I/O inside `withTenant`.** A test could wrap the tenant handle and fail when a `fetch` starts inside the callback. - **One `run*` per entry point.** A lint pattern could refuse `Effect.run*` outside `platform/rpc/procedure-edge.ts`, `platform/effect/edge.ts` and the job runner. - **Tenant isolation on a deploy.** `rls-preview-proof.mjs` could run in the preview workflow after the runtime check. - **Optional removal.** The six removal runs could become a scheduled CI job. Today the base run and `--remove-server-billing` pass; the MCP and task combinations stop at `packages/api` type errors that wait on a product decision about those recipes. # Environment and secrets Keep Node, Worker and native configuration aligned without exposing credentials. | Surface | Local source | Visibility | | -------------------------------- | -------------------------------------------- | ------------------------------------------------------- | | Node scripts and web development | `apps/web/.env` | Server variables stay server-only | | Local workerd | `apps/web/.dev.vars` and Wrangler bindings | Worker configuration | | Expo | `apps/mobile/.env` | `EXPO_PUBLIC_` values are bundled into the app | | Production/preview | Worker secrets, bindings and workflow inputs | Server secrets must not become build-time public values | `packages/env` validates each boundary separately. Web public configuration includes `VITE_SITE_URL`; native uses `EXPO_PUBLIC_SERVER_URL`. Never put credentials under public prefixes. Do not paste complete environment files into logs, issues or documentation. ## Database and storage [#database-and-storage] Node uses `DATABASE_URL`. Production workerd uses Hyperdrive; its connection string belongs to the current invocation. Keep Hyperdrive caching disabled. Local built-Worker tests can override the Hyperdrive connection string with an isolated password-bearing local URL. R2 credentials, bucket configuration and files gateway settings must describe the same environment. Public file URLs are an application concern; private objects still require authorization through the gateway. ## Runtime switches [#runtime-switches] Four deployment switches are declared in `packages/env/src/runtime-flags.ts` and read on every call, never cached, so changing one on the Worker takes effect without a code change. Unset and empty mean the default. A value that does not parse never becomes a different setting: the reader keeps the safe behavior and logs `[env] = is invalid; …` once per isolate. | Variable | Default | Accepted values | Behavior | Invalid value | | ---------------------- | ------------------------------------------ | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | `DB_TENANT_PIPELINE` | pipelined | `0`, `1` | `0` is the kill switch: tenant statements fall back to explicit `BEGIN`/`COMMIT` ([runbook](/docs/development/runbooks#tenant-statements-fail-after-a-database-or-driver-change)). | Pipelining stays on, and the value is logged. | | `TENANCY_PROBE` | off | `0`, `1` | `1` mounts `tenancy.probe` for the RLS preview proof. Previews set it; the production deploy refuses it. | The probe stays unmounted, and the value is logged. | | `TRUSTED_PROXY_HEADER` | unset: the socket address is the client IP | a header name, such as `x-forwarded-for` | On Node behind your own proxy, the header the proxy appends the client to. Cloudflare Workers always use `cf-connecting-ip` and ignore it. | Proxy headers are ignored (the socket address is used), and the value is logged. | | `TRUSTED_PROXIES` | `1` when `TRUSTED_PROXY_HEADER` is set | a positive integer | How many trusted proxies append to that header; the client is that many entries from the right. | The proxy header is ignored instead of falling back to one hop, and the value is logged. | ## Dev seed sign-in [#dev-seed-sign-in] `DEV_SEED_LOGIN=true` in `apps/web/.env` mounts `POST /api/auth/dev/seed-sign-in` on `pnpm --filter web dev`. The mobile dev link `autofolio://dev/?lang=pt-BR|en-US|de-DE` (Maestro flows, QA) calls it: it creates the demo User `demo-@example.com` once, loads `handoff/data/seed.json` into its personal Organization with the idempotent `db:seed:autofolio` seed and signs the app in. Off by default; `pnpm test:mobile:server` (a disposable database) turns it on. It is honoured only with `NODE_ENV=development`: a production build or runtime never mounts it, and the path answers 404. ## Optional providers [#optional-providers] Payments require provider selection and provider credentials, plus their visibility flags. AI requires provider configuration and `ai.assistant`. Email chooses an available transport in a defined order. A flag alone cannot configure an external account. Run `pnpm run setup` to populate or repair configuration and `pnpm run doctor` to diagnose it. Keep environment examples descriptive; real values belong in ignored local files or the intended secrets store. ## Key files [#key-files] - [`packages/env/src/server.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/env/src/server.ts) - [`packages/env/src/web.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/env/src/web.ts) - [`packages/env/src/runtime-flags.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/env/src/runtime-flags.ts) - [`packages/env/src/native.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/env/src/native.ts) - [`apps/web/wrangler.jsonc`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/wrangler.jsonc) ## Select the runtime [#select-the-runtime] ```sh # Reads apps/web/.env through the Node configuration path. pnpm dev:web ``` Use the direct development database URL. This runtime does not emulate Worker sockets. ```sh pnpm --filter web build pnpm --filter web exec wrangler dev -c dist/server/wrangler.json --local --port 8790 ``` Supply local Worker variables and an isolated Hyperdrive connection override. This exercises the built workerd artifact. # Review checklist Review or self-review a change against the repository's rules, with the command or test that checks each item. Use this list before opening a pull request and when reviewing one. Skip the sections the change does not touch. Each item names what checks it: `[type]`, `[lint]`, `[test]`, `[build]`, `[deploy]` or `[convention]`. A `[convention]` item is checked only by a reviewer, so read it carefully. ## Always [#always] - [ ] `pnpm check-types`, `pnpm lint`, `FORMAT_BASE_REF=origin/main pnpm format:check` and `pnpm test` pass. `[type]` `[lint]` `[test]` - [ ] Database behavior is tested against the disposable cluster: `node scripts/test-local-postgres.mjs exec pnpm test:integration`. `TEST_DATABASE_URL` never points at a shared or hosted database. `[test]` - [ ] A user-visible change has a changelog entry, and a changed flow updates its guide in the same change. `[convention]` - [ ] Commits are logical Conventional Commits, and file moves are not mixed with behavior changes. `[convention]` ## Backend feature shape [#backend-feature-shape] - [ ] The feature lives in `packages/api/src/features//` with role names (`contract`, `procedures`, `service`, `errors`, `jobs`, `http`) as described in [ADR-0011](/docs/adr/0011-backend-feature-shape). `[test]` boundary test - [ ] The contract file starts with the `LEAF MODULE` header and imports only leaves. `[test]` `scripts/leaf-modules.test.mjs` - [ ] Nothing new imports another feature's internals, a `*.procedures` file or a `*.jobs` file, and `platform/` imports no feature. `[lint]` `.oxlintrc.json` · `[test]` `scripts/feature-boundaries.test.mjs` - [ ] No import cycle was introduced. `[test]` `scripts/import-cycles.test.mjs` - [ ] A migrated feature's `*.service.ts`, `*.rules.ts`, `*.policy.ts` and `*.procedures.ts` are listed in the D2 and D8 overrides of `.oxlintrc.json`. `[lint]` ## Tenant isolation [#tenant-isolation] - [ ] Procedures use `context.db`, which runs as `app_tenant`. The `organizationId` filter is still present. `[test]` `rls.isolation.test.ts` - [ ] A new table uses `pgTable.withRLS` with `tenantPolicy(...)`, gets its GRANT in `packages/db/scripts/rls-baseline.mjs` and ships with a generated, reviewed migration. `drizzle-kit push` is never used. `[test]` `rls-catalog.test.ts` - [ ] A new cross-tenant path uses `asSystem(db, reason, fn)` with a new `SystemReason`, and `node scripts/check-system-call-sites.mjs --write` ran in the same change. `[lint]` - [ ] `withTenant` holds no network I/O. `[convention]` - [ ] An operation limited to some Org roles chains `requireOrgRole(...)` after `orgProcedure`; no service compares role strings. `[test]` `require-org-role.test.ts` · `[convention]` - [ ] A tenancy change ran `pnpm --filter @autofolio/db test:rls-matrix`, and `node scripts/rls-preview-proof.mjs ` passed against the preview before a production deploy. `[test]` `[deploy]` ## Client/server boundary [#clientserver-boundary] - [ ] A client imports only leaves, contracts or `import type`. No barrel was added to a server-capable module ([ADR-0002](/docs/adr/0002-no-barrels-on-server-capable-modules)). `[lint]` - [ ] A new cross-package import added a deliberate `exports` entry, and the package page names it. No `./routers/*` subpath comes back; web and mobile import `/contract`. `[test]` `packages/api/src/exports.test.ts`, docs package-exports test - [ ] `pnpm --filter web build` passes, including `assert-client-bundle-clean.mjs`. `[build]` - [ ] `apps/mobile` changed only if the change is meant for the native app. `[convention]` ## Errors and Effect [#errors-and-effect] - [ ] A domain failure is a `Schema.TaggedError` in `.errors.ts`, and the feature mapper handles it. A service imports no `@orpc/*`. `[type]` `[lint]` - [ ] A new client-visible code is in `APP_ERRORS`, `ERROR_MESSAGE_KEYS` and every locale (pt, en, de). `[type]` `[test]` locale tests - [ ] A procedure body calls `runProcedure` once. No `Layer`, `ManagedRuntime` or I/O runtime exists at module scope ([ADR-0010](/docs/adr/0010-procedures-run-one-effect-program), [ADR-0005](/docs/adr/0005-effect-boundary)). `[convention]` - [ ] Only idempotent reads are retried. A webhook POST or an email send is never retried in-round. `[convention]` ## Configuration and logging [#configuration-and-logging] - [ ] A new environment variable is declared in `packages/env` and documented in the environments guide. It is read through the schema, not `process.env`. `[lint]` `node/no-process-env` - [ ] Expected failures (4xx) are not logged as errors. Unexpected failures carry the request ID. Logs contain no credentials, prompts or private payloads. `[convention]` ## Runtime, bundle and decisions [#runtime-bundle-and-decisions] - [ ] A runtime, deadline or heavy-dependency change has a dated report in `docs/measurements/` with exact commands and limits. `[convention]` - [ ] A change to an established boundary reads the relevant ADR first. A new boundary decision gets an ADR with a status line, and the ADR index is updated. `[convention]` - [ ] Dependency changes follow the two-day release quarantine and exact pins. `[test]` tooling tests Read [Enforcement](/docs/development/enforcement) for the full list of rules and how each is checked. # Runbooks Symptom, command and verification for the operational procedures the template supports. Each runbook names a symptom, the commands to run and how to confirm the result. The reasons live in the linked decisions; this page lists only the steps. Run database commands against the environment you mean, and never against a database you need without a backup. ## Database restored or moved to another host [#database-restored-or-moved-to-another-host] **Symptom.** After a `pg_restore` or a provider move, tenant requests see no rows, fail with permission errors, or `db:apply-pending` reports RLS problems. A restore with `--no-owner --no-acl` loses grants, and loses policies when the `app_tenant` role is missing. If the target connection role cannot create roles, have an administrator create `app_tenant` and grant it once: ```sql CREATE ROLE app_tenant NOLOGIN NOINHERIT NOBYPASSRLS; GRANT app_tenant TO WITH INHERIT FALSE, SET TRUE; ``` Restore the dump, then run the pending migrations against the target. The command ends with the RLS verification and repairs its idempotent part: ```sh pnpm --filter @autofolio/db db:apply-pending ``` Check, and repair what is left if needed: ```sh pnpm --filter @autofolio/db db:verify-rls pnpm --filter @autofolio/db db:verify-rls --repair ``` **Verify.** `db:verify-rls` exits 0, and `select pg_has_role(current_user, 'app_tenant', 'SET')` is true on the runtime connection. If the runtime role differs from the migration role, add it to `app.session_roles`; `db:verify-rls` says so. Reason: [ADR-0009](/docs/adr/0009-row-level-security). ## Tenant statements fail after a database or driver change [#tenant-statements-fail-after-a-database-or-driver-change] **Symptom.** Tenant queries error or time out after a PostgreSQL, Hyperdrive or `pg` change, while system paths (Better Auth, cron) work. Turn off statement pipelining without a code change. Set the Worker variable or secret `DB_TENANT_PIPELINE=0`. Tenant statements then use explicit `BEGIN`, settings, statement and `COMMIT`. Redeploy or wait for the variable to apply, and retry the failing request. Reproduce locally with both modes before turning pipelining back on: ```sh pnpm --filter @autofolio/db test:rls-matrix ``` **Verify.** The failing request succeeds with the switch set. `test:rls-matrix` passes in both modes before you remove the variable. ## Hyperdrive query caching is on [#hyperdrive-query-caching-is-on] **Symptom.** The production deploy stops at the Hyperdrive check, or a Hyperdrive was created without `pnpm run setup`. With caching on, reads are keyed by query text and parameters and can cross the tenant boundary. ```sh node packages/project-setup-cli/src/hyperdrive.mjs check pnpm run setup --hyperdrive-id pnpm run doctor --connect ``` **Verify.** The check exits 0 and `pnpm run doctor --connect` reports caching disabled. Reason: [ADR-0009](/docs/adr/0009-row-level-security). ## Prove tenant isolation on a deploy [#prove-tenant-isolation-on-a-deploy] **When.** Before a production deploy that touches tenancy, the database driver or Hyperdrive. ```sh node scripts/rls-preview-proof.mjs ``` Run it against the pull request's preview: the `tenancy.probe` procedure exists only where `TENANCY_PROBE=1`, which is set for previews and refused in the production configuration. When sign-up needs email verification, pass two verified accounts with `RLS_PROOF_USER_A=email:password` and `RLS_PROOF_USER_B=email:password`. **Verify.** Exit 0 means no leak. Exit 1 is a leak or a broken guarantee, so do not deploy. Exit 2 means setup failed (for example, verification required). ## Take production offline [#take-production-offline] **Symptom.** Production must stop serving traffic now. Run the **Emergency shutdown (wrangler worker)** workflow with `action=shutdown` and `confirm=yes`. Run it again with `action=restore` to bring it back. The workflow toggles the Worker's `workers.dev` subdomain only. Production is served from a custom domain (`routes` in `apps/web/wrangler.jsonc`), which the workflow does not block. To stop traffic on the custom domain, also disable the route or add a WAF rule that blocks it in the Cloudflare dashboard, and remove it afterwards. **Verify.** Request the custom domain and the `workers.dev` URL. Both must refuse traffic during the shutdown, and both must serve again after the restore. ## Roll back a Worker deploy [#roll-back-a-worker-deploy] Follow [Deployment and rollback](/docs/deploy#migrations-and-rollback). A Worker rollback does not reverse migrations, restore deleted records or undo external deliveries. ## After an incident [#after-an-incident] Write a short note in the issue or pull request that closed it, with three headings: - **What is back**: the service, the data and the time it returned. - **What is lost**: rows, deliveries or files that could not be recovered, with evidence. - **Verdict**: recovered, partly recovered or not recovered. Add follow-up issues. ## Key files [#key-files] - [`.github/workflows/emergency-shutdown.yml`](https://github.com/CarlosZiegler/autofolio/blob/main/.github/workflows/emergency-shutdown.yml) - [`packages/db/scripts/verify-rls.mjs`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/scripts/verify-rls.mjs) - [`scripts/rls-preview-proof.mjs`](https://github.com/CarlosZiegler/autofolio/blob/main/scripts/rls-preview-proof.mjs) - [`packages/project-setup-cli/src/hyperdrive.mjs`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/project-setup-cli/src/hyperdrive.mjs) # Workspace scripts Run the repository’s existing entry points from the workspace root. | Command | Purpose | | -------------------------------------- | ------------------------------------------------------------------------ | | `pnpm run setup` / `pnpm run doctor` | Configure or diagnose the project | | `pnpm dev` | Start workspace development tasks | | `pnpm dev:web` / `pnpm dev:mobile` | Start one application | | `pnpm build` | Build workspace targets | | `pnpm check-types` | Run workspace type checks | | `pnpm lint` | Repository source checks | | `pnpm exec oxlint` | Oxlint, including import boundaries | | `pnpm format:check` / `pnpm format` | Check changed files / format | | `pnpm test` | Unit suites across workspaces | | `pnpm test:integration` | Isolated local Postgres integration suite | | `pnpm db:generate` / `pnpm db:migrate` | Generate / apply reviewed migrations | | `pnpm db:studio` | Open database inspection tooling | | `pnpm jobs:run` | Run scheduled work manually against configured data (`--every 60` loops) | | `pnpm update:js` | Guarded JavaScript dependency updater | | `pnpm update:native` | Expo compatibility repair; review and rebuild afterward | | `pnpm deploy` | Explicit deployment to the configured Worker | `db:push` deliberately refuses execution. Jobs and deployment commands can change configured remote state; use a disposable environment for experiments. See [testing](/docs/development/testing) for isolated commands. Turborepo coordinates workspace tasks. Before changing its configuration, resolve the installed `turbo` package and read its bundled `docs/README.md` and relevant task documentation; behavior is version-dependent. ## Key files [#key-files] - [`package.json`](https://github.com/CarlosZiegler/autofolio/blob/main/package.json) - [`turbo.json`](https://github.com/CarlosZiegler/autofolio/blob/main/turbo.json) - [`scripts/update-dependencies.mjs`](https://github.com/CarlosZiegler/autofolio/blob/main/scripts/update-dependencies.mjs) # Local setup Configure a clone without overwriting an existing project’s identity or credentials. ## Prerequisites [#prerequisites] Use the repository’s pnpm version (`packageManager` in the root manifest), a supported Node runtime (CI uses Node 24), Git and a local Postgres installation for integration tests. Native development additionally needs the platform toolchain and a compatible Expo development client. ## Configure and start [#configure-and-start] pnpm 11 has built-in `setup` and `doctor` commands, so the repository's scripts need `pnpm run`. AutoFolio runs locally for now ([ADR-0014](/docs/adr/0014-autofolio-on-the-template)): a local database, no deploys and no CI. ```sh pnpm install --frozen-lockfile pnpm run setup # identity, env files, auth secret pnpm run doctor ``` For an existing clone, preserve its identity and credentials and repair only missing configuration. Point `DATABASE_URL` at a [local PostgreSQL](#local-postgresql) and set `FIELD_ENCRYPTION_KEY` (32 bytes, base64) so plates and VINs can be saved. Then apply the migrations and start the web with the demo garage: ```sh pnpm run db:migrate DEV_SEED_LOGIN=true pnpm dev:web # http://localhost:3005 ``` `DEV_SEED_LOGIN` mounts the dev-only seed sign-in ([environments](/docs/development/environments)), which loads `handoff/data/seed.json` into one demo account per language. ## Mobile dev client [#mobile-dev-client] ```sh EXPO_PUBLIC_SERVER_URL=http://127.0.0.1:3005 pnpm dev:mobile pnpm --filter mobile ios # or android; builds and installs the dev client ``` Use the development build, not Expo Go. Open any board signed into the demo garage with `autofolio://dev/?lang=pt-BR|en-US|de-DE`, for example: ```sh xcrun simctl openurl booted "autofolio://dev/M-Garagem?lang=de-DE" ``` [Mobile app](/docs/features/mobile) covers the dev link in detail. ## End-to-end flows [#end-to-end-flows] ```sh node scripts/maestro.mjs F1 # Maestro flows F1–F5 and F7 from handoff/e2e/maestro, run against the dev client npx playwright test # F6, the buyer passport at /p/demo-token; needs DEV_SEED_LOGIN=true ``` ## Local PostgreSQL [#local-postgresql] Any PostgreSQL 16 or newer works; Neon is only one host. To develop without a hosted database, run one in Docker (or Homebrew) and connect as a table owner that can create roles: ```sh docker run -d --name template-postgres -e POSTGRES_PASSWORD=postgres -p 127.0.0.1:5432:5432 postgres:17 docker exec template-postgres psql -U postgres \ -c "CREATE ROLE template_owner LOGIN CREATEROLE CREATEDB PASSWORD 'template'" \ -c "CREATE DATABASE template_dev OWNER template_owner" ``` Set `DATABASE_URL=postgresql://template_owner:template@127.0.0.1:5432/template_dev` in `apps/web/.env` (and `.dev.vars` for `wrangler dev`), then `pnpm db:migrate`. Put it in the env file rather than the shell: `pnpm run dev` goes through Turbo, which does not pass undeclared shell variables to the dev server. The first migration creates the `app_tenant` role that row-level security switches to; connecting as the stock `postgres` superuser also works. If your role cannot create roles, the migration prints the two statements an administrator must run once. For the built Worker, Hyperdrive needs a local override with a password, e.g. `CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE=postgresql://template_owner:template@127.0.0.1:5432/template_dev`, or `localConnectionString` on the `hyperdrive` entry of an uncommitted Wrangler config. Row-level security behaves the same with and without Hyperdrive; see [tenant isolation](/docs/features/database#tenant-isolation). ## Local runtime [#local-runtime] The web dev server defaults to port 3005 and uses Nitro/Node, not workerd. The production build uses the Cloudflare plugin. A successful dev request alone does not validate Worker compatibility. Use the [built-Worker checks](/docs/development/testing) for changes to server dependencies. Parallel worktrees need separate ports and isolated databases. Set an explicit dev-server port, adjust the public server URL used by mobile, and stop only processes you started. Do not run migrations against another lane’s database. ## Common setup failures [#common-setup-failures] Missing credentials belong in the correct [environment file](/docs/development/environments). A stale development client needs rebuilding after native dependency changes. A database history mismatch needs investigation, not `db:push`. Start with `pnpm run doctor` and the existing setup skill before manually rewriting project identifiers. ## Key files [#key-files] - [`packages/project-setup-cli/src/index.mjs`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/project-setup-cli/src/index.mjs) - [`.agents/skills/setup-envs/SKILL.md`](https://github.com/CarlosZiegler/autofolio/blob/main/.agents/skills/setup-envs/SKILL.md) - [`.agents/skills/startup/SKILL.md`](https://github.com/CarlosZiegler/autofolio/blob/main/.agents/skills/startup/SKILL.md) # Testing and verification Verify domain behavior against real Postgres and the deployed runtime shape. ## Fast gates [#fast-gates] ```sh pnpm install --frozen-lockfile pnpm check-types pnpm lint pnpm exec oxlint FORMAT_BASE_REF=origin/main pnpm format:check pnpm test pnpm --filter mobile exec tsc --noEmit ``` Type checks do not validate Worker bundling or database behavior. The format check compares changed files to its base reference; use the intended review base. ## Isolated Postgres [#isolated-postgres] ```sh node scripts/test-local-postgres.mjs exec pnpm test:integration node scripts/test-local-postgres.mjs exec node scripts/test-local-jobs.mjs ``` The wrapper creates a disposable local cluster, exports the owned test database variables, applies migrations and stops its cluster when the child finishes. It refuses unsafe assumptions about a remote/shared test database. The jobs harness runs the built Worker with controlled provider fixtures and exercises persisted outcomes, timeouts, leases and repeated ticks. Effect timeout unit tests use `@effect/vitest` with `TestClock` and production durations. Queue persistence changes additionally require the fault gate in [ADR-0006](/docs/adr/0006-effect-persisted-queue). ## What to test [#what-to-test] Test the decisions your change makes, not the framework, the ORM or a getter. Database tests run against the disposable cluster above. **Every procedure:** - one success with the documented output; - one `INPUT_VALIDATION` rejection (HTTP 400 on the wire; `BAD_REQUEST` through an in-process `createRouterClient`); - a caller who is not a Member of the Active Organization gets `FORBIDDEN`; - another Organization's row is a `NOT_FOUND`, never a result (use the `two-tenants` fixture in `packages/api/src/testing/two-tenants.ts`); - each tagged error the service can raise, asserting the code and message the client sees (the pilots pin theirs through the real RPC handler in `packages/api/src/testing/characterization/`); - an audited write records its Audit Event in the same transaction. **Every new table or tenant path:** the RLS catalog test passes, and the change is proved on every role profile with `pnpm --filter @autofolio/db test:rls-matrix`. **Every job:** a replay of the same work is a no-op, a lease held by another consumer is not overwritten, and a deadline uses the production value with `TestClock`. **Every list:** the order is stable with equal sort values, the limit is capped, and a malformed cursor is rejected as input. ## Built Worker [#built-worker] ```sh pnpm --filter web build # Run from apps/web in an isolated local test environment: pnpm exec wrangler dev -c dist/server/wrangler.json --local --port 8790 ``` Provide the Hyperdrive local override for the configured binding ID with a password-bearing URL such as `postgresql://template_owner:local@127.0.0.1:PORT/template_test` (the table owner `pnpm test:integration` creates). The exact variable name comes from the Wrangler binding. Keep local tests local; do not deploy to discover a bundling failure. The build checks that client assets contain no server-only markers and that production AI code does not gain the server-function manifest dependency. Runtime smoke checks should include a public page, the changed API, auth refusal paths and discovery endpoints. ## Docs checks [#docs-checks] The web unit suite loads compiled MDX, validates metadata and links, orders releases, and checks Markdown/discovery output. Browser review covers light/dark, desktop/mobile, keyboard focus, local diagram scrolling, code blocks and the command palette. Bundle reports separate total client assets from the initial route graph. ## Key files [#key-files] - [`scripts/test-local-postgres.mjs`](https://github.com/CarlosZiegler/autofolio/blob/main/scripts/test-local-postgres.mjs) - [`scripts/test-local-jobs.mjs`](https://github.com/CarlosZiegler/autofolio/blob/main/scripts/test-local-jobs.mjs) - [`apps/web/scripts/assert-client-bundle-clean.mjs`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/scripts/assert-client-bundle-clean.mjs) # Web conventions The rules for code in apps/web/src, each with its reason, an example, the anti-pattern and what enforces it. These rules apply to `apps/web/src`. They mirror the [backend conventions](/docs/development/backend-conventions) and are recorded in [ADR-0012](/docs/adr/0012-web-features-mirror-the-backend-shape). Each rule has an ID (W1–W13) that the boundary test, the [enforcement matrix](/docs/development/enforcement) and reviews refer to. Enforcement levels are explained on the enforcement page. ## The shape [#the-shape] ```text apps/web/src/ routes/ file-based routing, thin; imports features and platform features// one folder per feature: .queries.ts, .hooks.ts, .page.tsx, components ..tsx, model ..ts, server-only [.].server.ts(x), assets/ (generated files only) platform// no domain: rpc, auth, i18n, errors, theme, layout, data-table, seo, og, … locales/ pt, en, de (central) ``` | Role | File | Public to other features | | ---------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | | Queries | `[.].queries.ts`: `queryOptions`, `mutationOptions`, keys, `prefetch*` for loaders | yes | | Hooks | `[.].hooks.ts`: state, effects, mutations, the auth client, `useQuery` | yes | | Page | `.page.tsx` (and `.page-view.tsx` when the page splits container and view) | to `routes/` only | | Components | `..tsx`: presentation | no, unless declared | | Model | `..ts`: pure logic | no, unless declared | | Server | `[.].server.ts(x)` | to server routes, `server.ts`, other `.server` files and the bridges | | Tests | `..test.ts(x)`, next to the file | — | Search params are validated in the route's `validateSearch`, usually with the schema the backend contract exports (`auditListInput`), and the route passes `search` and `navigate` to the page. Whether a feature-level module for search-param helpers is a public role is decided by `PUBLIC_ROLES` in the boundary test. ## W1. Imports flow routes → features → platform [#w1-imports-flow-routes--features--platform] **Rule.** `platform/**` imports no `features/**`. No file imports a route file or `router.tsx`, except tests and the generated `routeTree.gen.ts`. **Why.** Each layer can be read without the one above it, and a feature can be moved or deleted without touching platform code. **Example.** [`routes/_auth/dashboard.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/routes/_auth/dashboard.tsx) imports `@/features/dashboard/dashboard.page`; its data comes through `@/features/dashboard/dashboard.hooks` and `@/features/audit/audit.hooks`, which imports `@/platform/rpc/orpc`. **Anti-pattern.** A platform helper that imports a feature's hook "because it is convenient", or a page that imports its own route to read `Route.useSearch()`: the route passes `search` and `navigate` to the page as props instead. **Allowed, with care.** `getRouteApi("/_auth/admin/audit")` names a route by its ID without importing it, so W1 does not see it. It couples the component to that URL; use it only inside the feature that owns the route. **Enforcement.** `[test]` `scripts/web-feature-boundaries.test.mjs`. ## W2. Features meet through public roles [#w2-features-meet-through-public-roles] **Rule.** A feature imports another feature only through its public roles (`*.queries`, `*.hooks` and any other role listed in `PUBLIC_ROLES` of the boundary test), or through an edge declared in the boundary test with its reason. Imports between features use `@/features//…`, never `../`. `import type` is free. A route may import any non-test file of any feature: it is the composition root. **Why.** The public roles are the stable surface. A shared component is a deliberate, visible dependency instead of an accident. **Example.** `settings.page-view.tsx` shows the activity timeline of `audit` through the declared edge `audit.activity-timeline.tsx` ← `settings`. **Anti-pattern.** `import { TodoGrid } from "../todo/todo.grid"` from another feature. **Enforcement.** `[test]` `scripts/web-feature-boundaries.test.mjs`: an undeclared edge fails, a declared edge that no longer exists fails, and so does a relative import across features. ## W3. Server-only code is named `.server` [#w3-server-only-code-is-named-server] **Rule.** Every server-only module outside `routes/` and `server.ts` ends in `.server.ts(x)`. A file that is not `.server` imports a `.server` file only if it is a server surface or one of the two isomorphic bridges. The server surfaces are `server.ts`, tests, `routes/api/**`, `routes/[.]well-known/**` and the handler-only resource routes: a `[.]` in the name and no `component` (`docs[.]md.ts`, `llms[.]txt.ts`, `sitemap[.]xml.tsx`, …). A route with a component renders in the browser and is not a server surface, whatever its name. The bridges are [`platform/auth/session-query.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/platform/auth/session-query.ts) and [`features/docs/docs.page-loader.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/features/docs/docs.page-loader.ts); each uses the `.server` module only inside `createIsomorphicFn().server()`. The oRPC client, [`platform/rpc/orpc.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/platform/rpc/orpc.ts), is not on that list: it imports no `.server` file. It value-imports the backend router inside its own `.server()` branch, the exception ADR-0002 names, and is carved out of the client boundary lint by path (W13). **Why.** TanStack Start's import protection denies `**/*.server.*` files to the client environment. In `vite build` a denied import that is still in the client graph after the compiler strips `.server()` branches fails the build; in `vite dev` it becomes a mock that logs an error when used. The suffix turns a naming convention into a build error. **Example.** `platform/auth/read-session.server.ts` reads the session for server routes; the client asks through the session query bridge. **Anti-pattern.** A component that imports `features/files/files.gateway.server.ts` to reuse a constant. Put the constant in a leaf instead. **Enforcement.** `[test]` boundary test, which also fails on a listed bridge that no longer imports a `.server` file · `[build]` import protection · `[build]` `assert-client-bundle-clean.mjs`, with markers that exist only in web server modules. ## W4. No `createServerFn` [#w4-no-createserverfn] **Rule.** `apps/web/src` contains no `createServerFn`. Adding one changes the boundary test and ADR-0012, and must pass `assert-server-fn-manifest.mjs`. **Why.** A server function reached only through a `.server()` branch shipped with an empty manifest, and `/_auth` answered 500 (`070873c`). **Anti-pattern.** Wrapping a session read in `createServerFn` to call it from a component. **Enforcement.** `[test]` boundary test · `[build]` `apps/web/scripts/assert-server-fn-manifest.mjs`. ## W5. Components present; hooks hold the logic [#w5-components-present-hooks-hold-the-logic] **Rule.** Components (`features/**/*.tsx` and `routes/**/*.tsx`, except tests) make no value import of `@tanstack/react-query`, `@/platform/rpc/orpc` or `@/platform/auth/auth-client`. **Why.** Data access, cache keys and mutations live in one place per feature, and a component can be rendered with fake data. **Example.** The [`/dashboard` route](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/routes/_auth/dashboard.tsx) reads its data with `useDashboard()` from [`dashboard.hooks.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/features/dashboard/dashboard.hooks.ts) and passes it to [`dashboard.page.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/features/dashboard/dashboard.page.tsx) as props; its loader prefetches through [`dashboard.queries.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/features/dashboard/dashboard.queries.ts). **Anti-pattern.** `useQuery(orpc.audit.list.queryOptions(…))` inside a component. The rule is about the module, not the import text: reaching the oRPC client through a relative path, or TanStack Query through a re-export in another file, breaks it the same way. **Enforcement.** `[test]` boundary test. Its transition list is empty: no component is exempt, and an entry added later fails once the file is fixed. ## W6. No `.client.` in a file name [#w6-no-client-in-a-file-name] **Rule.** No file in `apps/web/src` has `.client.` in its name. **Why.** The import protection denies `**/*.client.*` on the server; a module with that name imported during SSR would break rendering. That is why the auth client is `auth-client.ts`. **Enforcement.** `[test]` boundary test. ## W7. No new dynamic `import()` [#w7-no-new-dynamic-import] **Rule.** Production code has no dynamic `import()` beyond the allowlist: the native/WASM choice in the OG renderer and the development-only devtools in the root document. **Why.** Code splitting is the framework's job (it splits route components from loaders); an ad hoc `import()` hides a module from the static graph the boundary checks rely on. **Enforcement.** `[test]` boundary test. ## W8. No barrel [#w8-no-barrel] **Rule.** No `index.ts(x)` outside `routes/` (where an index file is a route) and no `export *`, with or without `as` (`export * as ns from`), in `apps/web/src`. **Why.** Direct imports show which file a screen uses; a barrel is how server code reached the browser before ([ADR-0002](/docs/adr/0002-no-barrels-on-server-capable-modules)). **Enforcement.** `[test]` boundary test. ## W9. Test-only specifiers resolve [#w9-test-only-specifiers-resolve] **Rule.** Every `@/…` or relative specifier in `vi.mock(...)`, `vi.importActual(...)`, a test's `import()` and `new URL("./…", import.meta.url)` points at an existing file. **Why.** The type checker does not read those strings. A stale `vi.mock` path mocks nothing and the test passes for the wrong reason; a stale worker URL fails only at runtime. **Example.** The Document Text conversion worker is created with `new URL("./documents.worker.ts", import.meta.url)`. **Enforcement.** `[test]` boundary test. ## W10. Feature folders are flat and prefixed [#w10-feature-folders-are-flat-and-prefixed] **Rule.** `features//` holds only files named `.…`, plus `assets/` with generated `.json` and `.svg` files. **Why.** The file name says which feature and role it belongs to, wherever it is opened. **Enforcement.** `[test]` boundary test. ## W11. No import cycles [#w11-no-import-cycles] **Rule.** `apps/web/src` has no import cycle between files (type-only imports are ignored). Features may depend on each other in both directions only through public roles or declared edges. **Why.** Cycles make initialization order fragile and blur which layer owns what. **Enforcement.** `[test]` the W11 case of `scripts/web-feature-boundaries.test.mjs`, which runs oxlint's `import/no-cycle` on `apps/web/src` with the web `tsconfig` (so `@/` imports resolve), inside `pnpm test`. It is a test rather than a lint rule for the same reason as D7 on the backend: the import plugin makes `pnpm lint` markedly slower ([ADR-0012](/docs/adr/0012-web-features-mirror-the-backend-shape)). ## W12. Files stay short [#w12-files-stay-short] **Rule.** At most 100 lines of code in `routes/**` and 250 in every other `.ts(x)` file: `features/**`, `platform/**` and the files outside them, such as `server.ts`, `router.tsx` and `testing/**` (blank lines and comments not counted). Tests, `locales/**` and `routeTree.gen.ts` are exempt. **Why.** A route that grows past a wrapper is hiding a page; a component past 250 lines is usually two. **Enforcement.** `[lint]` `max-lines` warning in [`apps/web/src/.oxlintrc.json`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/.oxlintrc.json). A warning does not fail `pnpm lint`; a new file adds none (the gate in the `domain-feature` skill). ## W13. Nothing server-only in the client [#w13-nothing-server-only-in-the-client] **Rule.** The client boundary of `packages/config/oxlint-client-boundary.json` applies to all of `apps/web/src` except the server files listed in one override: `server.ts`, `routes/api/**`, `routes/[.]well-known/**`, `routes/__root.tsx` (it reads the request language through `createIsomorphicFn`), `**/*.server.ts(x)` and `platform/rpc/orpc.ts`. The `max-lines` overrides set no `no-restricted-imports` options, so they leave the boundary intact. **Why.** It is the same boundary as the rest of the repository ([client/server boundary](/docs/architecture/client-server-boundary)). **Enforcement.** `[lint]` the client boundary, unchanged · `[build]` `assert-client-bundle-clean.mjs`. ## Locales [#locales] New keys use the feature's prefix (`TODO_`, `ASSISTANT_`, `DOCS_`, …) in `apps/web/src/locales/{pt,en,de}.ts`, which stay central. Enforcement: `[convention]` for the prefix, `[test]` for parity between the three catalogs. ## Starting a new screen [#starting-a-new-screen] Use the web section of the `domain-feature` skill ([`.agents/skills/domain-feature/SKILL.md`](https://github.com/CarlosZiegler/autofolio/blob/main/.agents/skills/domain-feature/SKILL.md)). ## Key files [#key-files] - [`scripts/web-feature-boundaries.test.mjs`](https://github.com/CarlosZiegler/autofolio/blob/main/scripts/web-feature-boundaries.test.mjs) - [`apps/web/src/.oxlintrc.json`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/.oxlintrc.json) - [`apps/web/src/platform/rpc/orpc.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/platform/rpc/orpc.ts) - [`apps/web/src/platform/auth/session-query.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/platform/auth/session-query.ts) - [`apps/web/src/features/docs/docs.page-loader.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/features/docs/docs.page-loader.ts) - [`apps/web/src/features/dashboard/dashboard.queries.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/features/dashboard/dashboard.queries.ts) - [`apps/web/src/features/dashboard/dashboard.hooks.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/features/dashboard/dashboard.hooks.ts) - [`apps/web/src/features/dashboard/dashboard.page.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/features/dashboard/dashboard.page.tsx) - [`apps/web/src/routes/_auth/dashboard.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/routes/_auth/dashboard.tsx) - [`apps/web/scripts/assert-server-fn-manifest.mjs`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/scripts/assert-server-fn-manifest.mjs) - [`apps/web/scripts/assert-client-bundle-clean.mjs`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/scripts/assert-client-bundle-clean.mjs) # Account preference How a User reads and changes their locale, main currency, units and reminder channels, and why the data region is read-only. ## What it does [#what-it-does] An Account Preference is one row per User (`account_preference`), not per Organization: two Members of one household may use different languages and units. It holds the locale (`pt-BR`, `en-US`, `de-DE`), the main currency totals convert into, the distance unit (`km`/`mi`), the volume unit (`l`/`gal`), the reminder channels (push, email) and the data region (`eu`, `br`, `us`). The first read creates the row from the device locale the client sends, idempotently: two first reads at once store one row. | Device language | Locale | Currency | Distance | Volume | Region | | --------------- | ------- | -------- | -------- | ------ | ------ | | `pt-*` | `pt-BR` | BRL | km | l | br | | `de-*` | `de-DE` | EUR | km | l | eu | | anything else | `en-US` | USD | mi | gal | us | Distance is always stored in km and volume in millilitres; the units only change how values are shown. A Vehicle keeps its own currency; totals in the main currency show the rate and its date. The data region is pinned when the row is created and is not part of `update`: moving an account between regions is a support process (a data migration), so the profile shows it read-only. ## In the app [#in-the-app] The Profile tab (`M-Perfil`) reads the preference once under the tabs and keeps it in one shared query. Until that read returns, the app formats with the device locale's defaults; then the server value wins, including the app language. Language, main currency, units and reminders each open a sheet that sends only the field it changes; the cache updates at once and rolls back if the server refuses. The rows stay disabled until the first read, so an update never meets `NOT_FOUND`. Every screen formats amounts, dates, distances and volumes through `useFormat()` (`apps/mobile/src/features/account-preference/account-preference.hooks.ts`), which binds the `@autofolio/domain` formatters to the account locale and units. ## Procedures [#procedures] | Procedure | Who | Failures | | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `accountPreference.get` `{ deviceLocale? }` | Any signed-in User, for their own row | `UNAUTHORIZED` | | `accountPreference.update` (partial: `locale`, `currency`, `distanceUnit`, `volumeUnit`, `remindByPush`, `remindByEmail`) | Any signed-in User, for their own row | `UNAUTHORIZED`, `INPUT_VALIDATION` (unknown key such as `dataRegion`, currency outside BRL/EUR/USD/GBP/CHF), `NOT_FOUND` (no row yet: call `get` first) | Both return the whole preference. No Active Organization is needed. ## Data and isolation [#data-and-isolation] The `account_preference` table has row-level security keyed by the session's User (`ownedByCurrentUser`). `app_tenant` may insert the row but update only the choice columns, never `data_region` or `user_id`. Deleting the User deletes the row. ## Key files [#key-files] - [`packages/api/src/features/account-preference/account-preference.contract.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/account-preference/account-preference.contract.ts) - [`packages/api/src/features/account-preference/account-preference.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/account-preference/account-preference.service.ts) - [`apps/mobile/src/features/account-preference/account-preference.hooks.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/account-preference/account-preference.hooks.ts) - [`apps/mobile/src/features/settings/settings.profile.screen.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/settings/settings.profile.screen.tsx) - [`packages/db/src/schema/preferences.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/src/schema/preferences.ts) # Acquisition How a car's purchase (M-Compra) is saved as an append-only header and ledger entries, why filling in a blank is not a correction, how the amount financed opens a Loan, and why a replay never saves twice. ## What it does [#what-it-does] An Acquisition is how a car's Ownership Period began (RF06). `acquisition.save` writes, in one transaction: - a ★ `acquisition` header: the purchase date, down payment and amount financed in the car's currency, and the Loan it opened; - ★ `ledger_entry` rows of kind `acquisition_price` and `acquisition_cost` (the purchase base, RN06), dated the purchase date; - above zero financed, a `loan` whose principal is the amount financed and an opening ★ `loan_snapshot`. The principal is never a ledger entry or an expense (RN05); - with an invoice, a `vehicle_document` of kind `purchase_invoice` for the uploaded file; new entries get trust `with_proof`; - the Ownership Period's `start_date`, which follows the purchase date. A car in the garage without a period gets one; a wished-for car without one is `BAD_REQUEST`, and so is a sold car's date after its sale. Every field is optional (US06). An omitted field keeps its value, so a partial save is fine and the rest can be filled in later. The first save makes `recorded` true, which removes the garage's "Next step" card. ## Corrections (ADR-01, RNF12) [#corrections-adr-01-rnf12] Nothing is updated. Changing a recorded value (price, costs, date, down payment, amount financed) is a Correction: - a changed price or cost, or a moved date, writes a `correction` entry naming the latest entry of that kind, with the `reason`; - the new header names the one it replaces (`corrects_id`), so a period's headers form a chain and the latest is the purchase as it stands; - a changed amount financed opens a new Loan with the old one's terms and settles the old one with a zero-balance snapshot (a Loan's principal is fixed). Without a `reason`, a save that would change a recorded value writes nothing and answers `{ status: "reason_required" }`. Filling in a blank needs no reason. A save that changes nothing writes nothing. ## Trade-in (US08) [#trade-in-us08] `tradeIn: { vehicleId, valueMinor }` names one of the household's current cars. The same transaction sells it on the purchase date for `valueMinor` through Close sale's `recordSale` (Disposal header, `sale_proceeds` entry, a remaining Loan paid off, period closed, Sold), so the old car's deal result uses the trade-in value. The header stores `trade_in_vehicle_id` and `trade_in_entry_id`, the old car's `sale_proceeds` entry, which is the money; nothing is copied into the new car's ledger and its purchase base stays the full price. Corrections carry the link forward. `acquisition.get` answers `tradeIn` (car and value) once linked, and `tradeInOptions`: the other cars in the garage with an open period in the same currency, while the purchase has no trade-in and is not a sold car's. A trade-in is `BAD_REQUEST` when it is this car, not in the garage, in another currency, owned only after the purchase date, or when the purchase already has one; another Organization's car is `NOT_FOUND`. Nothing is written then. A sale is never undone (RN01), so a linked trade-in stays. ## Offline and idempotency (RNF04) [#offline-and-idempotency-rnf04] The client generates the save's `id` (a UUID), which becomes the new header's id. Sending it again answers the purchase as it stands and writes nothing more; saves of one car run one at a time. The same id for another car is `CONFLICT`. ## Procedures [#procedures] | Procedure | Who | Failures | | --------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `acquisition.get` `{ vehicleId }` | Members of the Organization | `NOT_FOUND` (car) | | `acquisition.save` `{ id, vehicleId, acquiredOn?, priceMinor?, costsMinor?, downPaymentMinor?, financedMinor?, invoiceFileId?, tradeIn?, reason? }` | Members of the Organization | `INPUT_VALIDATION`, `NOT_FOUND` (car, invoice file or trade-in), `BAD_REQUEST` (no period to record, date after the sale, trade-in refused), `CONFLICT` (id) | `acquisition.get` answers before any save too: the period's start as the date, and price and costs already in the ledger. `null` is a value not entered yet. `timeOwned(from, until)` in `@autofolio/domain/ownership` gives M-Compra's "3 years, 4 months", until today or the sale. ## Data and isolation [#data-and-isolation] All tables have row-level security by Active Organization. `acquisition`, `ledger_entry` and `loan_snapshot` are append-only for every role. No `audit_log` event is written: the ★ rows are the purchase's audit trail, and amounts never go to logs (RNF07). ## App [#app] M-Compra (`screen-M-Compra`, route `/purchase/[vehicleId]`) opens from the garage's "Next step" card and the vehicle profile's More menu. The date card opens a date sheet and shows the time owned; price, acquisition costs, down payment and amount financed are fields in the account locale (blank stays to fill in later); the trade-in row (`troca`) opens a sheet (`sheet-troca`) to pick a car and its value (`valor-troca`, `usar-troca`). Save (`salvar`) confirms a trade-in first, and asks for a reason when the server answers `reason_required`. A linked trade-in opens the old car's profile. More holds "Attach invoice" (a new document of kind `purchase_invoice`) or Documents. "Close sale" opens M-Fechar-Venda. `autofolio://dev/M-Compra` opens the seeded Corolla Cross with the drawn down payment and amount financed typed in. ## Key files [#key-files] - [`packages/api/src/features/acquisition/acquisition.contract.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/acquisition/acquisition.contract.ts) - [`packages/api/src/features/acquisition/acquisition.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/acquisition/acquisition.service.ts) - [`packages/db/src/schema/ledger.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/src/schema/ledger.ts) - [`apps/mobile/src/features/acquisition/acquisition.screen.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/acquisition/acquisition.screen.tsx) - [`packages/domain/src/ownership.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/domain/src/ownership.ts) # Administration Current platform authority protects User management, audits, flags and diagnostics. ## What an Admin can do [#what-an-admin-can-do] The admin area manages Users, bans, roles, Sessions and passwords, and reads the Audit Log. It also manages feature flags and app-update configuration and exposes diagnostics. The platform User role is distinct from an Org role. Navigation uses the observed Session for presentation. Server operations use `adminProcedure` or `adminWriteProcedure` and re-read current authority. A stale sidebar, a demotion or an impersonation cannot preserve old admin permissions. ## Audited changes [#audited-changes] The admin capability dispatches Better Auth operations inside the existing transaction and audit coordination. Keep those mutations behind that capability instead of writing auth tables directly. Per-User timelines and the global Audit Log expose different authorized views of the same security history. Impersonation runs with the target User's exact permissions. An admin impersonating a non-admin loses admin access until stopping impersonation. Never recover authority from the `impersonatedBy` field. ## Extend [#extend] Add a schema, server operation and localized hook/view. Test unauthenticated access, ordinary Users, a revoked/demoted Admin and impersonation. Destructive actions use the existing dialog patterns rather than `window.confirm`. Diagnostics must return safe status and never echo credentials. ## Key files [#key-files] - [`packages/api/src/features/admin/admin.procedures.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/admin/admin.procedures.ts) - [`packages/api/src/features/admin/admin.api.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/admin/admin.api.ts) - [`packages/auth/src/admin-audit.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/auth/src/admin-audit.ts) - [`packages/api/src/features/audit/audit.procedures.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/audit/audit.procedures.ts) - [`apps/web/src/features/app-shell/app-shell.nav.hooks.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/features/app-shell/app-shell.nav.hooks.ts) # Agent-readable pages Public Markdown and discovery derive from the same content developers read. ## Public representations [#public-representations] `/llms.txt` lists public content and connection endpoints. `/llms-full.txt` includes the landing, legal text and every developer page. Each developer page has a `.md` representation, including `/docs.md`, deep pages and changelog entries. ```sh curl http://localhost:3005/docs/features/spreadsheet-import.md curl http://localhost:3005/llms.txt ``` Developer content is English only. Site, privacy and terms Markdown supports `?lang=pt`, `?lang=en` and `?lang=de`. Developer docs URLs enter the sitemap without hreflang alternates. Robots permits docs and excludes authenticated routes. ## Build and rendering [#build-and-rendering] Fumadocs MDX compiles the repository content and processed Markdown at build time. The headless core supplies the page tree and search. Articles render on the server with shared UI tokens; a public GET route supports browser navigation. Runtime code reads no documentation files from disk and does not evaluate MDX source. The existing ADR files and `docs/connect-your-agent.md` are imported into the collection, not copied. Relative references become docs links or repository links during compilation. Shiki highlighting happens during the build with github-light/github-dark tokens. ## Extend [#extend] Add MDX with a title and description under `apps/web/content/docs`, then place it in its folder's `meta.json`. Use ordinary Markdown headings and links. Add a diagram as an accessible static SVG, with its light/dark variants. The source/link tests, sitemap and llms generation pick up the page automatically. These routes contain only curated public content. Never add private Threads, account data or Organization exports to the public collection. Tool authorization remains the responsibility of the authenticated API. ## Key files [#key-files] - [`apps/web/source.config.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/source.config.ts) - [`apps/web/src/features/docs/docs.source.server.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/features/docs/docs.source.server.ts) - [`apps/web/src/features/docs/docs.page.server.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/features/docs/docs.page.server.tsx) - [`apps/web/src/features/agent-pages/agent-pages.content.server.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/features/agent-pages/agent-pages.content.server.ts) - [`apps/web/src/routes/sitemap[.]xml.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/routes/sitemap[.]xml.tsx) # AI extras A sale listing from the passport, search across a car's documents and services, translation of a foreign document beside its original, and a maintenance plan read from the owner's manual. ## What it does [#what-it-does] The extras of the AI features spec (§5) sit behind one flag, `ai.extras`, off by default, and each needs its model in the registry ([ADR-0017](/docs/adr/0017-ai-model-registry-and-quotas)). A model writes text or reads a document; it never computes a figure the app shows (RN03), and what it returns stays a draft until the person keeps it (RN10). | Extra | Where (board) | Model call (registry row) | Quota | | -------------------- | ------------------------------------------- | ------------------------------------ | ----------- | | Sale listing | M-Vender ⋯ → `Extra-Listing` | `listing` (text) | structuring | | Search the documents | M-Documentos ⋯ → `Extra-Search` | `embedding` | none | | Translation | a document's sheet → `Extra-Translate` | `translation` (text) | structuring | | Plan from the manual | M-Manutencao, Plan segment → `Extra-Manual` | `manualPlan` (document: reads a PDF) | structuring | The monthly summary named in §5 is the insights feed's ([Insights](/docs/features/insights)). ## Grounding [#grounding] `@autofolio/domain/grounding` holds the rules, with tests: - `figuresIn` reads the figures a text writes with their separators dropped ("15.000 km" and "15,000 mi" both write `15000`; a date's parts are figures of their own). - `groundLines` keeps only a model's lines whose figures all appear in what it was given; the listing drops the others and says how many. - `groundTranslation` pairs each read line with its translation by number; a line left out, or whose figures changed, shows the original and asks for a check. - `groundPlanItem` keeps a manual's interval only when the quoted words print it, and turns miles into km and years into months. ## Sale listing [#sale-listing] `passport.listing { vehicleId, sections, language, distanceUnit, platform }` (owner role) writes from `snapshotOf`, the same field-by-field snapshot a passport link stores, limited to services, receipts and specs: photos, costs and the estimate never reach it, and neither can a plate, VIN or address (RN09). Distances are in the reader's unit. The phone keeps one answer per language for the session; nothing is stored. The owner edits the title and text, then copies or shares them. ## Search across documents [#search-across-documents] `vehicleDocument.search { vehicleId, query, platform }` embeds the question with the car's newest 100 services (title, shop, items, note) and 100 documents (title, the text the phone read, a kept translation), ranks them by cosine similarity and answers up to five hits, each a service or a document with its own date and reading. Nothing is generated. Row-level security leaves out the documents a Member may not see. The phone reads a photo filed with **Add document** while `ai.extras` is on, so its text is searchable (`vehicle_document.text`). ## Translation [#translation] The phone reads the document's stored photo, or new photos of a PDF's pages (it reads pictures only), and sends the lines to `vehicleDocument.translate { id, lines, locale, platform }`; they reach the model scrubbed. The draft shows each translation above its original. **Keep translation** calls `vehicleDocument.keepTranslation`, which stores the lines in `vehicle_document.translation` and the read text in `vehicle_document.text`; the file itself never changes. `vehicleDocument.translation { id }` reads the kept one. ## Plan from the owner's manual [#plan-from-the-owners-manual] The owner picks a PDF among the car's documents, or one from Files (filed as "Owner's manual"). `reminder.manualPlan { vehicleId, documentId, locale, platform }` (editor with documents) reads a PDF of up to 20 MB from private storage and sends it to the model as a document part; it is the one unscrubbed input, since a manufacturer's manual holds no owner's data. Each suggestion keeps the manual's page and words. **Add** creates the owner's own reminder with the interval (or opens the reminder form when a first due date is needed); tapping a suggestion opens the form to edit first. A suggestion is never presented as the manufacturer's data without its source. | Procedure | Failures | | --------------------------- | ------------------------------------------------------------------------------ | | `passport.listing` | `NOT_FOUND` (car, or `ai.extras` off), `CONFLICT` (not in the garage), `QUOTA` | | `vehicleDocument.search` | `NOT_FOUND`, `TOO_MANY_REQUESTS` | | `vehicleDocument.translate` | `NOT_FOUND`, `QUOTA`, `INPUT_VALIDATION` (over 400 lines or 12,000 characters) | | `reminder.manualPlan` | `NOT_FOUND`, `BAD_REQUEST` (no PDF, or over 20 MB), `QUOTA` | ## Dev boards [#dev-boards] `autofolio://dev/Extra-Listing`, `Extra-Search`, `Extra-Translate` and `Extra-Manual` open on the seeded M3. The seed turns `ai.extras` on and keeps a translation of the M3's purchase invoice, read in German (in Italian on the German board). Writing, searching and reading a manual still need a configured model on the server. ## Tests [#tests] - [`grounding.test.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/domain/src/grounding.test.ts) - [`passport.listing.test.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/passport/passport.listing.test.ts), [`vehicle-document.extras.test.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/vehicle-document/vehicle-document.extras.test.ts), [`reminder.manual.test.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/reminder/reminder.manual.test.ts): fake adapters, what left the server, quotas and other households. # AI foundation How every AI feature gets its model from one registry on TanStack AI, its flag, the plan quota it counts against, and the scrubber on everything it sends. ## What it does [#what-it-does] Every AI feature (the assistant, receipt and voice structuring, the Studio, insights, transcription, embeddings) resolves its model from one registry, `packages/api/src/features/ai/ai.models.ts`, and calls it through TanStack AI. Each feature has a flag, off by default, and the calls a User makes are counted against the plan's monthly quota. The decision record is [ADR-0017](/docs/adr/0017-ai-model-registry-and-quotas); the product rules are in `docs/specs/ai-features.md`. ## Configure a model [#configure-a-model] | Variable | Value | | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | `AI_PROVIDER`, `AI_MODEL` | the text model every text feature uses unless it has its own ([ADR-0007](/docs/adr/0007-ai-assistant-and-tool-catalog)) | | `AI_MODELS` | JSON, feature to `provider:model`, e.g. `{"studio":"gemini:gemini-3-pro-image-preview"}` | | `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `GEMINI_API_KEY` | the key of each provider in use | Image, transcription and embedding features have no default model. A provider without an adapter for the kind (Anthropic for images) or malformed JSON fails loudly on first use. Then turn the feature's flag on at `/admin/flags`: `ai.assistant`, `ai.capture`, `ai.studio`, `ai.genui`, `ai.insights`, `ai.extras`. ## Quotas [#quotas] | Quota | Free | Plus | | ----------------------------------------------------------------- | ---- | ---- | | `structuring`: scanner, voice, listing and translation by a model | 20 | 300 | | `studio`: Studio images | 3 | 60 | | `copilot`: assistant questions | 20 | 500 | Months are calendar months in UTC. A call counts before the model runs, under a per-User lock, so the limit is exact. Past it the procedure answers `QUOTA` (HTTP 402). The on-device scanner never counts. ## Adding an AI feature [#adding-an-ai-feature] 1. A row in `AI_FEATURES` (kind, flag, quota) and, if new, its flag in `packages/flags/src/registry.ts`. 2. In the service: `admitAi(db, systemDb, userId, plan, feature, platform)` returns the model or fails with `AiUnavailable`, `AiRateLimited` or `AiQuotaExceeded`; then `generateStructured`, `generateImages`, `transcribe` or `embedTexts` with it. Map failures with `aiRpcError`. 3. Tests use `fakeText` from `@tanstack/ai/testing` and `fakeImage`, `fakeTranscription`, `fakeEmbedding` from `packages/api/src/testing/ai-adapters.ts`; no test calls a live model. ## Data and privacy [#data-and-privacy] `ai_usage` keeps one row per counted call: the User, the quota and the time, never a prompt, an answer or an amount. It belongs to the User across households, tenant traffic only reads and adds its own rows, and deleting the User deletes them ([ADR-0009](/docs/adr/0009-row-level-security)). Every chat call ends with the `scrubOutbound` middleware, which removes plate, VIN and email shapes from the provider's copy of the conversation; image prompts and embedding inputs are scrubbed too. ## Procedures [#procedures] | Procedure | Who | Returns | | ---------- | ------------------ | --------------------------------------------------------------- | | `ai.usage` | any signed-in User | the plan and this month's use of each quota whose feature is on | # oRPC conventions Share Zod contracts and typed clients while keeping authorization on the server. ## Procedure selection [#procedure-selection] | Procedure | Use | | --- | --- | | publicProcedure | Public boot or health data, with captured-scope checks when supplied | | protectedProcedure | An authenticated User's personal data | | orgProcedure | Shared data in the membership-checked Active Organization | | orgProcedure.use(requireOrgRole(...)) | The same, limited to Members with one of the listed Org roles; available, not applied to any procedure yet | | adminProcedure | Global reads requiring the current platform admin role | | adminWriteProcedure | Admin writes through Better Auth's audited capability | Each feature lives in `packages/api/src/features//`: the leaf contract (`.contract.ts`), the procedures (`.procedures.ts`), the service and its errors. Register the procedures in `platform/rpc/app-router.ts`. Browser and mobile clients use the type-only `api/client` export and the feature's contract subpath (`api//contract`). The [backend conventions](/docs/development/backend-conventions) give the full shape. ## Add an operation [#add-an-operation] 1. Define input and output schemas, including bounded pagination and text limits. 2. Choose the procedure according to the resource owner. 3. Apply Organization scope in every relevant query and mutation. 4. Keep the mutation and Audit Event in one transaction when the operation is audited. 5. Add any cross-package schema export explicitly, then consume it through a hook. ## Pagination [#pagination] Each list endpoint uses one strategy. Choose it by what the interface needs: | Use | When | Shape | Examples | | --- | --- | --- | --- | | Offset | The interface shows a total, page numbers or facet counts over a bounded, slowly changing set | `page` from 1 and `pageSize` capped at 100; the output carries `total` and `pageCount` | `todo.list`, the admin audit table and user list | | Cursor | The list grows without bound or changes while it is read (feeds, timelines, "load more") | an opaque cursor of the stable sort key, such as `{ createdAt, id }`, plus `limit` capped at 100; the output carries the next cursor | Activity Timeline, integration delivery activity, Document Text pages | | Bounded window | The server caps the result by design | a plain array or a fixed window, no paging | Board lanes (`limitPerLane`), the Calendar date window, document search | Rules for every list: - Every paginated query has a total `ORDER BY` with a unique tie-breaker (`id`), so equal sort values cannot repeat or skip rows. - Every limit has a maximum in the contract. No endpoint returns an unbounded page. - A cursor is opaque to clients. A malformed cursor is `INPUT_VALIDATION` (HTTP 400), never a 500. - One endpoint offers one strategy; it does not accept both `page` and `cursor`. The HTTP endpoint is `/api/rpc`. The session-authenticated OpenAPI reference is at `/api/rpc/api-reference`, with its specification at `/api/rpc/api-reference/spec.json`. ## Client state [#client-state] TanStack Query owns server data, query keys and invalidation. Forms use TanStack Form. URL state expresses Todo views, filters and sorting. Components render the hook's state; they do not recreate authentication or authorization decisions. Validate external payloads at the boundary. Use the shared error vocabulary and translated resolver rather than returning internal exceptions. Do not create a parallel REST API for a domain operation already represented by oRPC. ## Key files [#key-files] - [`packages/api/src/platform/rpc/procedures.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/platform/rpc/procedures.ts) - [`packages/api/src/platform/rpc/context.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/platform/rpc/context.ts) - [`packages/api/src/features/todo/todo.procedures.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/todo/todo.procedures.ts) - [`packages/api/src/features/todo/todo.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/todo/todo.service.ts) - [`packages/api/src/features/todo/todo.contract.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/todo/todo.contract.ts) - [`apps/web/src/platform/rpc/orpc.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/platform/rpc/orpc.ts) # Appearance How Profile → Appearance colours the app Monochrome, by the current car's cover colour or Warm, where the cover colour comes from, and what stays neutral. ## What it does [#what-it-does] Profile → Appearance (`screen-Ap-Perfil-Aparencia`) offers three themes ([ADR-0016](/docs/adr/0016-appearance-and-car-colour)): | Theme | testID | Looks | | ---------- | ----------------------- | ---------------------------------------------------------------------------------------------------------- | | Monochrome | `appearance-monochrome` | the default: the tokens of `handoff/design/DESIGN.md`, the car photo the only colour | | Car colour | `appearance-car` | the page tinted by the current car's Cover Colour, fading to black; cards and controls translucent over it | | Warm | `appearance-warm` | coffee-brown surfaces and cream text | The primary pill stays white and green, red and orange stay reserved for state in every theme. Under Car colour, "Transition between cars" (`appearance-transition`) cross-fades the tint over 400 ms when another car comes into focus; Reduce Motion turns the fade off whatever the switch says. "Colour on the showcase too" (`appearance-showcase`, off by default) lets the public [showcase](/docs/features/showcase) take the cars' colours. ## Which colour [#which-colour] On a car's screens (M-Veiculo and the boards under it) the tint is that car's Cover Colour: each screen calls `useAppearanceCar(vehicleId)`, which sets the car in the appearance store on focus, so the tab bar outside the screen's tree takes it too. Elsewhere it is the first current car whose colour tints (`firstTint`), so one silver car does not keep the app grey. No colour, or a seed with chroma under 0.03 (white, silver, grey, black cars), means Monochrome. The buyer's passport preview passes `null`: the passport never shows a car colour. `tintedTheme(seed)` in `@autofolio/tokens/themes` derives the whole palette from one seed, clamped in OKLCH (lightness 0.28–0.34 at the top of the page, chroma at most 0.12). `themes.test.ts` checks WCAG AA for every text colour and the primary pill on every surface, for each theme and 24 seeds around the hue wheel; it is the gate for any change to the palettes. ## Cover Colour [#cover-colour] The cron tick's `coverColors` job (`vehicle-photo.jobs.ts`) reads each cover once on the server and stores `vehicle_photo.dominant_color` (hex) and `color_checked_at`, so the same job is the pipeline and the backfill. `vehicle-photo.color.ts` reads only each 8×8 JPEG block's DC coefficient (a 1/8-scale image; a Worker cannot hold a decoded 12-megapixel photo), weights the centre 60 % toward the middle, clusters with k-means and keeps the cluster with the most weight × saturation. The garage list returns it as `cover.color`; the app never decodes images for it. shortcut: PNG, WebP and arithmetic-coded JPEG covers give no colour (Monochrome); decode them when a cover in those formats needs one. ## Where the choice lives [#where-the-choice-lives] The device's choice is in `expo-secure-store` and read synchronously at start-up through a `useSyncExternalStore` store (`platform/theme/appearance.ts`), so the first frame has the right colours. It is also saved on the account (`account_preference.appearance`, and `showcaseColor` for the showcase switch, through `accountPreference.update`), so a new phone starts with it; once a device chooses, its own choice wins. Components read colours only through `useAppTheme()`. `apps/mobile/src/design/theme-seam.test.ts` fails on any hex or `rgb()` literal outside `design/tokens.ts`, since such a colour would stay Monochrome under the other themes. The signed-in web app takes the same three themes from Settings → Appearance: `applyAppearance` sets `data-theme` and the `--af-color-*` variables on ``. The marketing site and the passport at `/p/:token` stay as designed. ## Dev boards [#dev-boards] `autofolio://dev/Ap-Perfil-Aparencia` opens the screen; `Ap-Garagem-Mono`, `Ap-Garagem-Cor`, `Ap-Garagem-Quente`, `Ap-Veiculo-Mono`, `Ap-Veiculo-Cor` and `Ap-Veiculo-Quente` set the theme on the device and open M-Garagem or the seeded M3. Deferred: share-card backgrounds in the car's colour (ADR-0016's implementation notes). ## Key files [#key-files] - [`packages/tokens/src/themes.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/tokens/src/themes.ts) - [`packages/tokens/src/themes.test.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/tokens/src/themes.test.ts) - [`apps/mobile/src/platform/theme/appearance.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/platform/theme/appearance.ts) - [`apps/mobile/src/features/appearance/appearance.screen.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/appearance/appearance.screen.tsx) - [`packages/api/src/features/vehicle-photo/vehicle-photo.color.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/vehicle-photo/vehicle-photo.color.ts) - [`apps/web/src/features/app-shell/app-shell.appearance.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/features/app-shell/app-shell.appearance.ts) # AI assistant and tools TanStack AI streams a private conversation through one authorized tool catalog. ## Enable and configure [#enable-and-configure] Set `AI_PROVIDER` and its matching secret, then enable `ai.assistant`. Both gates are required. `AI_MODEL` can override the provider default. Secrets remain server-side. The existing MCP server works without a configured AI provider. The default request limits are five model/tool steps, 2,048 output tokens per model call, a 60-second abort deadline and ten requests per minute per User. Input and history are separately bounded. These per-request limits do not replace provider account spending controls. ## One catalog [#one-catalog] `catalog.schema.ts` defines Zod tool contracts; `catalog.ts` binds authorized implementations. The assistant and MCP adapt those same operations. Each call checks current Organization membership. The assistant uses the Session's Active Organization and rejects a change during a turn. The catalog has 23 tools. Read operations include `todo_list`, `todo_detail`, `files_list`, `whoami`, `search`, `fetch`, `document_read` and `organization_info`, plus AutoFolio's garage tools: `garage_cars`, `costs_summary`, `costs_compare`, `vehicle_tco`, `net_worth`, `upcoming`, `sale_estimate` (the deal result at the estimated value, an estimate) and `expense_draft` (a draft the User saves in Add expense; it writes nothing). Garage results carry every amount formatted for the account and the app screen with the details; the mobile assistant links each answer to that screen. Approved writes are `todo_create`, `todo_complete`, `import_start`, `create_todo_draft`, `draft_task_plan` and `propose_review`; `ask_for_input` pauses for a User choice. `files_list` exposes confirmed file IDs and conversion status. `document_read` returns two chunks by default, at most four of 8 KiB each, and `nextCursor` for another page. Search ranks converted text alongside Todos and filenames. Original file bytes never enter tool results. An `awaitingBrowser` result means the User must finish preparation in Files. Reading Document Text sends the selected chunks to the configured model provider and stores the results in the Thread. Treat source text as untrusted data and disclose partial reads. See [documents](/docs/features/documents). Every write needs approval bound to its User, Organization, tool name and parsed arguments. Assistant approvals also bind the Thread and call ID. The execution transaction checks current membership and expiry, applies the change, records its audit and stores the result. A replay returns that result rather than repeating the mutation. ## Copilot cards [#copilot-cards] With `ai.genui` on, the mobile assistant draws each garage tool result as a fixed card instead of quoting it ([ADR-0018](/docs/adr/0018-copilot-generative-ui), Plan B of the AI features spec §3; OpenUI was evaluated and set aside). The result schemas live in `@autofolio/api/assistant/contract`; `threadView` parses each one against its schema and drops anything else. - **Copilot-Costs** (`costs_summary`): the period's total, its change against the span before, the months as bars and the three biggest categories; "See costs" opens Finances. Chips suggest the next question. - **Copilot-Compare** (`costs_compare`, `sale_estimate`): cost per km or mile and per month per car; the sale at the estimated value with its source, the loan and the deal result (estimate); Prepare sale opens M-Vender. - **Copilot-Draft** (`expense_draft`): the draft's amount, volume, date, reading and vendor, the economy of the tank it closes; Review and save opens Add expense prefilled, Discard hides it. Nothing is saved before the User saves (RN10). - TCO, net worth, what is due and the car list have their own cards. Every card names its source and opens its screen. The app sends `cards: true`, so the server (with the flag on) asks the model for a sentence or two rather than every number. The dev links `autofolio://dev/Copilot-Costs`, `Copilot-Compare` and `Copilot-Draft` ask the board's question in a new conversation; they need a provider, `ai.assistant`, and spend a question of the quota. ## Streaming and persistence [#streaming-and-persistence] TanStack AI owns the native Promise/SSE lifecycle. The HTTP boundary owns input limits, auth, cancellation, the Thread lease and its bounded pool. Persistence snapshots at most once per second and saves the finished transcript. Stop, timeout or disconnect aborts the turn; reopening recovers the last saved state and pending interrupts. There is no disconnected background run or resumable transport. Text after the last snapshot can be lost. Deleting a Thread deletes its messages and linked approvals but does not undo completed domain changes. ## Extend [#extend] Add a catalog contract and authorized operation once, then adapt through the existing assistant/MCP consumers. Preserve exact-argument approval and replay protection. Tests use the deterministic adapter only in an `ai-test` build plus `AI_TEST_PROVIDER=true`; never publish that build. Read [ADR-0007](/docs/adr/0007-ai-assistant-and-tool-catalog) before changing transport, approvals or persistence. ## Key files [#key-files] - [`packages/api/src/features/agent-tools/agent-tools.catalog.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/agent-tools/agent-tools.catalog.ts) - [`packages/api/src/features/agent-tools/agent-tools.contract.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/agent-tools/agent-tools.contract.ts) - [`packages/api/src/features/assistant/assistant.http.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/assistant/assistant.http.ts) - [`packages/api/src/features/agent-tools/agent-tools.approvals.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/agent-tools/agent-tools.approvals.ts) - [`packages/api/src/features/assistant/assistant.threads.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/assistant/assistant.threads.ts) - [`scripts/test-local-ai.mjs`](https://github.com/CarlosZiegler/autofolio/blob/main/scripts/test-local-ai.mjs) # Authentication and Organizations Better Auth owns identity, sessions, Organizations, platform roles and OAuth. ## Session outcomes [#session-outcomes] Password sign-in can return a Session, a pending two-factor challenge, an unverified email, a Ban or a refusal. Treat those as distinct outcomes. `session-outcome.ts` and `two-factor-outcome.ts` provide the shared interpretation; hooks own navigation and error presentation. The server registers two-factor, admin, organization, passkey, Expo and MCP OAuth plugins. Email verification policy comes from `AUTH_SKIP_EMAIL_VERIFICATION`; setup makes the intended policy explicit. Current server authorization reads the database, with cookie caching disabled. ## Organizations and roles [#organizations-and-roles] Signup provisions a personal Organization. A User may be a Member of several Organizations; the Session selects one Active Organization. Todos and files belong to that Organization. Platform role `admin` grants global administration; Org roles `owner`, `admin` and `member` do not grant platform administration. Membership must be checked at the operation, even if the UI already selected an Organization. An MCP token has no active selection and stays pinned to the User's personal Organization. See [connecting agents](/docs/connect-your-agent). ## Web and native [#web-and-native] Web auth hooks coordinate TanStack Query, forms and redirects. Mobile uses the Better Auth Expo client, secure credential storage and native passkey integration. Its route gate revalidates the Session and hides private content during a transition. Logout, expiry or an authoritative 401 clears private state. A 403 does not automatically sign out an otherwise valid Session. ## Extend safely [#extend-safely] Add auth behavior to the server factory and its tests, then expose the outcome through a hook. Keep email callbacks, membership checks and audit recording in their existing owners. Test revoked Sessions and Organization changes as well as successful login. Never treat an impersonated admin's original identity as permission to access `/admin`. ## Key files [#key-files] - [`packages/auth/src/index.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/auth/src/index.ts) - [`packages/auth/src/session-outcome.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/auth/src/session-outcome.ts) - [`packages/db/src/provision-organization.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/src/provision-organization.ts) - [`apps/web/src/features/auth/auth.sign-in.hooks.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/features/auth/auth.sign-in.hooks.ts) - [`apps/mobile/src/platform/auth/use-session-lifecycle.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/platform/auth/use-session-lifecycle.ts) # Data export and erasure How a Member exports the household's data as CSV and PDF, and what deleting an account erases, when. ## What it does [#what-it-does] **Export my data** (US29, RF22) on M-Perfil queues a Data Export. The cron job builds a ZIP under `exports/orgs//.zip`: a README, one CSV per table (vehicles, ownership periods, ledger entries, expenses, services, odometer readings, loans and their balances, valuations, acquisitions, disposals, reminders, recurring expenses, documents and photos metadata, the requester's preferences and consents) and a PDF summary per car in `summaries/`. CSVs are UTF-8 with a BOM, amounts in minor units with their ISO 4217 code, distances in km; a cell that starts with `=`, `+`, `-` or `@` is prefixed with `'` so a spreadsheet never runs it. The PDF (a dependency-free text PDF, `data-export.pdf.ts`) is in the requester's language: purchase price, total spent, interest and fees, services, the latest estimated value with source · date · confidence (RN03) and, for a sold car, the deal result and the economic result apart (RF20). It never says "profit" (RN04). Plate and VIN are decrypted into the export only when the requester is the Organization's owner (RN09); nobody else's export has them. The document and photo files themselves are not copied. The ZIP stays 7 days (`DATA_EXPORT_KEEP_DAYS`); `dataExport.latest` signs a 15-minute link while it is ready. When the requester asked for email reminders and has a verified email, the job emails them once that it is ready (no link in the email: the app signs one). **Delete account and data** erases (packages/db `erase-user.ts`), from Better Auth's `user.delete.before` hook (self-service, "Delete now" and admin removal alike) and from the erasure job when a scheduled deletion is due: every household nobody else manages goes with the User, as long as they manage it or are its only Member (`householdsErasedWith`). Deleting the Organization cascades to every AutoFolio row, ★ append-only ones included. That is the one sanctioned exception to append-only, run as system code with the `account.erase` SystemReason; the `append_only` trigger lets a row go only together with its owner ([ADR-0019](/docs/adr/0019-erasure-after-a-grace-period)). The same transaction queues the Organization in `storage_purge` and moves another Member's session that was acting in it to their next household. A household another owner or admin manages stays; the User only leaves it. The User gets an email that the account is gone, in their language, and each other Member who used one of its cars gets one saying what became of it (deleted with the account, or the history they accepted stays in their garage; `erasureNotices`, read before the erasure). ## Deletion with a grace period [#deletion-with-a-grace-period] With the flag `account.deletion-grace` (privacy spec §2), Delete account schedules the erasure 30 days ahead instead of running it: - **At once** (`erasure.schedule`): `user.deletion_scheduled_at` is set, which locks the account (every household procedure answers `FORBIDDEN`; the app shows only P-Excluir-Agendada); every session and MCP token of the User goes; passports, showcases, unused invite links and pending invitations of the households that go with them, or made by them, are revoked. The User is emailed the date; each Member who uses one of those cars is emailed what happens to it. - **Cars other people use**: `erasure.preview` lists the cars of those households granted to other Members. Each is either deleted with the account or handed to one Member who uses it as a [History Transfer](/docs/features/history-transfer) with all of its technical history (services, readings, photos, documents; never money, plate or VIN). The Member accepts it into their own garage before the date; the copy's rows are theirs, marked "from previous owner", so nothing in it points at the deleted User. An offer not accepted goes with the household. - **Keep my account** (`erasure.cancel`): the lock goes, the handovers not yet accepted are withdrawn; revoked links stay revoked. - **Delete now**: Better Auth's `deleteUser`, the immediate path above, Members' notices included. - **After 30 days**: `pendingErasures` erases due accounts (up to 5 per tick) before `pendingStoragePurge` runs. **Web** mirrors the app: Settings → Security → Danger zone opens P-Excluir as a dialog (`screen-P-Excluir`, the same choices and `excluir-escolha-`, Schedule deletion `excluir-agendar`; there is no web export, so no Export first) and lands on `/login` signed out. The `/_auth` guard sends a scheduled account to `/deletion-scheduled` (`screen-P-Excluir-Agendada`: Keep my account, Delete now after a confirm dialog, Sign out), outside the app shell, so no household request is made. Without the flag the Danger zone keeps the type-your-email immediate deletion. The choice rules (`defaultChoice`, `transfersOf`, `daysLeft`) live in `erasure.contract.ts` for both. Audit actions: `account.deletion_scheduled`, `account.deletion_cancelled`, `history_transfer.offered` per handover, `user.deleted`. ## Timeline [#timeline] `ERASURE_TIMELINE` (`erasure.contract.ts`) is the one source for the app and the privacy policy: the grace period (`graceDays`, 30), sign-out and the database rows when the erasure runs, files erased within 30 days (in practice the next cron minutes), encrypted backups expire after 35 days (RNF09). Change the numbers there; the copy interpolates them. ## Procedures [#procedures] | Procedure | Who | Failures | | -------------------- | --------------------------- | ------------------------------------------------------------------------------ | | `dataExport.latest` | Members of the Organization | — | | `dataExport.request` | Owners and admins | — | | `erasure.preview` | The signed-in User | — | | `erasure.schedule` | The signed-in User | `CONFLICT` already scheduled, `BAD_REQUEST` a car or Member not in the preview | | `erasure.cancel` | The signed-in User | — | `request` is audited (`data.export_requested`). While one export is queued, a second request returns it (a unique partial index). ## Jobs [#jobs] - `pendingDataExports` (`data-export.jobs.ts`): deletes up to 25 ZIPs past their window (state `expired`), then leases the oldest queued export for 5 minutes and builds it. A build that crashes is retried on a later tick, at most 3 times, then marked `failed`; a requester who is no longer a Member, or no longer an owner or admin, gets `failed`. The export holds every car, so a Member granted only some of them ([Family garage](/docs/features/family-garage)) asks for none. - `pendingErasures` (`erasure.jobs.ts`): locks one due User at a time (`for update skip locked`), erases their households, deletes the User and audits `user.deleted` in one transaction, then emails them. - `pendingStoragePurge` (`erasure.jobs.ts`): leases one erased Organization and hard-deletes (bypassing the trash) up to 100 objects under each of `orgs/`, `.trash/orgs/`, `derived/orgs/`, `incoming/orgs/` and `exports/orgs/` for it; the row goes once a round finds nothing. ## Data and isolation [#data-and-isolation] `data_export` has row-level security: a Member reads and inserts only their own rows in the Active Organization, and only as `pending` without a key; the job alone marks them ready. `storage_purge` is system only (no policy, no grant). `user.deletion_scheduled_at` is not granted to tenant traffic; the erasure procedures write it on the system handle. ## App [#app] M-Perfil → Export my data opens `/export-data`: what the ZIP holds, then the latest export with one action (Prepare export, Download ZIP or Try again). The screen polls every 10 seconds while the export is pending. Delete account (`/delete-account`) is P-Excluir with `account.deletion-grace` (`screen-P-Excluir`): what happens at once and in 30 days, each car other people use with a menu (`excluir-escolha-`: transfer to a Member who uses it, or delete with the account; the one Member who logs on a car is the default), Export my data first and Schedule deletion (`excluir-agendar`) after a confirm sheet. Without the flag it is the immediate deletion: what is deleted and when, the word DELETE (EXCLUIR, LÖSCHEN) and a confirm sheet. A signed-in account with a scheduled deletion only reaches `/deletion-scheduled` (P-Excluir-Agendada, `screen-P-Excluir-Agendada`): the date and days left, Keep my account (`agendada-manter`), Delete now (`agendada-excluir-agora`, after a confirm sheet) and Sign out. Dev links: `autofolio://dev/P-Excluir`, `autofolio://dev/P-Excluir-Agendada` (a preview 27 days ahead; the seed account is not locked). The Face ID lock is the template's app lock: an opaque cover over the mounted app, so push registration and reminders are never blocked. ## Key files [#key-files] - [`packages/api/src/features/data-export/data-export.jobs.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/data-export/data-export.jobs.ts) - [`packages/api/src/features/data-export/data-export.archive.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/data-export/data-export.archive.ts) - [`packages/db/src/erase-user.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/src/erase-user.ts) - [`packages/api/src/features/erasure/erasure.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/erasure/erasure.service.ts) - [`packages/api/src/features/erasure/erasure.jobs.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/erasure/erasure.jobs.ts) - [`apps/mobile/src/features/data-export/data-export.screen.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/data-export/data-export.screen.tsx) # Database and migrations Drizzle manages one Postgres schema through reviewed, journaled SQL migrations. ## Connection ownership [#connection-ownership] `createDb` uses node-postgres and Drizzle relations. Workerd resolves the Hyperdrive binding; Node uses `DATABASE_URL`. Disable Hyperdrive query caching so a recently changed Session, membership or record is not answered from stale query results. Scheduled and streaming work use explicitly owned bounded pools. ## Change the schema [#change-the-schema] ```sh pnpm db:generate # Review the generated SQL and snapshot before applying it. pnpm db:migrate # CI and explicit automation use the same journaled runner: pnpm --filter @autofolio/db db:apply-pending ``` The migration scripts read `apps/web/.env` unless a named environment variable is supplied. They compare the migration history and apply pending SQL. Never use `db:push`; the script deliberately refuses it. Do not edit a migration that has already been applied. An existing database without matching history requires an explicit schema comparison before baseline. `--baseline ` is an operator decision after that comparison, not a generic repair for a failed migration. ## Domain and contracts [#domain-and-contracts] Organization-owned tables carry `organizationId`. Auth tables are owned by Better Auth. Domain enum files are client-safe leaves; Drizzle table definitions are server-only. Add relations to the sanctioned schema aggregate only when the relation builder needs them. Use a transaction for changes that must commit together, including domain writes with outbox/audit records. A retry after an uncertain response needs an idempotency key or a durable result; blindly replaying a write is unsafe. ## Tenant isolation [#tenant-isolation] Every table has row-level security. Procedures, assistant and MCP tools and the files gateway receive `context.db` as the `app_tenant` role for the session's User and Active Organization; each statement runs in a short transaction with `app.user_id` and `app.org_id` set locally, and `app.org_id()` re-checks membership on every statement. Better Auth, the cron tick, billing webhooks and admin run as the connection role through `asSystem(db, reason, fn)`, which is listed in `packages/db/src/system-call-sites.txt` and checked by `pnpm lint`. A new table uses `pgTable.withRLS` with `tenantPolicy(...)` helpers from `schema/tenancy.ts`, plus a GRANT to `app_tenant` in a custom migration. `rls-catalog.test.ts` fails for a table without RLS, a grant without a policy, a policy for another role, or `FORCE ROW LEVEL SECURITY` (which would hide every row from a connection role without BYPASSRLS). PostgreSQL 16 or newer is required. The decision, the supported connection roles and the rejected alternatives are in [ADR-0009](/docs/adr/0009-row-level-security). ```sh pnpm --filter @autofolio/db test:rls-matrix ``` The matrix starts a Docker PostgreSQL 16 and 18 for each connection role (superuser, owner with BYPASSRLS, plain owner, owner without CREATEROLE), applies migrations and runs the catalog and isolation suites. ### Restore or switch providers [#restore-or-switch-providers] The migration journal travels with a dump, but roles do not, and `pg_restore --no-acl` drops grants (and every policy when `app_tenant` is missing on the target). `db:apply-pending` notices: it ends with `db:verify-rls`, repairs the idempotent part (role, `app.session_roles`, functions, grants, missing policies) and fails with the steps to take when it cannot. ```sh # On the target, if the connection role cannot create roles, an administrator runs: # CREATE ROLE app_tenant NOLOGIN NOINHERIT NOBYPASSRLS; # GRANT app_tenant TO WITH INHERIT FALSE, SET TRUE; pg_restore --no-owner --no-acl -d "$TARGET_URL" dump.fc TARGET_URL="$TARGET_URL" pnpm --filter @autofolio/db db:apply-pending TARGET_URL TARGET_URL="$TARGET_URL" pnpm --filter @autofolio/db db:verify-rls TARGET_URL ``` If the Worker connects with a different role than the one that ran the migrations, add it to `app.session_roles` as the migration role; until then tenant requests see no rows. ## Verify against real Postgres [#verify-against-real-postgres] ```sh node scripts/test-local-postgres.mjs exec pnpm test:integration ``` The wrapper creates a disposable local cluster, applies migrations twice and stops its own database. It connects as a plain table owner (`TEMPLATE_TEST_DB_PROFILE=superuser|bypassrls|owner` picks another) and keeps tests away from any hosted database. Use additive migrations for deploys so rolling application code back does not require destructive schema rollback. ## Key files [#key-files] - [`packages/db/src/tenancy.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/src/tenancy.ts) - [`packages/db/src/schema/tenancy.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/src/schema/tenancy.ts) - [`packages/db/src/connection.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/src/connection.ts) - [`packages/db/src/job-db.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/src/job-db.ts) - [`packages/db/scripts/apply-pending.mjs`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/scripts/apply-pending.mjs) - [`packages/db/scripts/migration-runner.mjs`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/scripts/migration-runner.mjs) - [`scripts/test-local-postgres.mjs`](https://github.com/CarlosZiegler/autofolio/blob/main/scripts/test-local-postgres.mjs) # Sale How closing a sale (M-Fechar-Venda) ends a car's ownership period without deleting anything, pays off its Loan, and shows the deal result and the economic result apart. ## What it does [#what-it-does] Closing a sale (RF19, US23) ends the car's open Ownership Period. One transaction writes the ★ `disposal` header (date, the Loan it paid off, author), the ★ `ledger_entry` rows `sale_proceeds` and `sale_cost`, sets the period's end date and moves the car to `sold`. Nothing is deleted or merged (RN01): every expense, service and document stays with the car. `recordSale` in `disposal.service.ts` is the one write path: M-Compra's trade-in (US08) sells through it too, and `sale.tradedInFor` then names the car it was traded in for. When the car's newest Loan still has a balance, the sale pays it off: the balance becomes a `loan_principal` entry on the sale date (never an expense, RN05) and a final zero ★ `loan_snapshot` closes the Loan. ## Two results, never "profit" [#two-results-never-profit] The figures come from the period's ledger as it stands after corrections, through `@autofolio/domain` (RF20, RN04, RN06): - **Deal result** = price received − purchase base (price and acquisition costs) − capitalized upgrades − selling costs. - **Economic result of ownership** = deal result − running costs while owned (operating costs, interest and fees; never principal). Before a sale a rise in value is "estimated appreciation"; after it, "deal result" (`appreciationLabelKey`). ## Procedures [#procedures] | Procedure | Who | Failures | | ------------------------------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `disposal.get` `{ vehicleId }` | Members of the Organization | `NOT_FOUND` (car) | | `disposal.close` `{ id, vehicleId, disposedOn, priceMinor, costsMinor? }` | Members of the Organization | `INPUT_VALIDATION`, `NOT_FOUND` (car), `BAD_REQUEST` (not in the garage, date before the purchase or in the future), `CONFLICT` (id) | Both answer `open` (the open period's purchase base, upgrades, running costs and the balance to pay off) for a car that can be sold, and `sale` (every figure of the latest sale, both results and the payoff) once it is sold. ## Offline and idempotency (RNF04) [#offline-and-idempotency-rnf04] The client generates the Disposal's id. Sending it again answers the sale as it stands and writes nothing more; an id that names another car's sale is `CONFLICT`. ## Data and isolation [#data-and-isolation] `disposal` and `ledger_entry` are append-only for every role and isolated by Active Organization. No `audit_log` event is written: the ★ rows are the trail, and amounts never go to logs (RNF07). ## App [#app] M-Fechar-Venda (`screen-M-Fechar-Venda`, route `/close-sale/[vehicleId]`) opens from the vehicle profile's More menu. It shows the date, the loan payoff, price received and selling costs as fields, the purchase base and upgrades, the deal result (`positive` or `negative`), running costs and the economic result, recomputed as you type, and "Confirm sale" (`confirmar-venda`). Confirming shows the "Sold" sheet with "My collection", which returns to the garage. A sold car's profile shows both results again and opens the sale as recorded; a traded-in car's sale shows "Traded in for" (`trocado-por`), which opens the new car. `autofolio://dev/M-Fechar-Venda` opens the seeded Corolla Cross with the sale example typed in. Flow F7 sells it, so it runs once per demo account. ## Key files [#key-files] - [`packages/api/src/features/disposal/disposal.contract.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/disposal/disposal.contract.ts) - [`packages/api/src/features/disposal/disposal.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/disposal/disposal.service.ts) - [`apps/mobile/src/features/disposal/disposal.screen.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/disposal/disposal.screen.tsx) # Documents Convert supported files to Document Text in the browser, index it from cron and read it through the shared tool catalog. ## Conversion path [#conversion-path] A confirmed upload of a supported format creates a pending document bound to the source ETag. A dedicated browser Web Worker converts the original with `@firecrawl/anydoc-wasm` 0.2.4, then calls `documents.prepareText`, PUTs the Markdown to `incoming/orgs//.md` and calls `documents.confirmText`. Cron validates and indexes that Markdown; the server never runs AnyDoc. [ADR-0008](/docs/adr/0008-documents-and-large-uploads) records the memory gate the server design failed. Supported inputs are PDF with a text layer, Word, Excel, PowerPoint, OpenDocument, RTF, EPUB, CSV, plain text and Markdown. There is no OCR, external conversion service or streaming parser. The worker is created with `new Worker(new URL(...), { type: "module" })` and fetches WASM only when conversion starts; no dynamic `import()` is involved. ## Limits [#limits] Conversion caps are separate from [upload limits](/docs/features/files). Sources above **1.5 MiB (PDF)** or **10 MiB (other formats)** are stored but not converted. Extracted Markdown is capped at **192 KiB**; plain `.md`/`.txt` files above it show the limit before upload. WASM linear memory is bounded at 32 MiB, and conversion has a 45-second deadline in the browser. The caps live in the leaf module `packages/db/src/schema/documents.enums.ts`. Chunks target 4 KiB and never exceed 8 KiB. `document_read` returns two chunks by default and at most four, plus a `nextCursor`. Cron indexes one document per tick, after the other jobs settle, within a 15-second budget; see [scheduled jobs](/docs/features/scheduled-jobs). ## Failures and recovery [#failures-and-recovery] Encrypted, malformed, unsupported, oversized, resource-limited and OCR-required documents become permanent failures. `needsOcr` means the PDF has no extractable text; the original stays downloadable. `text_too_large` means the Markdown exceeded 192 KiB and will not be indexed. A timeout after 45 seconds, a cancelled conversion or a missing converter asset leaves the document pending and resumable: the file preview can restart preparation from the stored original, which the browser downloads through a byte-capped reader and checks against its ETag. Uploads from external clients, including MCP, report `awaitingBrowser` until a member opens the file in Files and the browser prepares its text. Deleting or changing a source invalidates its text and fences indexing leases before the storage operation. ## Reads and privacy [#reads-and-privacy] Only current Organization members can read chunks. The assistant and MCP use the same `files_list`, `search`, `fetch` and `document_read` operations; see [AI assistant](/docs/features/assistant) and [MCP](/docs/features/mcp). Reading text in the assistant sends the selected chunks to the configured model provider. Document text is untrusted: previews skip raw HTML and remote images, and text cannot grant tool permissions. ## Extend [#extend] Changing the AnyDoc version or raising a cap needs a new measured browser run. Moving conversion back to the server requires passing all three gates in ADR-0008. Keep `prepareText`, the Markdown PUT and `confirmText` together so the server checks ownership, size, UTF-8 and the text ETag. ## Key files [#key-files] - [`packages/db/src/schema/documents.enums.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/src/schema/documents.enums.ts) - [`packages/api/src/features/documents/documents.procedures.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/documents/documents.procedures.ts) - [`packages/api/src/features/documents/documents.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/documents/documents.service.ts) - [`packages/api/src/features/documents/documents.jobs.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/documents/documents.jobs.ts) - [`packages/api/src/features/documents/documents.contract.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/documents/documents.contract.ts) - [`apps/web/src/features/documents/documents.worker.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/features/documents/documents.worker.ts) - [`apps/web/src/features/documents/documents.conversion.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/features/documents/documents.conversion.ts) - [`patches/@firecrawl__anydoc-wasm@0.2.4.patch`](https://github.com/CarlosZiegler/autofolio/blob/main/patches/@firecrawl__anydoc-wasm@0.2.4.patch) # Email One transport boundary serves verification, recovery, invitations and security notices. ## Transport selection [#transport-selection] The email package selects Cloudflare's Worker `EMAIL` binding first, then Cloudflare REST credentials, then the optional Resend fallback, then development console delivery. Deployment setup uses Cloudflare with an onboarded sender domain. Set `EMAIL_FROM` to the intended display name and address. Node tooling cannot use the Worker binding. Real Node delivery needs `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_EMAIL_API_TOKEN`. A configured Worker binding does not prove Node can send. ## Delivery semantics [#delivery-semantics] `sendEmailEffect` owns the bounded send; its Promise facade serves Better Auth callbacks. There is one send attempt inside the round, with a 10-second timeout. Cloudflare acceptance requires a nonempty message ID. Provider acceptance and inbox receipt are separate checks. Required verification/recovery mail failures must not report a successful auth action. Informational welcome and security notices have their own handling. Never silently turn required production email into console-only delivery. ## Add a template [#add-a-template] Add a pure template in `templates.ts` returning subject, plain text and HTML. Escape user content, pass app branding and URLs explicitly, and call the shared transport. Use a local capture transport in tests; real sends need an explicitly chosen recipient and environment. Inspect [email domain setup](https://github.com/CarlosZiegler/autofolio/blob/main/docs/email-domain.md) for provider onboarding and the previous deployed preview verification for evidence of actual delivery. ## Key files [#key-files] - [`packages/email/src/transports.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/email/src/transports.ts) - [`packages/email/src/templates.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/email/src/templates.ts) - [`packages/env/src/email.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/env/src/email.ts) - [`packages/auth/src/index.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/auth/src/index.ts) # Expense How an expense is saved into the ledger with its odometer reading, fuel and receipt, how a receipt photo or a spoken sentence becomes a draft to confirm, why a likely duplicate is a warning, how a correction works, why a replay never saves twice, and how costs add up by category and period. ## What it does [#what-it-does] An Expense is a running cost of a car (`fuel`, `maintenance`, `insurance`, `tax`, `upgrade`, `other`). Saving one writes, in one transaction: - a ★ `ledger_entry` (RN02): integer minor units in the car's currency, effective date, category, vendor, odometer, note, author, and its source: `manual`, or `ocr_draft_confirmed` / `voice_draft_confirmed` for a photo or voice draft the User confirmed (below). Its kind is `upgrade` for an upgrade (capitalized, RN06) and `operating` for everything else (TCO); - the `expense` row: fuel in millilitres or charge in watt-hours, full or partial tank (RF09), and the receipt; - with an odometer value, a ★ `odometer_reading` with source `expense` (RN07, below); - with a receipt, a `vehicle_document` of kind `receipt` for the uploaded file, and the entry's trust becomes `with_proof`. Only a car with an open Ownership Period takes an expense: a sold or wished-for car answers `BAD_REQUEST`. The currency is always the car's. ## Outcomes, not errors [#outcomes-not-errors] `expense.create` answers one of three results: | `status` | Meaning | Written | | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | `saved` | `expense` holds the saved expense | all | | `duplicate` | RF10: `duplicate` is an expense of the car with the same amount, date and vendor (case and spaces ignored). Send again with `allowDuplicate: true` to save anyway | nothing | | `odometer_reason_required` | RN07: the reading is lower than `previousKm`, the latest reading on or before the expense date. Send again with `odometerReason` | nothing | A reading lower than the previous one, with a reason, is saved and flagged. ## Offline and idempotency (RNF04) [#offline-and-idempotency-rnf04] The client generates the expense `id` (a UUID) when the User taps Save and keeps it with the queued request. The id becomes the ledger entry's and the expense's id, so sending it again, even while the first send is still running, answers the first save and writes nothing more. The same id for another car, or an id another entry already uses, is `CONFLICT`. A correction's `correctionId` works the same way. ## Corrections (ADR-01, RNF12) [#corrections-adr-01-rnf12] `expense.correct` is `PATCH /expenses/:id`: a new `correction` ledger entry with every current value, overridden by the fields given (`null` clears `vendor` or `note`), `corrects_id` naming the entry it corrects and the `reason`. The original stays; the expense now points at the correction, and a later correction corrects that one. Changing the odometer appends a reading, flagged with the correction's reason when it is lower. Moving to a category other than fuel clears the fuel fields. Corrections of one expense run one at a time. ## Procedures [#procedures] | Procedure | Who | Failures | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `expense.create` `{ id, vehicleId, category, amountMinor, effectiveDate, vendor?, odometerKm?, odometerReason?, note?, receiptFileId?, fuel?, allowDuplicate, source? }` | Members of the Organization | `INPUT_VALIDATION`, `NOT_FOUND` (car or receipt file), `BAD_REQUEST` (no open ownership period), `CONFLICT` (id taken) | | `expense.correct` `{ id, correctionId, reason, …fields }` | Members of the Organization | `INPUT_VALIDATION` (blank reason), `NOT_FOUND`, `CONFLICT` | | `expense.list` `{ vehicleId, limit ≤ 100 }` | Members of the Organization | `INPUT_VALIDATION` | | `expense.get` `{ id }` | Members of the Organization | `NOT_FOUND` | | `expense.summary` `{ vehicleId?, period, today }` | Members of the Organization | `INPUT_VALIDATION`, `NOT_FOUND` (car) | | `expense.entries` `{ vehicleId?, category, period, today, cursor?, limit ≤ 100 }` | Members of the Organization | `INPUT_VALIDATION` | | `expense.structure` `{ kind, text, locale, today, platform }` | Members of the Organization | `INPUT_VALIDATION`; `{ draft: null }` when off | | `odometer.latest` `{ vehicleId }` | Members of the Organization | `NOT_FOUND` (car) | `expense.list` is a bounded window of the latest expenses, newest first, with their current values. Distances are always km and fuel millilitres; the client converts to the account's units. ## Fuel economy (RF09) [#fuel-economy-rf09] `fuelEconomy(fills)` in `@autofolio/domain/consumption` computes full to full: the distance between the first and last full fill-ups with a reading, over everything bought after the first. `formatFuelEconomy` and `formatEnergyEconomy` in `@autofolio/domain/format` show it as km/l, l/100 km, mpg or kWh/100 km. ## The app screen (M-Novo-Gasto) [#the-app-screen-m-novo-gasto] `/add-expense?vehicleId=` in the mobile app (`apps/mobile/src/features/expense/`), pushed from the vehicle profile's `add-gasto`. The form starts from the car's latest expense of the category (amount and vendor), its latest reading and today, so a repeat fill-up is two taps (US09). The amount is in the car's currency, the odometer in the account's unit, fuel in litres or gallons with a switch to kWh for a charge. A reading below the last one shows a reason field before anything is sent (RN07). The client runs `findLikelyDuplicate` over `expense.list` and shows the warning banner; the second tap sends the same id with `allowDuplicate`. Photo and Voice build a draft instead (below). Saving goes through the app's offline queue (`apps/mobile/src/platform/offline/`): the input is written to disk under its client id before it is sent, and sent again when the network or the app comes back. A queued expense the server answers with `duplicate`, `odometer_reason_required` or a refusal stays on the phone as "needs your review"; a toast and the car's form list it, and opening it puts it back in the form with the answer shown. Sign-out clears the queue with the rest of the device's private data. ## Photo and voice drafts (US34, US35) [#photo-and-voice-drafts-us34-us35] AI only creates drafts (RN10, ADR-05 of the handoff): nothing reaches the ledger until the User taps Confirm and save. - **Photo (RF35):** the User takes or picks the receipt photo and taps Extract details. The text is read on the phone, by Apple Vision on iOS and ML Kit's on-device model on Android (`expo-text-extractor`); no cloud OCR. `readReceipt` in `@autofolio/domain/capture` makes the draft: the amount on a total line, else the one printed most often; the currency named; the first real date not after today (`10/04` follows the account's locale and is less sure); the first line that reads like a name as the vendor; litres, gallons or kWh; a mileage after "km", "Kilometerstand" or "odometer"; the category from those and from keywords. Each field carries a confidence from 0 to 1. - **Voice (RF36):** the User holds the microphone (or taps, then taps again) and says the expense: "Changed the oil on the BMW, 650, 42,000 km". `expo-speech-recognition` transcribes it on the device where the phone supports it, else through the system recognizer. `readSpeech` reads the car (a model's name, or a make only one car has), km or miles as the odometer, litres as fuel, a number with a currency as the amount (else the first bare number, less sure), "yesterday" as the date and the category. - **Review:** the form opens on the draft under "Draft to confirm": "confidence high" or "medium" under each value read, a "check" pill otherwise. A field not read stays empty, except the date (today) and the odometer (the car's latest reading, to check). An amount read in another currency than the car's keeps its number with "check" and both currencies named: an expense is in the car's currency. The voice draft's car row opens the list of cars. Confirming runs `confirmDraft` and saves through the same queue, duplicate check and RN07 check as a typed expense, with the photo as the receipt. - **Reading failed:** when nothing worth a draft is read (no amount, date or vendor), the form opens by hand with what was read and the photo attached, and the User saves it as a manual expense. With the `ai.capture` flag on and an AI provider configured (the assistant's, see [Assistant](/docs/features/assistant)), the app also sends the recognised text, never the photo or the audio, to `expense.structure`. The model may only fill a fixed schema (category, amount, currency, date, vendor, odometer, litres); `groundDraft` keeps only the values written in that text, so it can never invent an amount, and `mergeDrafts` keeps the more confident value per field. The call shares the assistant's per-minute limit, gives up after 8 seconds on the phone and is never stored or logged; off, limited or failing, the phone's own draft stands. ## Costs by category and period (M-Gastos, US13) [#costs-by-category-and-period-m-gastos-us13] `expense.summary` is the Finances tab's aggregate. It reads every cost through what holds its current values, never by summing raw ledger rows: an Expense's ledger entry and a Service's ledger entry (the original or the latest correction). Acquisition, the sale and a Loan never count: its principal is not an expense (RN05), and its interest and fees are a component of the TCO on [Insights](/docs/features/insights) and listed on M-Financiamento, never a row here (the board has none). The total is `totalSpent` from `@autofolio/domain/rules`: the six expense categories, upgrades included. Rows are grouped in Postgres by category, month, currency and stored rate; `summarize` in `expense.rules.ts` folds them. - **Period:** `3m` and `12m` are calendar months, today's and the 2 or 11 before it (from the 1st); `all` is every entry up to `today`. `today` is the device's date, so months follow the user's calendar. The 12 monthly bars are always the last 12 months, whatever the period. - **Currency (RF23, US30):** one car's summary is in its own currency; every car's is in the account's main currency. An entry in another currency counts at its stored rate (`fx_rate` to that currency, `fx_date`) and the latest rate per currency comes back in `rates` for "rate · date". An entry without a rate to it is listed in `unconverted` and never summed. - **`expense.entries`** lists the costs behind one row, newest first, with a `nextCursor` (date and id) for the next page. Each line says where it lives (`expense` or `service`) and is in its own currency. M-Veiculo's "Costs, last 12 months" (#7) is a rolling year from the same day a year ago; M-Gastos' 12 months are calendar months, so the bars add up to the total. They agree when the oldest month has no entries before the same day. ## The Finances tab (M-Gastos) [#the-finances-tab-m-gastos] `/finances` (`apps/mobile/src/features/finances/`) opens on the newest current car, or the car of `?vehicleId=` (the dev link). The subtitle opens a sheet with "All vehicles", the current and the sold cars. The segmented control switches the period (`per-3m`, `per-12m`, `per-all`); the card shows the total, the bars (tap one to select its month) and the selected month's amount as text. Screen readers skip the bars and step through the months on that line (an adjustable element). Each category row opens `/cost-entries`, whose expenses open `/correct-expense?id=`: the expense's values and a required reason, saved as `expense.correct` through the offline queue under a correction id made once. A correction the server refuses later is dropped with a toast. The "Upcoming recurring" card waits for recurring expenses (#20). ## Data and isolation [#data-and-isolation] All four tables have row-level security by Active Organization. `ledger_entry` and `odometer_reading` are append-only for every role; the `expense` row may only repoint its ledger entry and fuel fields. No `audit_log` event is written: the ledger row is the money's audit trail, and amounts never go to logs (RNF07). ## Key files [#key-files] - [`packages/api/src/features/expense/expense.contract.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/expense/expense.contract.ts) - [`packages/api/src/features/expense/expense.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/expense/expense.service.ts) - [`packages/api/src/features/odometer/odometer.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/odometer/odometer.service.ts) - [`packages/api/src/features/expense/expense.rules.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/expense/expense.rules.ts) - [`apps/mobile/src/features/finances/finances.hooks.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/finances/finances.hooks.ts) - [`apps/mobile/src/features/expense/expense.hooks.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/expense/expense.hooks.ts) - [`apps/mobile/src/platform/offline/mutation-queue.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/platform/offline/mutation-queue.ts) - [`packages/domain/src/consumption.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/domain/src/consumption.ts) - [`packages/domain/src/capture.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/domain/src/capture.ts) - [`packages/api/src/features/expense/expense.structure.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/expense/expense.structure.ts) - [`apps/mobile/src/features/expense/expense.capture.hooks.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/expense/expense.capture.hooks.ts) # Family garage How M-Familia shares a household's cars with a preset or an area matrix per car: vehicle_member grants (until a date, with approvals), invite links and QR, the activity line, handing a car to another Member, the row-level security that hides ungranted cars and areas, and the permissions procedures enforce. ## What it does [#what-it-does] The family garage (US41, RF42) lets the owners and admins of an Organization (the household) give another Member some of its cars, each with an Access Preset or a custom matrix of Vehicle Permissions (privacy spec §4, boards G-Papeis and G-Permissoes). Owners and admins see and do everything; any other Member sees only the cars granted to them, and of each only the areas it permits. The matrix has six areas (expenses, maintenance, odometer, documents, net worth, photos), each `see`, `log` (implies see) or `edit` (correct and delete; implies log), or left out (hidden). Selling and removing the car are not an area: the household's owners and admins, or an `owner` grant, do that. The presets are fixed sets (`PRESET_PERMISSIONS` in `vehicle-member.enums.ts`): | Preset | Words | Expenses | Maintenance | Odometer | Documents | Net worth | Photos | | ------------ | ------------------ | -------- | ----------- | -------- | --------- | --------- | ------ | | `co_owner` | "Co-owner" | edit | edit | edit | edit | edit | edit | | `can_log` | "Can log expenses" | log | log | log | | | log | | `driver` | "Driver" | | | log | | | see | | `workshop` | "Workshop" | | log | see | | | see | | `accountant` | "Accountant" | see | see | see | see | see | see | | `view_only` | "View only" | see | see | see | | | see | | `owner` | "Owner" (legacy) | edit | edit | edit | edit | edit | edit | `custom` stores the matrix as set, named after the preset it equals; a matrix that grants nothing takes the car back. Each grant also keeps the coarse Vehicle Role (`owner` for an `owner` grant, `editor` when an area is at `log` or more, else `viewer`) for the checks that are not about one area. A fuel-up counts as an odometer entry: a Driver logs it (and only it) without seeing the car's expenses. The migration `vehicle_permissions` turned the old roles into presets: `owner` stays Owner, `editor` became Can log expenses and `viewer` View only; the old documents-and-net-worth switch added Documents and Net worth (`editor`: documents `edit`, net worth `log`, as custom; `viewer`: Accountant). ## Data and isolation [#data-and-isolation] `vehicle_member` holds one row per car and grantee: a Member (`member_id`, cascading when they leave the household) or a pending Invitation (`invitation_id`). Accepting the Invitation moves its rows to the new Member in Better Auth's `afterAcceptInvitation` hook. Two functions feed every car-scoped policy ([ADR-0009](/docs/adr/0009-row-level-security)): - `app.manages_org()`: the context User is an owner or admin of the Active Organization; - `app.granted_vehicles(area)`: the cars their grants name (`'car'`), or those whose permissions name the area. `vehicle`, `ownership_period`, `expense`, `acquisition`, `disposal`, `share_package` and the rest admit a row when its car is managed or granted. Per area, a row also needs its area seen: `ledger_entry` (every amount) Expenses, unless the caller wrote it (a Driver's fill-up); `recurring_expense` Expenses; `service` and a reminder Maintenance (a document's due-date reminder Documents); `odometer_reading` Odometer; `vehicle_photo` Photos; `vehicle_document` Documents; `valuation_snapshot` and `loan` (and through it `loan_snapshot`) Net worth. Totals, insights and the activity line read the ledger under the same policy, so they hold only what the caller may see. `share_access` follows its passport. Only owners and admins write `vehicle_member`, and they may read pending Invitations to grant them. The RLS suite proves that a Member of the household without a grant sees nothing of a car, View only no net worth, and a Driver only their own amounts. ## Procedures [#procedures] | Procedure | Who | Failures | | ------------------------------------------------------------ | ----------------- | --------------------------------------------------------------------------------------------- | | `vehicleMember.list` `{}` | every Member | none: every grant for an owner or admin, the caller's own otherwise, and `manages` | | `vehicleMember.set` `{ memberId \| invitationId, vehicles }` | owners and admins | `FORBIDDEN`, `INPUT_VALIDATION`, `NOT_FOUND` (grantee or car), `CONFLICT` (an owner or admin) | Each car in `vehicles` is `{ vehicleId, preset, permissions? }` (the matrix only for `custom`). `set` replaces what the person holds and is audited (`vehicle.access_changed`). Inviting by email, removing a Member and cancelling an Invitation stay Better Auth's organization plugin. | Procedure | Who | Failures | | ---------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------ | | `vehicleMember.activity` `{}` | every Member | none: others' expenses and services on the cars the caller sees, newest first, no amounts | | `inviteLink.create` `{ vehicles }` | owners and admins | `FORBIDDEN`, `INPUT_VALIDATION`, `NOT_FOUND` (a car) | | `inviteLink.list` `{ limit }` | owners and admins | `FORBIDDEN` | | `inviteLink.revoke` `{ id }` | owners and admins | `NOT_FOUND` (used, revoked or another household's) | | `inviteLink.open` `{ token }` | anyone (the page, the app) | `TOO_MANY_REQUESTS`; null for a used, revoked, expired or unknown link | | `inviteLink.accept` `{ token }` | any signed-in User | `NOT_FOUND` (as for open), `CONFLICT` (an owner or admin of that household), `TOO_MANY_REQUESTS` | ## Invite by link or QR [#invite-by-link-or-qr] Social sharing §3 ([spec](https://github.com/CarlosZiegler/autofolio/blob/main/docs/specs/social-sharing.md)), behind `social.invite-link`. `invite_link` keeps the household, the grants chosen before sharing (`vehicles`: car, preset, permissions and role each; no foreign key: a car that leaves drops out), `expires_at` (7 days), `used_at`/`used_by` and `revoked_at`. The token is the passport's (256 random bits; only its SHA-256 and an 8-character hint are stored), and the URL is `/i/`. Tenants may read, create and revoke the household's links as its owners or admins; nothing else is granted. The invitee is not a Member yet, so `open` and `accept` run as system code (SystemReason `invite-link.accept`), keyed by the token's hash and rate-limited (30 openings per address, 10 accepts per User, a minute). `accept` claims the link first in one transaction (of two people racing, one gets it), adds the User as a `member` of the household (role `member`) unless they already are, upserts their `vehicle_member` grants as the link holds them and audits `invite_link.accepted`; `create` and `revoke` are audited by `withAudit`. An owner or admin of that household who opens their own link gets `CONFLICT` and the link stays for the invitee. The web page `/i/:token` (`features/invite-link/invite-link.page.tsx`) shows who invites, the cars with their preset, whether documents or net worth come with them, and until when, and "Open in the app" (`://invite/`); like `/p/` and `/g/` it is `noindex`, `no-store`, `no-referrer`, disallowed in robots.txt, and the log scrubber drops its token. ## Temporary access, approvals and handing a car over [#temporary-access-approvals-and-handing-a-car-over] Privacy spec §4, boards G-Temporario, G-Aprovacao and G-Transferir; flags `people.temporary-access` and `people.approvals` (on in dev) show them in the app. - **Until a date.** A grant's `expires_at` ends it: `app.granted_vehicles()` ignores a grant past it at once, and the cron tick's `grantExpiry` job (`vehicle-member.jobs.ts`, 100 per tick) deletes the due ones and pushes "Access ended" to the person and to the household's owners and admins. `vehicleMember.set` takes `expiresAt` per car (one already past grants nothing); an invite link carries none (`shortcut`). - **Approvals.** With `needs_approval` on the grant, what the Member logs (`expense.create`, `service.create`, confirming a recurring expense) is written with `status = 'awaiting'`: in the ★ ledger like any other entry, but out of M-Gastos, insights, the car's History and the sale result (`heldBack`) until the car's owner decides. `entryApproval.list` shows the caller's cars' awaiting entries (a manager: every car; a Member: the cars their grant owns); `entryApproval.approve` sets them `approved` (audited `entry.approved`); `entryApproval.reject` appends a `correction` to zero with the reason, points the row at it and sets `rejected`, which takes it out of every list (audited `entry.rejected`). The ledger is never updated. A spreadsheet import writes `approved` (`shortcut`), and a reading is a fact, not a total: an awaiting fill-up's odometer reading counts at once. - **Handing a car over.** `vehicle_transfer` holds one waiting offer per car (`vehicleTransfer.offer`, for the car's owner, to a Member who does not manage the household). The offer names its car (`car`: make, model, year and nickname, never plate or VIN), copied when it is made, because the Member holds no grant that would let them read the car before accepting. The Member declines (`decline`) or accepts (`accept`, on the system handle `vehicle-transfer.accept`, since a Member cannot write grants): the car's live passports are revoked, their grant becomes `owner`, and a giver who held the car by a grant keeps the preset they chose (`giver_preset`) or leaves it. The household's owners and admins keep every car. The giver or a manager withdraws (`withdraw`). Every step is audited (`vehicle_transfer.*`); the history stays with whoever logged it. ## Activity line [#activity-line] `vehicleMember.activity` reads `ledger_entry` (operating costs and upgrades, not imports or corrections) by authors other than the caller, with whether a `service` points at the entry, newest first, at most 20. The car tables' row-level security keeps it to the cars the caller sees; the app names the authors from the household's Members ("Someone" once they left). Every car write chains a check after its input (`platform/rpc/require-vehicle-role.ts`): `requireVehiclePermission(area, level, of)` for the area it writes (`log` to add, `edit` to correct, change or delete), `requireVehicleRole("owner", of)` to buy, finance a purchase, sell, share (passports, showcases, history transfers, a partner's confirmation) or remove it, `requireVehicleRole("editor", of)` for the car's own details, Moments and insights. `expense.create` takes Expenses `log`, or Odometer `log` for a fuel-up. A weaker grant answers `FORBIDDEN`; no grant leaves the car to row-level security (`NOT_FOUND`, as for another household's car). Adding a car, "I bought it" and data exports are for owners and admins (`requireOrgRole`), since the plan limit and the export count every car. What row-level security cannot see is closed too: reminder alerts reach a Member only for their cars and only with Maintenance (document due dates with Documents), a spreadsheet import writes only to cars with Expenses `log`, and the Organization's files (the Files gateway, Document Text procedures and the agent tools) are for owners and admins. ## App [#app] M-Familia (`screen-M-Familia`, route `/family`) opens from Profile. It lists the household's people with their strongest preset, then its current cars with who holds them ("Dana: can log expenses"), and the privacy note. For owners and admins, "Invite someone" (`familia-convidar`) opens the access sheet: email or link, a preset or none per car, Send. A person opens the sheet with their cars (`familia-carro-`, each with its preset) and Remove (or cancel the Invitation); a car opens G-Papeis. G-Papeis (`screen-G-Papeis`, route `/vehicle-access?vehicleId&person`) titles "What Dana can do" over the car, lists the presets as a radio group (`papeis-`) and Custom (`papeis-custom`), Save (`papeis-salvar`) and, for a car they hold, "Remove access to this car" (`papeis-remover`, confirmed). Custom opens G-Permissoes (`screen-G-Permissoes`, `/vehicle-permissions?vehicleId&person&from`): the matrix from the preset G-Papeis had chosen, a checkbox per area and level (`permissoes--`; checking Edit checks Log and See, clearing See clears the rest), Sale locked "owner only", the rule, and "Save permissions" (`permissoes-salvar`). Saving returns to M-Familia. The "more" button opens My invitations and "Your garages", the household switcher: the User's households (no slugs), the open one checked; switching opens the other garage at its start, and the next sign-in opens on the household last switched to while the User is still its Member (`account_preference.last_organization_id`). M-Veiculo hides what the grant cannot do: Service and Documents segments without their areas, Add expense without Expenses or Odometer `log` (a Driver's form offers fuel only), Add a value without Net worth `log`, the gallery without Photos, selling without `owner`. G-Papeis's second group, with the flags: "Access until" (`papeis-ate`) opens a sheet with Today, 3 days, 1 week, Choose date (each until 23:59) or No end date; "Approve what Dana logs" (`papeis-aprovar`) is a switch, shown for a preset that logs. Changing the preset or the matrix keeps both. M-Familia's first group lists what waits for the User: "To approve" (`familia-aprovar`) and each car another Member is handing them (`familia-oferta`). A car the User owns (`familia-carro-`) opens a sheet: "Lend the car" (`familia-emprestar`, managers) and "Hand to a member" (`familia-transferir`); a car with an offer waiting says "Waiting for Dana to accept". G-Temporario (`screen-G-Temporario`, `/vehicle-lend?vehicleId&person`): To (`temporario-para`: a Member, or someone new by email, who gets an Invitation holding the car), Can (`temporario-pode`: a preset, Driver by default), the span chips (`temporario-today`, `-3d`, `-1w`, `-date`), Ends and "Send invite" (`temporario-enviar`). G-Aprovacao (`screen-G-Aprovacao`, `/approvals`): each entry with who logged it, the car, when and the amount, Approve (`aprovacao-aprovar`) and Reject (`aprovacao-recusar`, a sheet asking for the reason), and "Approve all 3" (`aprovacao-todos`). G-Transferir (`screen-G-Transferir`, `/vehicle-transfer?vehicleId&person`): New owner (`transferir-para`), You stay as (`transferir-fico`; a manager keeps every car), what changes, and "Send for Dana to accept" (`transferir-enviar`); with an offer waiting, Withdraw (`transferir-retirar`). The Member answers on `screen-G-Transferir-Aceitar` (`/vehicle-transfer-offer?id`, not drawn): Accept the car (`transferir-aceitar`) or Decline (`transferir-recusar`). M-Veiculo's Costs and Service rows say "Awaiting approval". shortcut: the expiry and a new offer are not pushed when they are made, only when an access ends; the offer is found in M-Familia. With `social.invite-link`, the invite sheet starts on "Link or QR" (`familia-via-link`; "Email" is `familia-via-email`): "Create invite link" opens the Link created sheet with the QR (`sheet-link-qr`), the link, Copy link and Share link. Pending links are rows of the people group ("Invite link · 2 cars · until Oct 30"), revoked after a confirmation, and "Recent activity" (`familia-atividade`) shows the last five lines. The garage shows the people header (`garage-pessoas`: avatars, "Shared garage", the latest line) once the household has more than one Member; it opens M-Familia. `autofolio://invite/` opens the invite (`screen-Social-Invite`, public): signed in, "Join the garage" (`convite-entrar`) switches to the household and opens its garage; signed out, "Create account" or "I have an account" come back to it after the sign-in. `autofolio://dev/Social-People` opens M-Familia. shortcut: an https invite link opens the web page, which hands over to the app; universal links (apple-app-site-association, Android App Links) would skip it. `autofolio://dev/M-Familia` opens the seeded household: Dana can log the Corolla's expenses, each awaiting approval (a fill-up today, a car wash yesterday), Sam may view a sold car and has the Porsche lent as a Driver for three days (a fill-up awaiting approval). `autofolio://dev/G-Papeis` and `autofolio://dev/G-Permissoes` open Dana's access to the Corolla Cross, `G-Temporario` the Porsche lent to Sam, `G-Transferir` the Corolla handed to Dana and `G-Aprovacao` the three entries. shortcut: an Invitation cancelled or rejected keeps its grants until it is deleted; they are never materialized and do not show. ## Key files [#key-files] - [`packages/db/src/schema/vehicle-member.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/src/schema/vehicle-member.ts) - [`packages/db/src/schema/invite-link.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/src/schema/invite-link.ts) - [`packages/api/src/features/invite-link/invite-link.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/invite-link/invite-link.service.ts) - [`packages/api/src/features/vehicle-member/vehicle-member.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/vehicle-member/vehicle-member.service.ts) - [`packages/api/src/features/vehicle-member/vehicle-member.jobs.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/vehicle-member/vehicle-member.jobs.ts) - [`packages/api/src/features/entry-approval/entry-approval.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/entry-approval/entry-approval.service.ts) - [`packages/api/src/features/vehicle-transfer/vehicle-transfer.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/vehicle-transfer/vehicle-transfer.service.ts) - [`packages/api/src/platform/rpc/require-vehicle-role.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/platform/rpc/require-vehicle-role.ts) - [`apps/mobile/src/features/vehicle-member/vehicle-member.screen.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/vehicle-member/vehicle-member.screen.tsx) # Feature flags Typed code defaults with database overrides and separate capability gates. ## Declare a flag [#declare-a-flag] `packages/flags/src/registry.ts` declares each key, kind, default value and description. `FlagKey` and `FlagValues` derive from that registry. Unknown database keys are ignored; a missing override falls back to the code default. ```ts "home.greeting": { kind: "boolean", defaultValue: true, description: "Show the personalized greeting on the home screen.", } ``` The evaluator applies platform and targeting context to typed rows. Parsing belongs at the database boundary. Clients consume the safe registry/evaluator exports; `/repo` owns the Drizzle reads and writes and must remain server-only. ## Boot and administration [#boot-and-administration] `appConfig` combines evaluated flags and app-update configuration in one public boot request. Admin flag procedures use the current platform admin guard. Web uses `useFlags`; native boot reads the same server definitions. A visibility flag is not a capability or authorization check. `billing.web` can show navigation only after server providers are configured; `ai.assistant` and `ai.capture` (a receipt or voice draft structured by the model, see [Expense](/docs/features/expense)) also require a model provider. A hidden control never replaces membership or role authorization. ## Extend [#extend] Add the key in code, use its inferred type, and translate any user-facing labels in every catalog. Test default behavior, absent/disabled overrides and invalid stored values. Keep optional features bootable with their flag off and no provider credentials. ## Key files [#key-files] - [`packages/flags/src/registry.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/flags/src/registry.ts) - [`packages/flags/src/evaluate.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/flags/src/evaluate.ts) - [`packages/flags/src/repo.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/flags/src/repo.ts) - [`packages/api/src/features/app-config/app-config.procedures.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/app-config/app-config.procedures.ts) - [`apps/web/src/features/app-config/app-config.hooks.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/features/app-config/app-config.hooks.ts) # Files and uploads Send files directly to R2, confirm durable upload intent and recover interrupted multipart uploads. ## Gateway and isolation [#gateway-and-isolation] The web client uses authorized upload procedures for direct byte transport and the files-sdk gateway at `/api/files` for browsing, downloads and deletion. Every operation checks the current Session and membership; in AutoFolio the gateway and the Document Text procedures also need an owner or admin of the household, since its files hold every car's documents ([Family garage](/docs/features/family-garage)). The server applies `orgs//`; a filename cannot select or escape an Organization. One private R2 bucket serves all Organizations. Keys preserve the User's filename, including spaces and Unicode, under [ADR-0001](/docs/adr/0001-stored-file-keys-keep-the-user-s-filename). Treat keys as opaque and encode them for URLs. Do not reintroduce filename sanitization that silently changes the display name. ## Durable upload lifecycle [#durable-upload-lifecycle] `uploads.prepare` persists an intent after validating membership, MIME type and size. A signed PUT binds the exact key, size, content type and conditional create. Files above 16 MiB use multipart uploads with 8 MiB parts, at most three concurrent parts and at most three attempts per part. The browser sends `File.slice()` objects and resumes from server-listed parts. Completion validates part lengths and ETags, then checks HEAD metadata before confirming the file. Signed URLs expire after 15 minutes; intents last 24 hours. Cron recovers missing acknowledgements and aborts abandoned multipart sessions. Explicit cancellation aborts multipart; single-PUT cleanup waits 16 minutes to cover a late upload through an issued URL. Images can be up to 100 MiB, documents 1 GiB and ZIP archives 5 GiB. The legacy gateway proxy remains capped at 10 MiB and reauthorizes PUTs through the files-sdk patch. Soft delete is enabled; versioning is not. Text previews stop at 64 KiB, and downloads can use range requests without loading the whole original into the Worker. Confirmed supported files enter the separate [Document Text workflow](/docs/features/documents). Storage limits and conversion limits are independent; a stored original can remain downloadable when text preparation fails. [ADR-0008](/docs/adr/0008-documents-and-large-uploads) explains both paths. ## Configure and extend [#configure-and-extend] Supply `STORAGE_BUCKET`, `STORAGE_ENDPOINT`, `STORAGE_ACCESS_KEY_ID` and `STORAGE_SECRET_ACCESS_KEY` in the intended environment. Setup does not create an R2 bucket. Configure bucket CORS for the actual web origins, upload/read methods and signed headers; expose `ETag` so multipart completion can read it. `apps/web/r2-cors.json` is the starting template, not a list of every worktree or deployed origin. Extend size/MIME rules in storage config and retain admission, confirmation and source-version checks. Keep `files-sdk` on one compatible copy across web, API and storage and rebase its pinned patch on upgrades. Verify upload, download byte equality, anonymous denial, prefix isolation, multipart resume/cancel and interrupted confirmation. ## Key files [#key-files] - [`apps/web/src/routes/api/files/$.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/routes/api/files/$.ts) - [`apps/web/src/features/files/files.gateway.server.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/features/files/files.gateway.server.ts) - [`packages/storage/src/create-r2-files.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/storage/src/create-r2-files.ts) - [`packages/storage/src/upload-plugin.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/storage/src/upload-plugin.ts) - [`packages/api/src/features/uploads/uploads.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/uploads/uploads.service.ts) - [`packages/storage/src/direct-s3.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/storage/src/direct-s3.ts) - [`packages/storage/src/config.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/storage/src/config.ts) - [`patches/files-sdk@2.6.2.patch`](https://github.com/CarlosZiegler/autofolio/blob/main/patches/files-sdk@2.6.2.patch) # Garage and catalog How a Member adds a Vehicle from the NHTSA catalog or by hand, edits it, and reads the Organization's garage, with the Free plan limit. ## What it does [#what-it-does] The garage is every Vehicle of the Active Organization, in one of four states: `garage` (current), `sold`, `wishlist` or `archived`. Any Member adds and edits cars; there is no Org role check. - **Catalog search** (`catalog.search`): words match make, model and trim in any order. The shared `vehicle_catalog` table caches NHTSA vPIC (CAT-01). The first word is taken as a make and fetched from vPIC (`GetModelsForMake`) at most once per 30 days, only when the cache has no better answer, with a 4-second timeout. vPIC lists models only per make, so when the cache matches nothing the common makes (`COMMON_MAKES`) are fetched as well, six at a time within the same timeout: a model word alone ("Corolla") works on a cold cache. A slow or failing vPIC is not an error: the search answers from the cache. vPIC has no trims or year ranges, so those stay empty unless the source has them; trim is then free text. A curated entry whose trim is sold in one market carries that market (`market`, ISO country; vPIC rows have none). The client sends the account's market (its locale's country): an empty query lists that market's curated entries and nothing else, and a typed one never shows another market's trims and lists the market's first. - **Add a car** (`vehicle.create`): from a Catalog Entry (make, model and trim are copied, never joined) or typed in and flagged `manual`. Year, nickname and photo are optional. The odometer is sent in the unit the user sees and stored in km as the first Odometer Reading. The photo is an image from the [direct upload flow](/docs/features/files) (`uploads.prepare` → `PUT` → `uploads.complete`) and becomes the cover. The currency defaults to the User's Account Preference, else EUR. A client-generated `id` makes a replay return the same car (RNF04). - **Edit a car** (`vehicle.update`): make, model, trim, year, nickname, color, plate and VIN (null clears), link a manual car to a Catalog Entry (it stays flagged `manual`), or replace the cover. Plate and VIN are encrypted with `FIELD_ENCRYPTION_KEY` before the write (RN09). - **Specs** (`vehicle.specs`, M-Galeria): the car's make, model, trim, year, color and its Catalog Entry's fuel, and only whether a plate and VIN are set, with `canReveal` for the Organization's owner. `vehicle.identifiers` (owner only, audited as `vehicle.identifiers_revealed`) decrypts them; the app keeps them in memory, never in the query cache. - **Wishlist** (US27, RF27): `vehicle.create` with `wishlist: { targetPriceMinor?, note? }` saves a wished-for car in the `wishlist` state, with no Ownership Period and no odometer (one is refused), outside the Free limit. `vehicle.update` edits `targetPriceMinor` and `note` (null clears). `vehicle.moveToGarage` ("I bought it") moves it to `garage` and opens an Ownership Period from today, under the same lock and RN11 check as adding a car; a replay on the car already in the garage changes nothing, a sold or archived car is `CONFLICT`. `vehicle.removeWish` deletes a wished-for car with its photos (audited as `vehicle.deleted`); any other state is `CONFLICT`, since an owned car's history is never deleted (RN01). - **Archive a car** (`vehicle.archive`): moves it to `archived`, out of the garage and out of the Free count. Nothing is deleted and its Ownership Period stays as it is (RN01). - **A car's profile** (`vehicle.profile`, M-Veiculo): the hero (make, model, year, color, latest odometer, cover) and the overview, computed on the server. The latest Valuation comes with its source, date and confidence (RN03), its change against the previous one in basis points and up to 24 values for the sparkline. "Costs, last 12 months" is the total spent in the window, the same number as M-Gastos' "Total spent": `totalSpent` from `@autofolio/domain` over the ledger as it stands, each entry replaced by the latest correction of its chain, keeping the original's kind. It counts every expense category, upgrades included; never a Loan's interest and fees, its principal (RN05) or the acquisition. It is not the TCO ([Insights](/docs/features/insights) adds depreciation, interest and fees). Cost per distance is that total over the km driven since the reading on or before the window's start (`perDistance`), in the unit the client asks for; null without two readings. The next service is the most urgent open reminder from the [reminder feature](/docs/features/service)'s forecast (`listReminders`). The Loan balance is the newest Loan's latest snapshot. An entry in another currency counts only through its stored rate into the car's. - **Add a value** (`vehicle.addValuation`): a manual Valuation in the car's currency, dated today unless `asOf` is given (never later), `medium` confidence by default. A car in another currency than the owner's main one may carry `rate`, what one unit is worth in that currency; it is stored with the Valuation (`fx_currency`, `fx_rate`) and net worth converts at it. ★ Appended, never overwritten; a client-generated `id` makes a replay write nothing new. - **Net worth** (`garage.netWorth`, M-Patrimonio, US20): each current (`garage`) car with its latest Valuation and its newest Loan's latest balance, ranked by value, and the totals in the User's main currency through `netWorth` from `@autofolio/domain` (`netWorthOf` in `vehicle.rules.ts`): assets, debt and assets minus debt (RF17). Sold, wishlist and archived cars never count. An amount in another currency counts at the latest rate stored with any Valuation of that currency into the main one, returned in `rates`; without one it is returned in `unconverted` and never summed. - **Reference values** (RF38, ADR-06): see [below](#reference-values-rf38). `vehicle.profile` and `garage.netWorth` return each car's `referenceSource`, the provider of its market, or null. - **The garage** (`garage.list`): every car, newest first, with its latest odometer and Valuation (source, date and confidence), a 15-minute signed cover URL, years owned, target price and note, and `needsAcquisition` for a current car whose purchase is not recorded. It returns the count per state, the User's plan and `collection` (M-Colecao, RF28): `yearsOwned`, the days of every Ownership Period of the current and sold cars (open ones to today) in whole years, never rounded up, and `drivenKm`, each such car's latest reading minus its first (`collectionOf` in `vehicle.rules.ts`). The app filters by state on the device. ## In the app [#in-the-app] The Garage tab (`features/garage/garage.screen.tsx`, M-Garagem) filters the list by state on the device and formats money and distance with the User's Account Preference. The list is saved per server, User and Organization in the app's cache folder (`garage.cache.ts`), so the tab opens offline; sign-out deletes it. Covers use the photo id as the image cache key, so they survive the 15-minute URL. Add vehicle (`garage.add-vehicle.screen.tsx`, M-Cadastro) debounces `catalog.search` by 300 ms and shows three results (a hint asks to type more when there are others), asks for the year and the odometer before a car joins the garage (a wish needs neither; a catalog year fills the field), uploads the picked photo before `vehicle.create` with the native file upload (`expo-file-system`, exactly the signed `content-type` and the file's own length), keeps Save pinned above the keyboard with a Done key for the number pads, returns to the Garage tab after a save, and turns `PLAN_LIMIT` into a toast with a way to Plus; the + button checks `canAddActiveVehicle` first. Add vehicle and its edit mode (`garage.edit-vehicle.screen.tsx`, from the profile's more menu) sit above the tabs, as their board has no tab bar. A garage card or Sold row opens the car's profile (`features/vehicle/vehicle.screen.tsx`, M-Veiculo): the full-bleed hero, the segmented Overview | Costs | Service | Documents (one component per segment; Costs lists `expense.list`, Service lists `service.list`, Documents lists the newest four with their status and opens [M-Documentos](/docs/features/vehicle-documents)), the white "Add expense" pill (`add-gasto`) and Gallery, Prepare sale, Documents and History. Prepare sale opens [M-Vender](/docs/features/passport). The dev link `autofolio://dev/M-Veiculo` opens the seeded M3: the dev sign-in answers the seeded cars' ids. My collection (`garage.collection.screen.tsx`, M-Colecao) is drawn from the same cached list, so it opens offline: the stats card (vehicles, current, sold, years owned; two by two at large text sizes), the distance driven under the title, a photo card per current and sold car with its period, opening its History, and the footer: "No public cars", or how many Showcase links are public. Its share button follows the flags: with `social.moments` and `social.showcase` a sheet offers "Share as image" (the collection's card) and "Public link" (the Showcase links, with the public count); with one flag it opens that one; with neither it is dimmed and only explains that nothing is public. The Sold section's "My collection" link and the "Sold." sheet after a sale open it; `autofolio://dev/M-Colecao` too. A Wishlist row opens the wish sheet (`garage.wish-sheet.tsx`): photo, target price and note, "I bought it" (then the purchase, M-Compra), Edit (the Add vehicle form with the target price and note instead of odometer and nickname) and Remove after a confirmation. Under the Wishlist filter, + and the empty state add a wish (`/add-vehicle?wishlist=1`). Net worth (`features/vehicle/vehicle.net-worth.screen.tsx`, M-Patrimonio) sits in the Finances tab: its row on M-Gastos and the estimated value card of M-Veiculo open it. A car row opens the value sheet (`vehicle.value-sheet.tsx`, shared with M-Veiculo's "Add a value"): amount, date, confidence and, for a foreign car, the rate. More lists the loans behind the debt and opens M-Financiamento. The Insights pill opens a placeholder until M-Insights lands. `autofolio://dev/M-Patrimonio` opens the seeded garage. Its footnote says "Reference value unavailable in your country" while no provider covers a car (the value sheet and M-Veiculo's empty value card say it too); the manual value always stays possible. ### Reference values (RF38) [#reference-values-rf38] A licensed provider per market (FIPE, DAT/Schwacke, KBB, CAP HPI) implements `ValuationProvider` in `vehicle.valuation-provider.ts`: an `id` (the Valuation's `source`), a country, the market's currency and `quote(car)`, which returns an amount, date, confidence and optional provider code, or `"unavailable"`. It sees catalog facts and the latest odometer only, never plate, VIN or owner. `VALUATION_PROVIDERS` is empty: no licence is contracted (VAL-03), FIPE has no official API (VAL-01), and nothing is scraped. A car's market is the currency it is priced in (euro countries share one until a car gets a country). `pendingRevaluations` (`vehicle.jobs.ts`) runs on the cron tick during 03:00 UTC: for up to 50 current cars a tick whose market has a provider and that it has not valued today, it appends a ★ `valuation_snapshot` with `source` = the provider's id and no author. The row id is derived from car, provider and day, so a repeated tick writes once. A failing provider, an `"unavailable"` answer or a quote in another currency skips the car; it never fails the tick. ## Plan limit (RN11) [#plan-limit-rn11] Free holds one current (`garage`) Vehicle; sold and wishlist cars do not count, and a wish is saved even when the garage is full. Plus is unlimited. The plan comes from the User's Paid Access (`billing.policy.ts`): an active entitlement is Plus, none is Free. A deployment with no billing provider sells nothing, so it enforces no Free limit and every User is Plus; that is the local default. A save or "I bought it" beyond the limit fails with `PLAN_LIMIT` (HTTP 402). The count runs under a per-garage advisory lock, so two saves cannot both pass it. ## Procedures [#procedures] | Procedure | Who | Failures | | ---------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------- | | `catalog.search` | Any signed-in User | — | | `vehicle.create` | Members of the Active Organization | `NOT_FOUND` (catalog entry), `BAD_REQUEST` (photo), `PLAN_LIMIT`, `CONFLICT` (id taken) | | `vehicle.update` | Members of the Active Organization | `NOT_FOUND` (car or catalog entry), `BAD_REQUEST` (photo) | | `vehicle.specs` | Members of the Active Organization | `NOT_FOUND` | | `vehicle.identifiers` | The Organization's owner | `FORBIDDEN` (not the owner), `NOT_FOUND` | | `vehicle.moveToGarage` | Members of the Active Organization | `NOT_FOUND`, `PLAN_LIMIT`, `CONFLICT` (sold or archived) | | `vehicle.removeWish` | Members of the Active Organization | `NOT_FOUND`, `CONFLICT` (not on the Wishlist) | | `vehicle.archive` | Members of the Active Organization | `NOT_FOUND` | | `vehicle.profile` | Members of the Active Organization | `NOT_FOUND` | | `vehicle.addValuation` | Members of the Active Organization | `NOT_FOUND` (car), `BAD_REQUEST` (future date, rate into the car's currency), `CONFLICT` (id taken) | | `garage.list` | Members of the Active Organization | — | | `garage.netWorth` | Members of the Active Organization | — | ## Data and isolation [#data-and-isolation] `vehicle`, `odometer_reading`, `vehicle_photo`, `ownership_period`, `valuation_snapshot` and `acquisition` have row-level security; the procedures use `context.db`. `vehicle_catalog` is readable by every trusted session and written only through `asSystem(db, "catalog.refresh", …)`. `garage.list` and `garage.netWorth` read at most 200 cars (a garage, not an archive). Writes are audited as `vehicle.created`, `vehicle.updated` (an archive and "I bought it" too) and `vehicle.deleted` (a wish removed). Plate and VIN never reach a log: the audit row holds the car's id only. The photos and the cover are the [gallery](/docs/features/vehicle-photos). ## Key files [#key-files] - [`apps/mobile/src/features/garage/garage.hooks.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/garage/garage.hooks.ts) - [`packages/api/src/features/catalog/catalog.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/catalog/catalog.service.ts) - [`packages/api/src/features/catalog/catalog.vpic.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/catalog/catalog.vpic.ts) - [`packages/api/src/features/vehicle/vehicle.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/vehicle/vehicle.service.ts) - [`packages/api/src/features/vehicle/vehicle.contract.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/vehicle/vehicle.contract.ts) - [`packages/api/src/features/billing/billing.policy.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/billing/billing.policy.ts) - [`packages/db/src/schema/garage.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/src/schema/garage.ts) # History transfer How a seller hands a car's technical history to the next owner's AutoFolio account: the materialized offer, both consents, the system path the buyer reads it through, and what never travels. ## What it does [#what-it-does] History transfer (US43, RF46) lets a seller offer the technical history of a car in their garage or sold to the AutoFolio account of one email. The buyer sees the offer in their garage and accepts or declines it. Accepting adds a new car to the buyer's Active Organization with a copy of what the seller ticked, each record marked "From previous owner". Both answers are ★ `consent_record` rows (purpose `history_transfer`), and every step is audited (`history_transfer.offered`, `.revoked`, `.accepted`, `.declined`; never the email). | Travels | Never travels | | ---------------------------------------------------------------- | ----------------------------------------------------------------- | | make, model, trim, year, colour, catalog entry | plate and VIN (RN09), nickname, notes | | services: title, date, reading, shop, item names, trust | service costs and line amounts, ledger entries, expenses | | odometer readings with their flags and reasons (RN07) | acquisitions, disposals, loans, valuations (the economic history) | | photos, and the documents the seller ticks one by one (none yet) | reminders, recurring expenses, passports | A service keeps "with receipt" only when its receipt document goes too; a partner's verification is the partner's, so it travels as "with receipt" at most. ## Data and isolation [#data-and-isolation] `history_transfer` lives in the seller's Organization: `recipient_email` (lower-cased), `snapshot` (the materialized technical history, with the seller's file keys), `counts`, `expires_at` (30 days), and one outcome at most (`accepted_at` with `accepted_by` and `accepted_vehicle_id`, `declined_at` or `revoked_at`). A pending offer holds its snapshot and an ended one never does; once it expires, the cron tick's `historyTransfers` job (`history-transfer.jobs.ts`, 100 offers a tick) drops the snapshot too, and the offer stays listed as expired with its counts; a car has one pending offer, and a new one replaces it. Tenant traffic inserts an offer and revokes it, nothing else ([ADR-0009](/docs/adr/0009-row-level-security)). The buyer is in another household, so `inbox`, `accept` and `decline` run on the system handle (SystemReason `history-transfer.receive`) and every query is keyed by the session's email, only when it is verified. Accepting copies the files into `orgs//transfers/` first (a file the seller has since deleted is left out), then in one transaction locks the offer, checks the Free limit (RN11), and writes the car, an Ownership Period opening today, the readings, files, photos, documents and services (`from_previous_owner`, no `ledger_entry_id`), the outcome, the buyer's consent and the audit event. A failure removes the copies. `service`, `odometer_reading` and `vehicle_document` carry `from_previous_owner`. A copied service has no money: `amountMinor`, `currency` and `ledgerEntryId` are null in the service contract, and `service.correct` refuses it (`CONFLICT`). History events carry the flag too. ## Procedures [#procedures] | Procedure | Who | Failures | | --------------------------------------------------------------------- | --------------------------- | ---------------------------------------------------------------------------------------- | | `historyTransfer.prepare` `{ vehicleId }` | the car's owner (documents) | `NOT_FOUND`, `CONFLICT` (wished-for or archived car) | | `historyTransfer.offer` `{ vehicleId, email, sections, documentIds }` | the car's owner (documents) | `INPUT_VALIDATION`, `BAD_REQUEST` (own email), `NOT_FOUND` (car or document), `CONFLICT` | | `historyTransfer.list` `{ vehicleId?, limit }` | every Member | none: the offers of the cars they see, with `status` | | `historyTransfer.revoke` `{ id }` | the car's owner | `NOT_FOUND`; an ended offer stays as it is | | `historyTransfer.inbox` `{}` | every Member | none: pending offers to their verified email from other households | | `historyTransfer.accept` `{ id }` | owners and admins | `FORBIDDEN`, `NOT_FOUND` (gone), `CONFLICT` (the seller's own household), `PLAN_LIMIT` | | `historyTransfer.decline` `{ id }` | every Member | `NOT_FOUND` (gone), `CONFLICT` (the seller's own household) | ## App [#app] M-Passaporte's last row, "Transfer history to the new owner" (`transferir-historico`), opens the transfer screen (`screen-transfer`, route `/transfer`): the cars to offer and the offers made, each with its status and, while pending, Withdraw. A car opens the offer form (`screen-transfer-offer`, `/transfer/`): Services, Odometer readings and Photos with their counts, the documents to tick, what never goes, the next owner's email (`transferir-email`), the consent footnote and "Offer history" (`transferir-oferecer`). The buyer finds "Offered to you" on M-Garagem; an offer opens its review (`screen-transfer-received`, `/transfer-offer/`): who offers it, what comes with it, what never does, then "Accept and add to my garage" (`transferir-aceitar`), which opens the new car, or Decline (`transferir-recusar`). The copied services read "From previous owner" in Service, M-Veiculo and History, without a cost or a correction. `autofolio://dev/M-Passaporte` opens the seeded offer of the sold Audi; `autofolio://dev/M-Garagem` shows the Cayman whose history a fictitious seller offers the demo account. shortcut: copied documents are not queued for Document Text, so search and the assistant skip them; the buyer is not notified by push or email, the seller tells them. ## Key files [#key-files] - [`packages/db/src/schema/history-transfer.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/src/schema/history-transfer.ts) - [`packages/api/src/features/history-transfer/history-transfer.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/history-transfer/history-transfer.service.ts) - [`packages/api/src/features/history-transfer/history-transfer.rules.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/history-transfer/history-transfer.rules.ts) - [`apps/mobile/src/features/history-transfer/history-transfer.screen.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/history-transfer/history-transfer.screen.tsx) # Internationalization Portuguese, English and German UI with request-scoped SSR translation. ## Catalog ownership [#catalog-ownership] Web catalogs live in `apps/web/src/locales/{pt,en,de}.ts`. `MessageKey` derives from English; the other catalogs must retain key and interpolation parity. Shared error translations come from `packages/errors/src/locales`. Mobile maintains its own app catalogs (`pt-BR`, `en`, `de`). Use `useTranslation` in components and hooks. Format dates and numbers with Intl. Do not concatenate translated fragments when the sentence needs different word order. ## Server rendering [#server-rendering] `getSSRLanguage` checks a validated `?lang=` first, then the locale cookie, then the fallback. `getRequestI18n` clones the i18next instance for that request while sharing loaded catalogs. Mutating one global language during SSR would make concurrent requests affect each other. Each catalog is its own chunk. The Worker loads all three before its first render (`src/server.ts`); the browser fetches only the languages the page was rendered in, the `` and any page-scoped `lang` such as a passport's, before it hydrates (`src/client.tsx`). Switching language loads the new catalog first (`loadLanguage`). Never import `@/locales/*` statically from client code: `scripts/assert-locale-chunks.mjs` fails the build when two catalogs share a chunk. The client keeps its shared instance and updates the document language after a switch. Public localized pages advertise locale alternates. Developer articles are English only: their article region has `lang="en"`, their chrome is translated, and their sitemap entries have no hreflang alternates. ## Extend [#extend] Add a message to Portuguese, English and German together. Preserve placeholder names and run the locale parity tests. A new language also needs the supported-language definition, formatters, SEO mappings and native catalogs updated. Translate the domain consistently: a Todo's translated label can differ from its code identifier. ## Key files [#key-files] - [`packages/intl/src/i18n.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/intl/src/i18n.ts) - [`packages/intl/src/server.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/intl/src/server.ts) - [`apps/web/src/platform/i18n/intl.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/platform/i18n/intl.ts) - [`apps/web/src/client.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/client.tsx) - [`apps/web/src/locales/en.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/locales/en.ts) - [`apps/mobile/src/platform/i18n/i18n.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/platform/i18n/i18n.ts) # Features and flows Trace each feature through contracts, authority, persistence and presentation. ## AutoFolio [#autofolio] ### Garage [#garage] - [Garage and catalog](/docs/features/garage) - [Account preference](/docs/features/account-preference) - [Appearance](/docs/features/appearance) - [Vehicle photos](/docs/features/vehicle-photos) and [documents](/docs/features/vehicle-documents) - [History](/docs/features/vehicle-history) - [Family garage and invite links](/docs/features/family-garage) ### Money [#money] - [Acquisition](/docs/features/acquisition) and [loan](/docs/features/loan) - [Expense](/docs/features/expense) and [recurring expense](/docs/features/recurring-expense) - [Spreadsheet import](/docs/features/spreadsheet-import) - [Insights](/docs/features/insights): TCO, compare, proactive insights, monthly summary, dashboard photo ### Service [#service] - [Services and reminders](/docs/features/service) - [Partner workshops and dealers](/docs/features/partners) ### Sale and sharing [#sale-and-sharing] - [Sale](/docs/features/disposal) - [Passports](/docs/features/passport) - [History transfer](/docs/features/history-transfer) - [Moments and share cards](/docs/features/moments) - [Showcase links](/docs/features/showcase) ### AI [#ai] - [AI foundation](/docs/features/ai): model registry, flags, quotas - [Document scanner](/docs/features/scanner) - [Cinematic Studio](/docs/features/studio) - [AI assistant, tools and copilot cards](/docs/features/assistant) - [AI extras](/docs/features/ai-extras) ### Your data and the app [#your-data-and-the-app] - [Data export and erasure](/docs/features/data-export) - [Mobile app](/docs/features/mobile) ## Platform [#platform] - [Authentication and Organizations](/docs/features/auth) - [oRPC conventions](/docs/features/api) - [Database and migrations](/docs/features/database) - [Files and uploads](/docs/features/files) - [Documents](/docs/features/documents) - [Scheduled jobs](/docs/features/scheduled-jobs) - [Feature flags](/docs/features/feature-flags) - [Internationalization](/docs/features/i18n) - [Payments](/docs/features/payments) - [Email](/docs/features/email) - [Open Graph images](/docs/features/og-images) - [MCP and agent connections](/docs/features/mcp) - [Agent-readable pages](/docs/features/agent-pages) - [Administration](/docs/features/admin) - [Webhooks and notifications](/docs/features/webhooks) ## Choose the owner first [#choose-the-owner-first] Vehicles, their records and files belong to an Organization, the household. Account Preferences, Sessions and Entitlements belong to a User. A Thread belongs to both one User and one Organization. Follow these owners through the contract, authorization check, transaction and client query key. Each guide names the implementation files, extension path and operational limits. Architectural decisions remain in the [ADRs](/docs/adr). # Insights How M-Insights computes a car's total cost of ownership and cost per km or mile for a period, compares it across the garage's cars, and how proactive insights, last month's summary and the dashboard photo work. ## What it does [#what-it-does] A car's total cost of ownership (TCO) for a period (RF18, US21) is **depreciation + running costs + interest and fees** (`periodTco` in `@autofolio/domain`). Loan principal never counts (RN05): a payment is counted once, as the interest and fees booked to the ledger. "Compare cars" (RF25, US22) puts every car's cost per km or mile for the same period side by side, a car sold in it over the months it was owned: its running costs over its distance, the number M-Veiculo shows, as the M-Insights board draws it (the M3 at $1.12 / mi there, its TCO $1.80 / mi above). - **Running costs** are M-Gastos' "Total spent" for the same period (`totalSpent`): its six categories, upgrades included, read through each Expense's and Service's current ledger entry (a correction replaces the original; raw ledger rows are never summed). - **Interest and fees** are the car's `loan_interest` and `loan_fee` entries dated in the period, as they stand after corrections. M-Financiamento shows the same entries over the Loan's whole life, so a year's are part of that total (the seeded M3: $1,870 of $3,740). - **Depreciation** is the car's value at the period's start minus its value at its end (`depreciation`). The value on a day is the latest purchase price or Valuation on or before it, else the first one: a car bought during the period starts at its price. A gain in value is negative and lowers the TCO; the app labels it "Estimated appreciation", never a profit (RN04). Without any value it is unknown and left out. - **Distance** is the latest odometer reading minus the last one on or before the period's start, as on M-Veiculo. Without two readings there is no cost per distance. Periods are calendar months up to today's: `1m` (this month), `6m`, `12m`, or `all` (the whole ownership). `12m` is M-Gastos' 12 months. ## Procedures [#procedures] | Procedure | Who | Failures | | ----------------------------------------------------------------- | --------------------------- | ------------------------------------- | | `insights.tco` `{ vehicleId, period, today, distanceUnit }` | Members of the Organization | `INPUT_VALIDATION`, `NOT_FOUND` (car) | | `insights.compare` `{ period, today, distanceUnit }` | Members of the Organization | `INPUT_VALIDATION` | | `insights.overview` `{ vehicleId?, period, today, distanceUnit }` | Members of the Organization | `INPUT_VALIDATION`, `NOT_FOUND` (car) | `tco` answers the total and per distance, each component, the distance in km, running costs by category and by month (at most 12). `compare` answers the cars with a distance in the period, each with its running costs and those per distance and whether it is sold: current cars in M-Garagem's order, then cars sold during the period (their costs and readings end at the sale). Amounts are integer minor units in the car's currency; a cost or Valuation in another currency without a stored rate is left out. ## Data and isolation [#data-and-isolation] Reads only, through the request's `app_tenant` handle with the Organization filter; row-level security alone hides another Organization's cars. Nothing is written or audited, and amounts never go to logs (RNF07). ## App [#app] M-Insights (`screen-M-Insights`, route `/insights`) opens from M-Patrimonio's Insights button. The subtitle picks a current car; the segmented control (`periodo-1m`, `periodo-6m`, `periodo-1a`, `periodo-tudo`) the period. The TCO card shows the TCO per distance, the total and the distance with the months' running costs as a line; then the three components with the formula, the category ring (grey ramp, legend with percentages) and "Compare cars" (each car's running costs per distance, a sold one marked "Sold"), whose current cars open their numbers. Its footnote says why that figure is lower than the TCO per distance above: it leaves out depreciation and interest. The screen reads `insights.overview`, Compare cars and the chosen car's TCO in one request: without a car the server picks the first current one with a cost per distance in the period, else the first current one, and that choice stays when the period changes. `autofolio://dev/M-Insights` opens the seeded M3. ## Proactive insights (AI spec §4) [#proactive-insights-ai-spec-4] With the `ai.insights` flag on, each current car is compared with its own pattern, and only a real change becomes an insight, always with its number and its basis (`insights.signals.ts`, pure): | Kind | Found when | Shows | Plan | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ---- | | `fuel_economy_drop` | the last full-to-full tank (RF09) used 15% or more per distance than the car's average over at least two tanks before it, the fill at most 45 days old | the last tank and the average, in the account's economy unit | Plus | | `spend_spike` | this month's costs (M-Gastos' bars) pass twice the average of the six months before, three of them with costs | spent so far and the monthly average | Free | | `insurance_increase` | an insurance payment is 5% or more above the one 12 months (± 20 days) before it, at most 45 days old; three times or more is a change of cadence, not a price | both payments and their dates | Plus | | `service_due` | a service reminder is due soon or overdue (US17's alert) | the due date, distance left and pace | Free | Free sees the basic kinds, Plus all of them (ADR-0017's tier; `PLUS_INSIGHTS`). No model computes these numbers (RN03); the `insights` model row of the AI registry is unused for now. **Last month's summary.** The card above the list: the household's running costs in the last calendar month per currency, against the average of the six months before (once three of them had costs), km driven, the biggest category and each car with costs. **Storage.** The `insight` table records one row per kind and key (the fill, the month, the payment, the reminder's due), so the same change is never news twice; only `status` changes (`new` → `dismissed`). The numbers are read from the records each time, never stored. RLS as for Moments: a Member sees only granted cars; the summary row (no car) only owners and admins. **Morning push.** The `insights` job runs in each data region's morning hour (07, 11, 14 UTC, as reminders and Moments): it records what each household's cars hold and pushes the new ones from the last 7 days to the Members who allow push and see that car, once per device, never retried. Plus's kinds reach Plus accounts only; a due service is listed, not pushed (the reminder job already did); the summary reaches owners and admins in the month's first week. Push copy carries percentages, never an amount, plate or VIN. The scan pages through the households and keeps its place for the day in `job_cursor`, as the Moments job does. Reading the feed writes only the insights it has not recorded before. | Procedure | Who | Failures | | --------------------------- | ------------------------------------- | ------------------------ | | `insights.feed` `{ today }` | Members of the Organization | `INPUT_VALIDATION` | | `insights.dismiss` `{ id }` | owners, admins and editors of the car | `NOT_FOUND`, `FORBIDDEN` | The app's **Insights** screen (`screen-Insights`, route `/insight-feed`) shows the summary card, "By car" when several cars had costs, then "What changed": each row opens the car's costs or maintenance and has Hide (also a VoiceOver action). Free sees a row to Plus. The garage shows "Insights for you · N to see" while some wait, and Profile always lists it with the flag on. `autofolio://dev/Insights` opens it on the demo garage: the seeded Porsche's last tank (+35%) and its insurance renewal (+12%), the M3's due services and September's summary. ## Dashboard photo (Painel) [#dashboard-photo-painel] M-Veiculo → More → "Odometer from a photo" (`screen-Painel`, route `/odometer-photo`, with `ai.insights` on and a car in the garage): photograph the instrument cluster (or pick a photo), the text is read on the phone (Vision / ML Kit, never metered, nothing uploaded) and `readCluster` picks the odometer: the number on an ODO/Gesamt line, else the largest whole number that is not the range or a trip (a trip shows tenths); clocks, temperatures and percentages are skipped, miles when the display says so. The range is shown for checking only, other numbers can be picked, and the value stays a draft until Save (RN10). `odometer.record` `{ id, vehicleId, km, readOn, reason? }` appends the reading with source `photo` through RN07: a lower one (or one above a later reading) answers `odometer_reason_required` and the screen asks a reason; saved, it is flagged. It goes through the offline queue (RNF04, the id is made once); a reading the server sends back later opens the screen again from a toast. A sold car answers `BAD_REQUEST`, another household's `NOT_FOUND`. `autofolio://dev/Painel` opens it with a sample cluster read past the M3's last reading. ## Key files [#key-files] - [`packages/domain/src/rules.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/domain/src/rules.ts) - [`packages/api/src/features/insights/insights.rules.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/insights/insights.rules.ts) - [`packages/api/src/features/insights/insights.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/insights/insights.service.ts) - [`apps/mobile/src/features/finances/finances.insights.screen.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/finances/finances.insights.screen.tsx) - [`packages/api/src/features/insights/insights.signals.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/insights/insights.signals.ts) - [`packages/api/src/features/insights/insights.jobs.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/insights/insights.jobs.ts) - [`apps/mobile/src/features/insights/insights.screen.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/insights/insights.screen.tsx) - [`apps/mobile/src/features/odometer/odometer.cluster.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/odometer/odometer.cluster.ts) # Loan How a car's financing (M-Financiamento) keeps its balance as an append-only history, books interest and fees to the ledger, never counts principal as an expense, and opens or edits a Loan from the app. ## What it does [#what-it-does] A Loan finances one car (RF07, US07). The `loan` row holds the fixed terms: lender, amount financed (`principal_minor`), currency, rate and term. What is still owed is the ★ `loan_snapshot` series, and what the Loan costs is ★ `ledger_entry` rows of kind `loan_interest` and `loan_fee`, which count in total cost (TCO). Principal is never an expense (RN05): what was repaid is only the amount financed minus the latest balance, which builds equity. A car shows its newest Loan, the last one recorded. M-Compra opens one from the amount financed ([Acquisition](/docs/features/acquisition)); M-Financiamento opens one for a car without a balance owed. ## Procedures [#procedures] | Procedure | Who | Failures | | ----------------------------------------------------------------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `loan.get` `{ vehicleId }` | Members of the Organization | `NOT_FOUND` (car) | | `loan.create` `{ id, vehicleId, principalMinor, startDate, lender?, rate?, termMonths? }` | Members of the Organization | `INPUT_VALIDATION`, `NOT_FOUND` (car), `BAD_REQUEST` (not in the garage, start in the future), `CONFLICT` (a balance owed, id) | | `loan.updateTerms` `{ loanId, lender, rate, termMonths }` | Members of the Organization | `INPUT_VALIDATION`, `NOT_FOUND` (Loan) | | `loan.updateBalance` `{ id, loanId, balanceMinor, asOf, interestMinor?, feesMinor? }` | Members of the Organization | `INPUT_VALIDATION`, `NOT_FOUND` (Loan), `BAD_REQUEST` (car not in the garage, date before the start or in the future), `CONFLICT` (id) | Each answers the car and its newest Loan as `loan.get` does: the latest balance and its date, the history (oldest first, at most 60), principal paid, interest and fees (as they stand after corrections, since the start), and the payments made. Payments are the monthly payment days reached from the start to the latest balance, at most the term ("22/48"). A rate is basis points of its period: `{ bp: 690, period: "year" }` is "6.9% APR", `{ bp: 119, period: "month" }` is "1,19% a.m.". The app picks the period the market quotes (a month in Brazil, a year elsewhere) and the owner can switch it. ## Update balance [#update-balance] `loan.updateBalance` writes a new snapshot and, when entered, the interest and fees paid since the last update as entries dated the balance's date in the car's open Ownership Period. A backdated balance joins the history in date order; the latest balance is the newest date's. A zero balance pays the Loan off, after which a new one can be opened. ## Offline and idempotency (RNF04) [#offline-and-idempotency-rnf04] The client generates the Loan's id and the snapshot's id. Sending one again answers the Loan as it stands and writes nothing more; an id that names another car's Loan or another Loan's snapshot is `CONFLICT`. ## Data and isolation [#data-and-isolation] `loan` has row-level security by Active Organization and only its terms are updatable; `loan_snapshot` and `ledger_entry` are append-only for every role. No `audit_log` event is written: the ★ rows are the trail, and amounts never go to logs (RNF07). ## App [#app] M-Financiamento (`screen-M-Financiamento`, route `/loan/[vehicleId]`) shows the balance in `negative` with its history line, principal with payments made, interest and fees, the rate, the RN05 note and "Update balance" (`atualizar-saldo`). The More menu edits the terms, or opens a new Loan once one is paid off; a car without a Loan offers Add loan. The vehicle profile's Loan balance card opens it. `autofolio://dev/M-Financiamento` opens the seeded M3. ## Key files [#key-files] - [`packages/api/src/features/loan/loan.contract.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/loan/loan.contract.ts) - [`packages/api/src/features/loan/loan.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/loan/loan.service.ts) - [`apps/mobile/src/features/loan/loan.screen.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/loan/loan.screen.tsx) # MCP and agent connections OAuth-authenticated agents use the existing Worker and the shared tool catalog. ## Connection flow [#connection-flow] The MCP resource is `/api/mcp`. Better Auth provides OAuth 2.1, PKCE S256 and dynamic client registration. Protected-resource and authorization-server metadata live at the well-known endpoints; JWKS is `/api/auth/jwks`. Read the [connection guide](/docs/connect-your-agent) for client commands and supported scopes. An unauthenticated request receives a Bearer challenge. `data:read` is required; `data:write` permits requesting writes, each still subject to exact User approval. Read-only grants do not see write tools. Revoking a connection rejects later access even before its JWT expires. ## Scope and approval [#scope-and-approval] MCP is pinned to the User's personal Organization because an OAuth token does not carry the app's Active Organization. Tools check current membership. Writes return an approval request and an Integrations link. Requests expire after 15 minutes, with at most 20 unexpired pending records per User. Legacy draft tools create inert proposals. Approving proposal creation does not apply its domain changes; the separate Apply step remains. Replaying an executed approval returns the stored result. ## Resources and widgets [#resources-and-widgets] The server adapts the same 22-tool catalog as the [assistant](/docs/features/assistant), subject to OAuth scope filtering. It exposes authorized Todo resources and planning prompts. Todo tool results can advertise both MCP Apps and OpenAI widget resources; hosts without iframe support still receive JSON. `template://organization/documents/{fileId}` returns the first bounded JSON page of converted text. Continue through `document_read` with `nextCursor`. The `plan-from-document` prompt reads relevant pages and calls `draft_task_plan`; creating that proposal needs approval and its separate Apply step creates Todos. Document resources use the same membership and read-scope checks as tools. Files uploaded by external clients still need [browser text preparation](/docs/features/documents) before document tools can read them. ## Extend [#extend] Add shared operations in the catalog, retain scope filtering and the in-process JWKS lookup, and use the server builder per connection. A Worker HTTP self-fetch for JWKS is not equivalent. Verify protocol errors, revoked tokens, insufficient scope and approval binding. Do not advertise an experimental MCP server-card endpoint as a stable discovery requirement. ## Key files [#key-files] - [`packages/api/src/features/mcp/mcp.server.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/mcp/mcp.server.ts) - [`packages/api/src/features/mcp/mcp.guard.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/mcp/mcp.guard.ts) - [`packages/api/src/features/mcp/mcp.context.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/mcp/mcp.context.ts) - [`packages/auth/src/mcp-oauth.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/auth/src/mcp-oauth.ts) - [`apps/web/src/routes/api/mcp.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/routes/api/mcp.ts) # Mobile app Expo Router uses the shared domain API with native navigation and scoped local state. ## Application structure [#application-structure] `apps/mobile` uses Expo SDK 58, React Native and file-based Expo Router routes. Auth routes sit outside the protected tabs. The NativeTabs are Garage, Finances, Service and Profile; Profile contains Sessions, passkeys, Organizations and optional subscriptions. The app is dark-only and monochrome by default (`handoff/design/DESIGN.md`); [Appearance](/docs/features/appearance) adds Car colour and Warm, and every component takes its colours from `useAppTheme()`. The native design layer (`src/design`) builds on `@autofolio/tokens` and holds the DESIGN.md §5 components: pills, card, row, segmented control, toggle, bottom sheet, glass buttons, vehicle hero, garage card, empty state and tabular numbers. Icons are the handoff SVGs only. Hooks own mutations, auth transitions, dialogs and toasts. Keep route components focused on composition rather than duplicating transport policy. `src/` follows the backend's feature shape ([ADR-0013](/docs/adr/0013-mobile-features-mirror-the-backend-shape)): routes in `src/app` only return a screen, each feature lives in one flat `src/features//` folder (`.screen.tsx`, `.hooks.ts`, components and models), and cross-cutting code sits in `src/platform/` (rpc, auth session, i18n, feedback, theme). Screens and components read data only through hooks. `scripts/mobile-feature-boundaries.test.mjs` checks the direction rules in `pnpm test`. ## Backend and scope [#backend-and-scope] `EXPO_PUBLIC_SERVER_URL` selects the backend, with localhost:3005 as the local fallback. A physical device needs a reachable laptop LAN address. Better Auth's Expo client owns credentials; request scope captures User, Session and Organization. Organization transitions cancel stale waits and hide stale query results, including an A-to-B-to-A transition. Cached data is scoped to the observed identity and Organization. It is not authorization, and a late response from an old identity must not clear or overwrite the new one. Dev builds open any board with seeded data through `autofolio://dev/?lang=pt-BR|en-US|de-DE` (the dev variant answers both `autofolio-dev://` and `autofolio://`). The link signs into the demo account of that language through the server's dev-only `/api/auth/dev/seed-sign-in` (`DEV_SEED_LOGIN=true`, see [environments](/docs/development/environments)); a live session of that demo account, its own garage open, is kept instead of seeding and signing in again (the next link takes well under a second and adds no device), and each board opens over the tabs alone, at their start. Boards without a screen yet open a placeholder whose root carries `screen-`. Without `?lang` the link opens in en-US, the flows' locale. M-Novo-Gasto, and Add expense opened from M-Veiculo's link, start with the board's amount typed in (F2 has no typing step); the app's own form starts empty. Production bundles drop the dev screen. ## Native capabilities [#native-capabilities] RevenueCat requires its SDK in the native build and both billing gates. The template's push client and todo widget left with the task demo; reminders came back with the AutoFolio alerts, and the home-screen widget shows the next service ([widgets](/docs/packages/widgets)). Biometric app lock protects local presentation, not server permission. ## Extend and verify [#extend-and-verify] Use the shared API contracts, then expose behavior through a mobile hook and the existing design components. Test on a development build when adding native modules. TypeScript and Hermes export do not prove device permission dialogs, purchases, background push or store signing. The Expo SDK compatibility matrix owns native versions. Do not bump native modules merely because npm has a newer release; rebuild the dev client when the native dependency set changes. See [libraries and upgrade policy](/docs/libraries). ## Key files [#key-files] - [`apps/mobile/src/app/_layout.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/app/_layout.tsx) - [`apps/mobile/src/features/app-shell/app-shell.tabs.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/app-shell/app-shell.tabs.tsx) - [`apps/mobile/src/platform/rpc/orpc.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/platform/rpc/orpc.ts) - [`apps/mobile/src/platform/auth/organization-transition.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/platform/auth/organization-transition.ts) - [`apps/mobile/src/features/dev/dev.board.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/dev/dev.board.ts) - [`apps/mobile/src/design/tokens.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/design/tokens.ts) - [`apps/mobile/README.md`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/README.md) # Moments and share cards How the garage's Moments are found in records the app already has, announced each morning, and shared as a card made on the phone that is never uploaded. ## What it does [#what-it-does] A Moment is a fact of the household's garage worth sharing, found in its records and never invented (social sharing spec §1): a new car (an Ownership Period opens: added, "I bought it", an accepted History Transfer, a dealer's hand-over), a whole-year ownership anniversary, a distance milestone every 10,000 km or mi driven while owned (the account's unit), a sale (a Disposal), a [Cinematic Studio](/docs/features/studio) picture saved to a car's gallery, and the collection reaching 3, 5, 10, 25, 50 or 100 cars. Only the latest of each counts, so a car added with years of history brings one anniversary, not five. A Moment is new until someone in the household shares or dismisses it. The launch backlog is not: on a household's first find (the launch, or a garage filled with years of history), a Moment older than 30 days is written as `seen`, listed under "Earlier" but never counted as new and never pushed (`statusOnFind`). Sharing is a Share Card: an image made on the phone (story 9:16, post 4:5 or square 1:1) and handed to the system share sheet. Nothing is uploaded, no link is in the image, and plate, VIN and address never appear; the date is on, distance and the car's value are off by default, and a shown value carries its source, date and confidence (RN03). The flag `social.moments` gates the job's push and the app's screens. It is off by default (production stays off until QA passes); dev builds of the app show the screens anyway. ## Data and isolation [#data-and-isolation] `moment` belongs to the Organization: `kind`, `key` (the period, the Disposal, `:`, `::` or `cars:` or the Studio picture), `value`, `unit`, `photo_id` (a studio Moment's picture), `occurred_on` and `status` (`new`, `seen`, `shared`, `dismissed`). One row per Organization, kind and key, so finding a Moment again inserts nothing. A collection Moment has no car; row-level security shows it only to owners and admins, and a Member sees the Moments of the cars granted to them ([ADR-0009](/docs/adr/0009-row-level-security)). Tenant traffic inserts Moments and changes `status`, nothing else. Deleting a car, or a studio Moment's picture, deletes its Moments. The pure rules are `findMoments` in `moment.rules.ts`; `recordMoments` reads the facts (periods of current and sold cars, the first and highest reading of each open period, disposals, the gallery's Studio pictures) and inserts what is new. ## Procedures [#procedures] | Procedure | Who | Failures | | ----------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `moment.list` `{}` | every Member | none: first finds what is new, then lists every Moment not dismissed, newest first, with its car, a 15-minute cover link and a studio Moment's picture | | `moment.setStatus` `{ id, status }` | an editor of the car; owners and admins | `INPUT_VALIDATION` (`new` is not settable), `NOT_FOUND`, `FORBIDDEN` (a viewer) | ## Job [#job] The cron tick's `moments` job (`moment.jobs.ts`) runs during each data region's morning hour (07 UTC EU, 11 UTC BR, 14 UTC US), with `social.moments` on. It finds the Moments of every household whose owner's account is in the region (their distance unit) and pushes the ones from the last seven days, once per device, to each Member who allows push and sees that car: one Moment says what it is ("Novo na garagem · Toyota Corolla Cross chegou."), several say how many. The launch backlog is written as seen and never pushed. Sends are never retried; the Moments screen always lists them. The scan reads 50 households at a time in id order, pushes that page, and keeps its place for the day in `job_cursor` (`platform/jobs/day-cursor.ts`): the hour's later ticks go on from there and find nothing left once it is done; the next day starts over. A Moment that appears after its household was scanned is found the next morning (or when the Moments screen is read). ## App [#app] - **Social-Celebrate** (`screen-Social-Celebrate`, `/celebrate/`): opens after Add vehicle saves a car to the garage. The car's photo under the fade, the "New in the garage" pill, the model and "Your Nth car · the story starts today"; "Share this moment" (`celebrar-compartilhar`), "Record the purchase" (`celebrar-compra`, while the car has no purchase) and "Not now" (`celebrar-agora-nao`). A save from a dev link's example reading (the handoff's F1) still ends on the garage, as F1 asserts. - **Social-Card** (`screen-Social-Card`, `/share-card?momentId=…|vehicleId=…&template=…`): templates New car, Photo, Milestone and Collection (`cartao-modelo`), formats (`cartao-formato`), toggles date, distance, car value and Cover the plate (`cartao-date`, `cartao-distance`, `cartao-value`, `cartao-placa`; locked on, with no manual cover, for a Studio picture, whose plates the Studio blurred, and the card then says "AI-generated background, original car"), another photo of the gallery (`cartao-foto`) and Share (`cartao-compartilhar`). The preview is the image: `makeImageFromView` snapshots it, the JPEG goes to a cache file, the share sheet gets the file (React Native's on iOS, `expo-sharing` on Android, `platform/share/share-file.ts`) and the file is deleted after. A shared Moment is marked shared; Android's sheet does not say whether the person shared, so closing it counts. - **Social-Moments** (`screen-Social-Moments`, `/moments`): "New · N" and "Shared" (`momentos-segmento`), a row per Moment with its photo, title and kind · date, Share and, until shared, Dismiss, each with its spoken name; New ends with "Earlier", the seen backlog, then My collection (`momentos-colecao`), which opens the collection's card. Profile has a Moments row; the garage shows "N new moments" while some wait; My collection's share button opens the collection's card (its "Share as image" choice when `social.showcase` is on too). `autofolio://dev/Social-Celebrate`, `Social-Card` (the seeded M3) and `Social-Moments` open the boards; the seed's sales are already shared, the current cars' arrivals are the seen backlog, a Studio picture in the M3's gallery brings a studio Moment, and the rest are found on the first read. shortcut: on an original photo the plate cover is placed by the person (tap the photo where the plate is) until an on-device plate detector exists; a Studio picture needs none. ## Key files [#key-files] - [`packages/db/src/schema/moment.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/src/schema/moment.ts) - [`packages/api/src/features/moment/moment.rules.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/moment/moment.rules.ts) - [`packages/api/src/features/moment/moment.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/moment/moment.service.ts) - [`packages/api/src/features/moment/moment.jobs.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/moment/moment.jobs.ts) - [`apps/mobile/src/features/moment/moment.card.screen.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/moment/moment.card.screen.tsx) # Open Graph images Takumi renders typed React templates to PNG on Node and workerd. ## Request to image [#request-to-image] The OG registry pairs each template with its input schema and render function. The route validates query parameters, selects a supported locale and renders the configured image dimensions. Home, default, post and profile templates cover public pages. Node development uses the native Takumi renderer. Workerd uses the WASM renderer and bundled font data. The existing renderer adapter contains historical runtime-specific imports; documentation adds no new dynamic imports. ## React Compiler boundary [#react-compiler-boundary] OG components are called by the image renderer outside React's normal dispatcher. They use the `"use no memo"` directive so React Compiler does not insert hook-dependent memoization. ```tsx function ImageHeading({ title }: { title: string }) { "use no memo"; return
{title}
; } ``` Use renderer-supported layout and inline styles. Do not assume browser CSS, client hooks or arbitrary DOM APIs work inside an OG template. ## Add a template [#add-a-template] Define a bounded Zod parameter schema, add a registry entry and render with the shared frame. Connect it through the SEO helper so canonical image URLs use the intended origin. Test every locale with native rendering and verify the actual built Worker path; a Node PNG is not proof that the WASM path works. ## Key files [#key-files] - [`apps/web/src/platform/og/registry.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/platform/og/registry.ts) - [`apps/web/src/platform/og/render.server.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/platform/og/render.server.tsx) - [`apps/web/src/platform/og/render-wasm.server.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/platform/og/render-wasm.server.ts) - [`apps/web/src/platform/og/frame.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/platform/og/frame.tsx) - [`apps/web/src/platform/seo/og-url.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/platform/seo/og-url.ts) # Partner workshops and dealers How M-Oficinas and the partner portal D-Parceiros let an approved workshop verify a service and a dealer hand over a car, with the owner's consent and an audit trail. ## What it does [#what-it-does] A **Partner** is an Organization of a workshop or dealer that a platform admin approved (US44, US45, RF47, RF48). Only an approved Partner appears to owners, and only it can make a record "Partner-verified" (the domain rule: never "verified" without an auditable partner). Nothing is shared by default and no data is sold; there are no offers. - **Confirmation request.** An owner picks a workshop on M-Oficinas and one of a car's services. The request carries a copy of what the Partner sees: the car's make, model and year, the owner's first name and initial, the service's title, date, reading and workshop. Never the plate, VIN, an address or an amount (a CHECK refuses those keys). On D-Parceiros the Partner confirms it, and the service becomes `partner_verified` with `verified_by` (the Partner) and `verified_at`; or declines it, and the owner sees that. Correcting the service withdraws an open request; a verified service cannot be corrected. - **Hand-over.** A dealer prepares a car (catalog text and its reading at delivery) and the services it did before delivery. The portal shows a 16-character code once (Crockford base32, 80 bits, 30 days); only its SHA-256 is stored. The customer enters it in the app, sees exactly what accepting adds, and only then does the car join their garage, through the garage's own create (the Free plan's one-car limit counts it), with the dealer's services partner-verified at no cost to the new owner: the price is the purchase's. ## Data and isolation [#data-and-isolation] `packages/db/src/schema/partner.ts`: | Table | Owned by | Tenant traffic | | ------------------ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------ | | `partner` | the Partner's Organization (its key) | every session reads approved Partners; an Organization reads and inserts its own application, never approves | | `partner_request` | the household (`organization_id`) | the household and the addressed Partner read it; the household inserts and withdraws, the Partner declines | | `partner_handover` | the dealer's Organization | the dealer reads and inserts its own | Two steps cross Organizations, each on its own [system reason](/docs/adr/0009-row-level-security): `partner.verify` (confirming writes the household's service trust, which tenant traffic can never claim) and `partner.handover` (the customer reads a hand-over by its code's hash and accepts it). Confirming checks that the Partner is approved, the request is its own and pending, and that whoever sent it is not a Member of the Partner. ## Procedures [#procedures] | Procedure | Who | Failures | | --------------------------------------------------------------- | -------------------------------- | ---------------------------------------------------------------- | | `partner.directory` | every Member | none: approved Partners of the User's data region | | `partner.request` `{ id, requestId, partnerId }` | the car's owner role | `NOT_FOUND` (service, Partner), `CONFLICT` (open or verified) | | `partner.requests` `{ id }` | every Member | none: the service's requests, latest first | | `partner.portal` | every Member | none: the Partner (or application), pending requests, hand-overs | | `partner.submitApplication` `{ kind, name, city, region }` | owners and admins | `CONFLICT` (already applied) | | `partner.confirm` / `partner.decline` `{ id }` | Members of the Partner | `FORBIDDEN` (not approved, own Member's request), `NOT_FOUND` | | `partner.createHandover` | Members of an approved Partner | `FORBIDDEN` | | `partner.previewHandover` / `partner.acceptHandover` `{ code }` | every Member / owners and admins | `NOT_FOUND` (wrong, expired or used code), `PLAN_LIMIT` | | `admin.partners.applications` / `admin.partners.approve` | platform admins | `NOT_FOUND` | Applying, approving, sending, confirming, declining and creating a hand-over are audited (`partner.*`); an accepted hand-over records who accepted it and the car it became. A hand-over nobody accepted is deleted, with the customer's name, `HANDOVER_EXPIRED_KEEP_DAYS` (30 days) after its code expired, by the cron tick's `handovers` job (`partner.jobs.ts`, 100 a tick). ## Screens [#screens] - **M-Oficinas** (`screen-M-Oficinas`, `/workshops`) opens from Profile, or from a service's trust card on M-Servico ("Ask a partner workshop to confirm"), which then shows the request's state. The more button opens **Receive a car** (`/handover`, also `autofolio://handover?code=…`). - **D-Parceiros** (`screen-D-Parceiros`, `/partners`) is the signed-in portal of the Active Organization: requests to confirm, the hand-over form with the code and recent hand-overs, the application form for an Organization that is not a Partner yet, and a switch for a Member of another Partner. Admins approve applications at `/admin/partners`. `autofolio://dev/M-Oficinas` lists the seeded workshops. For the portal, `POST /api/auth/dev/seed-sign-in` with `{ "lang": "en", "as": "partner" }` signs in the first workshop's staff with the household's two requests waiting. ## Key files [#key-files] - [`packages/db/src/schema/partner.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/src/schema/partner.ts) - [`packages/api/src/features/partner/partner.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/partner/partner.service.ts) - [`apps/mobile/src/features/partner/partner.screen.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/partner/partner.screen.tsx) - [`apps/web/src/features/partner/partner.page.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/features/partner/partner.page.tsx) # Passports How Prepare sale (M-Vender) shares a car as a field-by-field snapshot behind a hashed link, how the buyer opens it on the web without signing up, and how Passports (M-Passaporte) shows visits, moves an expiry and revokes a link. ## What it does [#what-it-does] A Passport (RF39, US38) is what a seller shows a buyer: a copy of the sections the owner ticks, never a view of the account (RN08). Prepare sale offers five sections with their counts: Photos, Services and mileage, Anonymized receipts, Specs and Costs and values. The car's make, model and year always go; plate, VIN, address and garage location never can (RN09), and `materializePassport` from `@autofolio/domain` throws if one is ever asked for. The link is valid for 7, 30 or 90 days. "Create link" builds the snapshot (ADR-04 of the handoff) and stores it in `share_package` with: - the SHA-256 of a random 256-bit token (base64url); the link `/p/` is returned once and never stored; - the token's first eight characters as a hint, so the owner tells links apart (`autofolio.app/p/7Kq2wM9x…`); the other 35 keep 210 bits secret; - copies of the car's photos (at most 30, cover first) under `passports//`, named by position so a public URL carries no internal id, made with a server-side S3 copy before the row is written; a failure removes the copies already made; - a ★ `consent_record` row (`passport_share`, version `passport-2026-10`) for the owner who shared it. Receipts are shared as each service's date, shop and item names only. A receipt file can carry names and addresses that cannot be removed reliably, so no file and no amount leaves the account. Services carry date, title, reading and trust, never their cost or note. ## The view the buyer gets [#the-view-the-buyer-gets] `passportView(snapshot, expiresAt, sign)` in `passport.service.ts` turns a stored snapshot into `PassportView` (`passport.contract.ts`): the car, `validUntil`, and each section or `null`, with photos signed for 15 minutes. "Preview as buyer" (`passport.preview`) builds the same view for the current choice without storing or copying anything, so the preview is what the buyer gets. Distances are km; each reader's page shows its own unit. ## The buyer's page [#the-buyers-page] `/p/:token` in `apps/web` (D-Passaporte, US40, RF40) needs no account. Its loader calls `passport.open { token }`, a public procedure that runs as system code (`asSystem`, SystemReason `passport.open`): it reads a live package by the token's hash, never a live table, and writes one ★ `share_access` row per visit with the date and the country Cloudflare's edge resolved (`cf-ipcountry`, on Workers only). No address is stored with it; the address only meters openings in `rate_limit` (60 a minute, swept by the auth cleanup), past which the call answers `TOO_MANY_REQUESTS`. A revoked, expired or unknown token answers `null`, and the page shows the same neutral notice for all three (D-Passaporte-Revogado), so it never tells whether a link existed. A live one renders Summary (with this owner since, last mileage, services with a receipt, then specs and costs when shared, each estimate with source, date and confidence), Services (date, mileage, title and trust, then the anonymized receipts) and Photos, from the same `PassportView` as the owner's preview. Tabs the owner did not share are absent. The page speaks the buyer's language (`?lang=`, the site cookie or Accept-Language among pt, en and de), else the owner's account language. Dates and money follow that language; distance is in its region's unit (mi for en-US). Responses under `/p/` carry `X-Robots-Tag: noindex`, `Cache-Control: private, no-store` and `Referrer-Policy: no-referrer`, `robots.txt` disallows the path, and the log scrubber replaces any `/p/` with `[redacted]`. ## Procedures [#procedures] | Procedure | Failures | | -------------------------------------------------- | --------------------------------------------------------------------- | | `passport.prepare` `{ vehicleId }` | `NOT_FOUND` (car), `CONFLICT` (not in the garage) | | `passport.preview` `{ vehicleId, sections, days }` | `INPUT_VALIDATION`, `NOT_FOUND` (car), `CONFLICT` (not in the garage) | | `passport.create` `{ vehicleId, sections, days }` | `INPUT_VALIDATION`, `NOT_FOUND` (car), `CONFLICT` (not in the garage) | | `passport.list` `{ vehicleId?, limit? }` | `INPUT_VALIDATION` | | `passport.get` `{ id }` | `NOT_FOUND` (link) | | `passport.setExpiry` `{ id, days }` | `INPUT_VALIDATION`, `NOT_FOUND` (link), `CONFLICT` (revoked, cleaned) | | `passport.revoke` `{ id }` | `NOT_FOUND` (link); revoking a revoked link does nothing | | `passport.open` `{ token }` | `INPUT_VALIDATION`, `TOO_MANY_REQUESTS`; `null` when not available | All but `open` are for Members of the Organization; `open` is public. `list` and `get` answer each link's status (`active`, `expired`, `revoked`), views and last visit; `get` adds its latest 100 visits. ## Revoking [#revoking] Revoke deletes the copied photos, then sets `revoked_at` and drops the snapshot in one statement; a retry after a failure finishes the job. The row and its visits stay. A CHECK keeps a revoked package snapshot-free, and the tenant update policy lets a live package change only its expiry or be revoked; a revoked one never changes again. An expired link keeps its snapshot for `PASSPORT_EXPIRED_KEEP_DAYS` (30 days), so the owner can still extend it. Then the cron tick's `passports` job (`passport.jobs.ts`, 20 links a tick) deletes its photo copies and its snapshot, like a revoke; the link stays expired and its expiry no longer moves (`passportExtendable`, which also hides the row in the app). ## Data and isolation [#data-and-isolation] `share_package` and `share_access` are isolated by Active Organization; `share_access` and `consent_record` are append-only. No `audit_log` event is written: the consent record (who shared, when, which text) and the package's `created_by` and `revoked_at` are the trail, and `withAudit`'s transaction would hold the storage calls. ## App [#app] Prepare sale (`screen-M-Vender`, route `/prepare-sale/[vehicleId]`) opens from the vehicle profile. Its toggles carry the prototype's ids (`item-photos`, `item-services`, `item-docs`, `item-specs`, `item-costs`); "Valid for" opens a 7 / 30 / 90-day sheet; "Create link" (`criar-link`) shows the Link created sheet with the link, Copy link, Share and Passports. "Preview as buyer" opens `/passport-preview/[vehicleId]`. Passports (`screen-M-Passaporte`, route `/passports`) opens from Profile and from M-Vender. Each card shows the car, the link's hint and status, views and last visit, and Revoke (after a confirmation); a card opens the link (`/passports/[id]`) with its expiry, what it shares and every visit. The demo garage's M3 link carries copies of its three photos. In the English demo garage it opens at `/p/demo-token`, which flow F6 uses: `npx playwright test` (root `playwright.config.ts`, local only) seeds that garage through the dev seed sign-in, so the web app must run with `DEV_SEED_LOGIN=true`. `autofolio://dev/M-Vender` opens the seeded M3; `autofolio://dev/M-Passaporte` shows its seeded live link with seven visits. Flow F5 ticks the receipts and creates a link. ## Key files [#key-files] - [`packages/api/src/features/passport/passport.contract.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/passport/passport.contract.ts) - [`packages/api/src/features/passport/passport.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/passport/passport.service.ts) - [`packages/api/src/features/passport/passport.rules.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/passport/passport.rules.ts) - [`packages/db/src/schema/passport.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/src/schema/passport.ts) - [`packages/api/src/features/passport/passport.jobs.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/passport/passport.jobs.ts) - [`apps/web/src/features/passport/passport.view.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/features/passport/passport.view.tsx) - [`apps/mobile/src/features/passport/passport.prepare.screen.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/passport/passport.prepare.screen.tsx) - [`apps/mobile/src/features/passport/passport.list.screen.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/passport/passport.list.screen.tsx) # Payments Stripe, Polar and RevenueCat feed one server-verified entitlement model. ## Enable providers deliberately [#enable-providers-deliberately] All providers start off. Set `BILLING_PROVIDERS` to the required comma-separated names: `stripe`, `polar`, `revenuecat`. Each listed provider must have its complete valid configuration. `BILLING_ENVIRONMENT` distinguishes SANDBOX from PRODUCTION; Stripe key mode must agree. Stripe and Polar sell on the web. RevenueCat connects store purchases on mobile. If both web providers are enabled, `BILLING_CHECKOUT_PROVIDER` chooses new checkouts; existing customers retain their provider portal. Visibility separately uses `billing.web` and `billing.mobile`. ## Entitlements and webhooks [#entitlements-and-webhooks] Paid Access belongs to the User, not an Organization. The server's entitlement read model projects enabled-provider rows in the current environment. Device-reported purchases alone grant nothing. RevenueCat reconciliation replaces only its provider rows; Stripe and Polar apply signed, deduplicated events in transactions. | Provider | Webhook path | Server secrets | | --- | --- | --- | | Stripe | `/api/billing/stripe` | STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_IDS | | Polar | `/api/billing/polar` | POLAR_ACCESS_TOKEN, POLAR_WEBHOOK_SECRET, POLAR_PRODUCT_IDS | | RevenueCat | `/api/billing/revenuecat` | REVENUECAT_SECRET_API_KEY, REVENUECAT_WEBHOOK_AUTHORIZATION, REVENUECAT_WEBHOOK_SIGNING_SECRET | Configure RevenueCat app and entitlement IDs too. Public platform SDK keys belong in the mobile environment; server keys do not. Raw webhook verification precedes payload parsing. A replay is a no-op, and a failed write rolls back its dedupe record so the provider can retry. ## Extend and test [#extend-and-test] Add plans to the leaf `billing-plans.ts`, map provider product IDs in env, and add name/description translations. Checkout return URLs come from the server, never a client-supplied redirect. Test purchases in sandbox and verify `billing.status`, cancellation and webhook replay before enabling production visibility. Stripe and Polar currently rely on webhooks plus the grace window; no scheduled pull reconciles their subscriptions. RevenueCat verification has a freshness bound. Native purchases require a development build with the SDK, not just a successful TypeScript check. See [ADR-0004](/docs/adr/0004-payments-providers). ## Key files [#key-files] - [`packages/api/src/features/billing/billing.settings.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/billing/billing.settings.ts) - [`packages/api/src/features/billing/billing.webhook.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/billing/billing.webhook.ts) - [`packages/api/src/features/billing/billing.status.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/billing/billing.status.ts) - [`packages/api/src/features/billing/billing.plans.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/billing/billing.plans.ts) - [`apps/mobile/src/features/billing/billing.purchases-sdk.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/billing/billing.purchases-sdk.ts) - [`docs/billing.md`](https://github.com/CarlosZiegler/autofolio/blob/main/docs/billing.md) # Privacy centre P-Central, what I share with Revoke, consents per purpose and the right of access: what the app shows, where the counts come from, and how a no to AI turns every AI feature off. ## What it does [#what-it-does] With the `privacy.centre` flag (on in dev, off in production until QA), Profile shows one **Privacy** row instead of the privacy, data region, export and delete rows. It opens **P-Central** (`/privacy-centre`, [spec §1](https://github.com/CarlosZiegler/autofolio/blob/main/docs/specs/privacy-and-people.md)): - **Your data**: _See what we hold about you_ (`/privacy-data`) and _Export my data_ ([data export](/docs/features/data-export)). - **Sharing**: _What I share_ (`/privacy-shared`, P-Compartilhado) and _Consents_ (`/privacy-consents`, P-Consentimentos). - **Account**: security and devices ([sign-in and security](/docs/features/sign-in-security)), the data region (read-only; the EU says Frankfurt), delete account. - A footer with the privacy policy, its subprocessors section and the data protection contact (`EXPO_PUBLIC_PRIVACY_EMAIL`, else the support address; hidden without either). Each row's second line is live: the counts come from `privacy.summary`, the consents from the Account Preference. ## See what we hold [#see-what-we-hold] The right of access (LGPD art. 18 II, DSGVO art. 15) next to the export (portability, art. 20): seven categories with a count and what each holds. `privacy.summary` reads every count in one statement, each a scalar subquery under row-level security in the Active Organization: cars; records (ledger entries, services, odometer readings); files (photos and documents); the User's AI calls; live shares (passports and showcases not revoked or expired, invite links still waiting, the household's other people); the User's live sessions. The account itself counts as one. A Member who does not manage the household sees no invite links, as the invite screen does. ## What I share [#what-i-share] Every live passport, showcase and invite link, with what it shows (the passport's sections, the showcase's fields and live or frozen), since when and its views; then the household's people with their roles and cars. Revoke is one tap and a confirmation, through each link's own procedure (`passport.revoke`, `showcase.revoke`, `inviteLink.revoke`), so the snapshot and photo copies go as they always do. _Revoke all links_ asks once and revokes them all; people keep their access, which is changed in the [family garage](/docs/features/family-garage). ## Consents [#consents] One switch per purpose: AI features (on until the User says no), notifications (the `remindByPush` channel; turning it on asks the system once), usage analytics and news emails (off until the User says yes; no analytics SDK or newsletter ships yet). `accountPreference.consent` writes each answer as a ★ Consent Record with `PRIVACY_POLICY_VERSION` (push changes `remindByPush`) and audits it as `account.consent_changed`, target `consent:`, metadata `{ consent, policyVersion }`. `accountPreference.get` returns each purpose's latest answer in the same statement as the row. A no to AI turns every `ai.*` feature off for that User: `admitAi` answers `AiUnavailable`, the AI usage row lists no quota, the assistant is `NOT_FOUND`, the insights job pushes them nothing, and the app reads every `ai.*` flag as off (`useFlags`). Nothing already saved is deleted. ## Procedures [#procedures] | Procedure | Who | Failures | | --------------------------- | --------------------------- | --------------------------------- | | `privacy.summary` | Members of the Organization | — | | `accountPreference.consent` | Signed-in Users | `NOT_FOUND` before the first read | ## Key files [#key-files] - [`packages/api/src/features/privacy/privacy.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/privacy/privacy.service.ts) - [`packages/api/src/features/account-preference/account-preference.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/account-preference/account-preference.service.ts) - [`apps/mobile/src/features/privacy/privacy.hooks.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/privacy/privacy.hooks.ts) # Recurring expense How a scheduled cost (insurance, a loan payment, parking, a tax) becomes an expected entry the owner confirms into the ledger or skips, shown in M-Gastos and in the alerts. ## What it does [#what-it-does] A Recurring Expense (RF30, US14) schedules a cost on a car: insurance, a loan payment, parking, a tax or other, with an amount in the car's currency and a cadence. The cadence is `everyMonths` from an anchor date (`anchorOn`): 1 is monthly on its day ("every 10th"), 12 yearly on its date, anything up to 120 every N months. Every occurrence is counted from the anchor, so the 31st clamps per month and never drifts (`nextOccurrence` in `@autofolio/domain/reminder`). Nothing reaches the ledger by itself. The cron tick turns each due occurrence into an **expected entry**: a `reminder` row with `recurring_expense_id`, due that day and titled with the payee or the kind (in the author's language). So the alert list, push and email of [the reminder job](/docs/features/service) show it as they show any reminder. The owner then confirms it with the actual amount and date, which writes the ★ ledger entry, or skips it. Confirming books: - insurance, parking, tax, other: an Expense (category insurance, other, tax, other) whose ★ ledger entry shares the expected entry's id, so M-Gastos and the TCO count it; - a loan payment: `loan_interest` (a cost) and `loan_principal` (never an expense, RN05) ★ ledger entries, and the Loan's new ★ balance. The interest is a month at the Loan's latest balance and rate (an annual rate divided by 12), at most the payment; the owner can change it to match the statement. Without a rate the owner enters it. ## Procedures [#procedures] | Procedure | Who | Failures | | ------------------------------------------------------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `recurringExpense.list` `{ vehicleId? }` | Members of the Organization | — | | `recurringExpense.create` `{ id, vehicleId, kind, label?, amountMinor, everyMonths, firstDueOn }` | Members of the Organization | `INPUT_VALIDATION`, `NOT_FOUND` (car), `BAD_REQUEST` (not in the garage, a loan payment without a Loan), `CONFLICT` (id) | | `recurringExpense.update` `{ id, kind?, label?, amountMinor?, everyMonths?, firstDueOn? }` | Members of the Organization | `INPUT_VALIDATION`, `NOT_FOUND`, `BAD_REQUEST` (a loan payment without a Loan) | | `recurringExpense.delete` `{ id }` | Members of the Organization | `NOT_FOUND` | | `recurringExpense.expected` `{ id }` | Members of the Organization | `NOT_FOUND` (not open) | | `recurringExpense.confirm` `{ id, amountMinor, effectiveDate, interestMinor? }` | Members of the Organization | `INPUT_VALIDATION`, `NOT_FOUND`, `BAD_REQUEST` (car no longer owned), `CONFLICT` (skipped) | | `recurringExpense.skip` `{ id }` | Members of the Organization | `NOT_FOUND`, `CONFLICT` (confirmed) | `list` answers the schedules by next due and the open expected entries, oldest first, each with a loan payment's split. A first date in the past starts at its next occurrence from today; a new date or interval restarts the schedule the same way. Deleting a schedule deletes its expected entries; confirmed costs stay in the ledger. ## Tick and idempotency [#tick-and-idempotency] `pendingRecurringExpenses` (`recurring-expense.jobs.ts`) runs every minute: it reads at most 100 schedules of current cars due by today, inserts their expected entries (one per schedule and date, a unique index) and moves each schedule from the date it read to the next occurrence. A schedule far behind catches up an occurrence a tick. A sold car's schedule is no longer read. The client generates a schedule's id; replaying it answers what was saved. Confirming or skipping again changes nothing. ## Data and isolation [#data-and-isolation] `recurring_expense` has row-level security by Active Organization; tenant traffic can update only its kind, label, amount, interval and dates. Expected entries are `reminder` rows. No `audit_log` event: a schedule is not money of record, and a confirmed occurrence is a ★ ledger entry with its author (RNF12). ## App [#app] M-Gastos shows "Upcoming recurring" (`recorrentes`) under the categories: the schedule due first ("Insurance · every 10th", its amount), or how many payments wait to be confirmed. It opens Recurring expenses (`/recurring`): To confirm, Scheduled, and Add (`add-recorrente`). The form (`/recurring-form`) picks the type, payee, amount, Monthly / Yearly / Every N months and the first due date; "Loan payment" is offered only for a car with a Loan. An expected entry (`/expected-expense`, also from its alert on the Service tab) confirms the amount and date, with the interest of a loan payment, or skips it. `autofolio://dev/M-Gastos` opens the seeded M3 with its insurance on the 10th; the Porsche's parking waits to be confirmed under All cars. ## Key files [#key-files] - [`packages/api/src/features/recurring-expense/recurring-expense.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/recurring-expense/recurring-expense.service.ts) - [`packages/api/src/features/recurring-expense/recurring-expense.jobs.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/recurring-expense/recurring-expense.jobs.ts) - [`apps/mobile/src/features/recurring-expense/recurring-expense.screen.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/recurring-expense/recurring-expense.screen.tsx) # Document scanner How a car document photographed on the phone becomes a draft of its type, grounded in the text read, and is saved only when the person confirms. ## What it does [#what-it-does] The scanner (AI features spec §1) reads any car document and returns a draft of its type with a confidence and the line it was read from per field; nothing is saved before Confirm (RN10). | Document | Becomes | | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | Fuel receipt | a fuel expense and its odometer reading, the photo as its receipt | | Workshop invoice (up to 5 photo pages) | a service with its items and reading; printed taxes go in its note | | Insurance policy | the policy as a document (its end is a reminder) and the premium as a recurring expense | | Vehicle tax (IPVA, licensing, Kfz-Steuer) | the notice as a document; paid, an expense of the tax category; still due, a yearly recurring expense from its due date | | Purchase invoice | M-Compra's price, costs, down payment and amount financed (a recorded value changed needs a reason) | | Loan contract | the Loan (or the terms of the one the purchase opened) and its monthly payment from the next month | The flag `ai.capture` shows the scanner and lets the model help. Reading is on the phone (`expo-text-extractor`) and never metered; with the flag on and a structuring model configured, the text (never the photo) goes to `scan.structure`, which counts against the structuring quota ([AI foundation](/docs/features/ai)). ## Reading and grounding [#reading-and-grounding] `@autofolio/domain/scan` is the scanner's pure core: - `detectKind` names the document type from its words; the type the person picked always wins. - `readDocument` is the phone's own draft: a receipt's fields (`readReceipt`) plus, per type, the lines with a tax's name and due date, a policy's term and instalments, a contract's rate (yearly or monthly), term and payment, a workshop's item lines and taxes. - `groundScan` keeps only values printed in the text, each with its line, at medium confidence when the model filled it: a model can never invent an amount, a date or a name. An item whose amount is not printed keeps its name only. - `mergeScans` takes the surer value per field; the same value from the phone and the model is sure (0.9). `scanReady` says when every value a type needs is read surely. `scan.structure` sends the scrubbed text (no plate, VIN or email) to the model with a fixed schema, grounds the answer against the same scrubbed text and drops a document date after today. Off, rate-limited, slow or failing: `{ draft: null }` and the phone's draft stands; past the quota, `QUOTA`. | Procedure | Who | Failures | | ----------------------------------------------------------- | ------------ | ------------------------------------------------------------- | | `scan.structure` `{ kind?, text, locale, today, platform }` | every Member | `INPUT_VALIDATION` (empty or over 12,000 characters), `QUOTA` | ## Confirm [#confirm] Confirm writes through the features' own procedures. Fuel and a workshop invoice go through their offline queues (`useExpenseQueue`, `useServiceQueue`), so RF10's duplicate warning and RN07's lower reading answer as when typed (an old invoice is checked against the readings around its date on the server); the confirmed rows carry `ocr_draft_confirmed`. The rest need a connection: the original is filed first as the car's document, then the recurring expense, the Loan or the purchase. Every write has an id made with the draft, and an uploaded page is reused, so a retry never writes twice. Pages after the first are filed as the same document, "· 2/3". ## App [#app] - **Scan-Main** (`screen-Scan-Main`, `/scan?vehicleId=…`): One document or Shoebox (`modo-um`, `modo-caixa`), the document type (Detect automatically by default), the pages with Add page and remove, Take photo (`tirar-foto`) then Read document (`ler-documento`). Shoebox's Choose photos (`escolher-fotos`) reads each photo as one document and opens the queue. - **Scan-Draft** (`screen-Scan-Draft`, `/scan/draft?id=…`): the pages, the type (`tipo-doc`), each field (`campo-`) with "confidence high" or a check pill and the line it was read from highlighted under it, what Confirm will create, Confirm (`confirmar`), Save anyway after a duplicate warning, a reason when RN07 or a purchase correction asks, and Discard draft. - **Scan-Batch** (`screen-Scan-Batch`, `/scan/batch?vehicleId=…`): the drafts oldest first (readings then rise as they are saved), the ones read the same grouped under the first, a state per row (ready, check, same as above, needs you, saved, on this phone) and "Confirm the N ready ones" (`confirmar-prontos`). Ready means a fuel receipt, workshop invoice or tax notice whose needed values were read surely; the rest open one by one. M-Veiculo's More and M-Documentos' more open the scanner. `autofolio://dev/Scan-Main` opens it with the sample receipt as a page; `Scan-Draft` opens a workshop invoice's draft, `Scan-Batch` a shoebox of four (one fuel receipt twice), all read on the phone from fixed text. shortcut: PDFs and the share sheet are not imported yet: the phone reads text from photos only (Hermes has no WebAssembly for the PDF converter, and server conversion failed its memory gate, ADR-0008); a native PDF text module and a share extension come with the next dev-client build. The drafts live in memory for the session; persist them if owners scan long shoeboxes. ## Key files [#key-files] - [`packages/domain/src/scan.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/domain/src/scan.ts) - [`packages/api/src/features/scan/scan.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/scan/scan.service.ts) - [`apps/mobile/src/features/scan/scan.plan.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/scan/scan.plan.ts) - [`apps/mobile/src/features/scan/scan.confirm.hooks.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/scan/scan.confirm.hooks.ts) # Scheduled jobs One cron tick settles six concurrent jobs, then indexes Document Text within the same deadline and scoped pool. ## What runs [#what-runs] The Worker scheduled handler calls `runScheduledJobs` on the one-minute cron. The program runs audits, an hourly auth cleanup (rate-limit rows, sessions and verifications a day past expiry), imports, upload recovery, webhooks, billing, push and reminder alerts concurrently. Reminder alerts work during each data region's morning hour only, sending each due window once and never retrying a send ([alerts](/docs/features/service#alerts-us17-rf14)). Revaluations work during 03:00 UTC only and ask each enabled market-value provider for the current cars of its market; none is enabled yet ([reference values](/docs/features/garage#reference-values-rf38)). A typed failure or defect in one job does not interrupt its siblings. After these jobs settle, document maintenance and indexing run with their own 15-second budget inside the remaining global deadline. Indexing processes one document per tick; larger backlogs need multiple ticks. Failures become a single scheduled execution error identifying the failed jobs. Document conversion runs in a browser Web Worker. Cron validates and indexes submitted Markdown; it does not run AnyDoc. [ADR-0008](/docs/adr/0008-documents-and-large-uploads) records the memory evidence behind this ordering and the 192 KiB text cap. A global timeout before the earlier jobs settle skips document work. The tick acquires one job pool in an Effect scope. Its 25-second deadline interrupts work; fetch cancellation and drained database promises settle before the pool closes. An interrupted summary has empty counters, while durable rows and traces retain completed work. The Cinematic Studio runs beside the tick on the same pool with its own 150-second budget (`STUDIO_JOB_MS`): an image model takes longer than 25 seconds, and a scheduled Worker may run 15 minutes. ## Run locally [#run-locally] ```sh pnpm jobs:run # The cron's stand-in beside `pnpm dev:web` (Studio requests, imports, reminders): pnpm jobs:run --every 60 # Against a dev database that is not local (refused without the flag): pnpm jobs:run --every 60 --any-db # Production-runtime recovery and network-fault verification: node scripts/test-local-postgres.mjs exec node scripts/test-local-jobs.mjs ``` The CLI uses the configured database (`apps/web/.env`) and refuses one that is not on this machine unless you pass `--any-db`: every job runs, so pushes and emails go out from that database too. Use the disposable wrapper for tests. The workerd harness calls `/cdn-cgi/handler/scheduled` with `--test-scheduled` and uses a local R2 fixture and TCP fault proxy. It verifies replay and connection cleanup, not Cloudflare's production scheduling latency. ## Add a job [#add-a-job] Return an Effect from the new job, register it in the composed program and summary, and use the scoped database passed to it. Model durable admission, claim ownership, replay and terminal failure before adding retry. Keep external write attempts idempotent where possible; do not add in-round retries to sends. Test deadlines with `@effect/vitest` and TestClock using production values. Changes to queue persistence or deadlines must repeat the fault tests in [ADR-0006](/docs/adr/0006-effect-persisted-queue). ## Key files [#key-files] - [`apps/web/src/server.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/server.ts) - [`packages/api/src/platform/jobs/run.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/platform/jobs/run.ts) - [`packages/api/src/platform/jobs/failure.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/platform/jobs/failure.ts) - [`packages/db/src/job-db.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/src/job-db.ts) - [`scripts/test-local-jobs.mjs`](https://github.com/CarlosZiegler/autofolio/blob/main/scripts/test-local-jobs.mjs) # Services and reminders How a service is recorded into the ledger with its items, reading and private receipt, how a correction keeps its history, and how reminders are due by date or distance with a forecast from the car's readings. ## What it does [#what-it-does] A Service is a maintenance record (RF12): type, date, odometer, shop, items, note and a private receipt. Recording one writes, in one transaction: - a ★ `ledger_entry` of kind `operating`, category `maintenance`: the total in the car's currency (RN02), the shop as vendor, the odometer and the author; - the `service` row: title, date, odometer, shop, items (`{ name, amountMinor? }`), note, trust and receipt; - with an odometer value, a ★ `odometer_reading` with source `service` (RN07, as for an [Expense](/docs/features/expense)); - with a receipt, a `vehicle_document` of kind `receipt`, and the trust becomes `with_proof`; - with `reminderId` or `next`, the reminder it fulfils or schedules (below). The items only explain the total: priced lines add up to at most the total, and exactly to it when every line is priced. Zero is a valid total (warranty work). Only a car with an open Ownership Period takes a service. `service.create` answers `saved`, or `odometer_reason_required` with `previousKm` when the reading is lower than the one before its date (nothing written; send again with `odometerReason`). The client generates the `id`; a replay answers the first save (RNF04). ## Trust [#trust] `trust` is the record's confidence indicator: `declared`, `with_proof` (a file is attached; it does not prove the document is genuine) or `partner_verified`. Tenant traffic can never write `partner_verified` (row-level security on `service` and `ledger_entry`); only the partner path (v2) will. A verified service cannot be corrected by the User (`CONFLICT`). ## Corrections and history (ADR-01, RNF12) [#corrections-and-history-adr-01-rnf12] `service.correct` is the pencil on M-Servico. It writes a new `correction` ledger entry with the current values overridden by the given ones, naming the entry it corrects, with the `reason`; the original stays. The `service` row then points at the correction and takes the new title, items, shop and note. Items and total are corrected together. A changed odometer appends a reading, flagged with the reason when it is lower. Adding `receiptFileId` attaches a receipt later. `service.get` returns `history`: the original and each correction, oldest first, with `recordedAt`, `reason`, total, date, odometer and shop. "Edited once" is `history.length - 1`. ## Reminders (RF13, US16) [#reminders-rf13-us16] A Reminder is due by date (`dueOn`), by distance (`dueKm`), or both, whichever comes first. One with `everyKm`/`everyMonths` repeats: it is a plan item (M-Manutencao's Plan segment). `basis` explains the date: `date` ("Date you entered"), `mileage_forecast` ("Based on your average of …"), `interval` ("Manufacturer interval": accepted unchanged from the catalog's schedule, kept while the interval is not edited). `reminder.list` answers the car's open reminders, most urgent first, with: - `lastReading` and `kmPerMonth`: the car's last reading and its average distance per month over the year up to it (`averageKmPerMonth`; null before a month of readings); - per reminder, `remainingKm` from the last reading (negative when overdue), `forecastOn` (when the average reaches `dueKm`) and `nextOn`, the earlier of `dueOn` and `forecastOn`, which orders the list. The forecast is never stored; it is computed on every read by `forecastReminder` in `@autofolio/domain/reminder`, which the client may also use offline. ## Alerts (US17, RF14) [#alerts-us17-rf14] Reminders work without push. `reminder.alerts` is the in-app alert list across the household's current cars: every open service reminder, and a document's due date (US18) once it is due soon or past (an expiring document). `reminderUrgency` in `@autofolio/domain/reminder` gives each one its `urgency`: - `overdue` on facts only: the last reading is past `dueKm`, or `dueOn` is gone by; - `soon` when `nextOn` (the date, or the forecast from the last reading and the average distance per month) is within `DUE_SOON_DAYS` (30), a forecast already passed included; - `later` otherwise, a distance on a car without an average among them. The list comes overdue first, then due soon, then later (`byUrgency`), each with its car's `kmPerMonth`. Sold and wishlist cars have no alerts. The `reminders` job on the [scheduled tick](/docs/features/scheduled-jobs) sends the same alerts. During each data region's morning hour (07 UTC for the EU, 11 for Brazil, 14 for the US), every tick reads the alerts of households where a Member asked for push or email, and writes each one that is due soon or overdue to `reminder_notification` once per reminder, window and occurrence (its due date and distance then): a rescheduled reminder notifies again, a replayed tick never does. It then sends each Member who asked a push per registered device and one email digest with the new alerts, in their language (`reminder.messages.ts`), with the car's make and model and never a plate, VIN or amount. Sends are never retried: the in-app list always has them. `reminder_notification` is system-only, like `push_delivery`. A reminder created with only an interval is first due that far from today and the last reading. A distance-only interval on a car without a reading is `BAD_REQUEST`. ## Plan suggestions (US19, RF29) [#plan-suggestions-us19-rf29] `reminder.suggestions` offers a schedule for the car's Plan segment. When the car's Catalog Entry has a `service_plan`, it is the manufacturer's (`source: "manufacturer"`). NHTSA vPIC has no maintenance data, so today no entry has one, and the answer is AutoFolio's curated generic plan for the car's powertrain (`source: "generic"`, `GENERIC_SERVICE_PLANS` in `db/schema/garage.enums.ts`): combustion, hybrid or electric, from the catalog's fuel (`powertrainOf`). When the catalog does not know the fuel (`knownPowertrain: null`, a manual car), the client's `powertrain` picks the plan, combustion by default. The app always labels a generic plan as generic, never as the manufacturer's. Each item's `task` is a locale key (`af.oil_change`). - Accept: `reminder.create` with `planTask`. Accepted unchanged from the manufacturer's schedule, its basis is `interval`; edited, or from the generic plan, it is forecast like any other. - A task is not suggested again once any reminder of the car came from it (open, done or removed), or while it is dismissed: `reminder.dismissSuggestion` adds it to `vehicle.dismissed_plan_tasks`, `reminder.restoreSuggestions` empties it. - The app also hides a suggestion the plan already has under the same name. ### Scheduling from a service [#scheduling-from-a-service] - `service.create` with `reminderId` fulfils that reminder: a repeating one moves to its next occurrence from this service's date and odometer and points at this service; a one-off one is `done`. - `service.create` with `next: { everyKm?, everyMonths? }` schedules the next one (a new reminder titled like the service, or the fulfilled one with the new interval). - `service.scheduleNext` is M-Servico's "Next change scheduled" card: it creates, or moves, the reminder scheduled from that service. `service.get` returns it as `next`. ## Receipts: five-minute links (RF15) [#receipts-five-minute-links-rf15] The receipt is a file in the Organization's private storage. `vehicleDocument.url` returns a presigned link that expires after 300 seconds (`VEHICLE_DOCUMENT_URL_SECONDS`), with `expiresAt` and `mimeType`; ask again when it expires. Another Organization's document is `NOT_FOUND`. ## Procedures [#procedures] | Procedure | Failures | | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `service.create` `{ id, vehicleId, title, performedOn, shop?, odometerKm?, odometerReason?, items, amountMinor, note?, receiptFileId?, reminderId?, next? }` | `INPUT_VALIDATION`, `NOT_FOUND` (car, receipt file, reminder), `BAD_REQUEST` (no open ownership period; interval without a reading), `CONFLICT` (id taken) | | `service.correct` `{ id, correctionId, reason, …fields, receiptFileId? }` | `INPUT_VALIDATION`, `NOT_FOUND`, `CONFLICT` (id taken, partner-verified) | | `service.get` `{ id }` | `NOT_FOUND` | | `service.list` `{ vehicleId, limit ≤ 100 }` | `INPUT_VALIDATION` | | `service.scheduleNext` `{ id, everyKm?, everyMonths? }` | `INPUT_VALIDATION`, `NOT_FOUND`, `BAD_REQUEST` | | `reminder.list` `{ vehicleId, limit ≤ 100 }` | `NOT_FOUND` (car) | | `reminder.alerts` `{ limit ≤ 100 }` | `INPUT_VALIDATION` | | `reminder.create` `{ id, vehicleId, title, dueOn?, dueKm?, everyKm?, everyMonths?, planTask? }` | `INPUT_VALIDATION`, `NOT_FOUND` (car), `BAD_REQUEST`, `CONFLICT` | | `reminder.update` `{ id, title?, dueOn?, dueKm?, everyKm?, everyMonths? }` (`null` clears) | `INPUT_VALIDATION`, `NOT_FOUND`, `BAD_REQUEST` (no due left) | | `reminder.dismiss` `{ id }` | `NOT_FOUND` | | `reminder.suggestions` `{ vehicleId, powertrain? }` | `INPUT_VALIDATION`, `NOT_FOUND` (car) | | `reminder.dismissSuggestion` `{ vehicleId, task }`, `reminder.restoreSuggestions` `{ vehicleId }` | `INPUT_VALIDATION`, `NOT_FOUND` (car) | | `vehicleDocument.url` `{ id }` | `NOT_FOUND` | All are for Members of the Organization; a non-Member gets `FORBIDDEN`. Distances are always km; the client converts to the account's unit. ## In the app [#in-the-app] The Service tab (`features/service/service.screen.tsx`, M-Manutencao) opens on the current car with the most urgent alert, and the subtitle picks another car or "All cars", which lists every current car's alerts with the car's name. Upcoming shows the car's `reminder.alerts` (an expiring document opens M-Documentos) with the due line ("in 746 mi or 2 months": `remainingKm` in the account's unit, calendar months to `dueOn`, days under a month) and the basis line; Plan is the reminders with an interval, then the suggested schedule (`service.suggestions.tsx`): a row per item with dismiss and add (also VoiceOver actions), a tap opens the reminder form filled in to edit before adding, a powertrain row when the fuel is unknown, and "Restore" for dismissed ones; History is `service.list`. "Notifications off" shows while alerts cannot reach the device as pushes (the account's choice, the system permission or an unregistered device); a tap asks for push, or opens Settings once the system no longer asks. `usePushRegistration` (under the tabs) registers the device through `notifications.register` once both allow it. A reminder (`service.reminder.screen.tsx`) is edited, removed, or logged as done, which opens the new-service form with its `reminderId`. The form (`service.form.screen.tsx`) generates the id once, so the RN07 resend with `odometerReason` and a lost response never save twice; amounts are parsed to minor units in the account's locale. M-Servico (`service.detail.screen.tsx`) shows the items, the receipt through `vehicleDocument.url` (asked again before `expiresAt`), the trust level, the next reminder (`service.scheduleNext`) and the versions; the pencil and "attach receipt" send `service.correct`. ### Home-screen widget (US33) [#home-screen-widget-us33] The widget (`service.widget.ts`, synced by `useServiceWidgetSync` under the tabs) shows the household's most urgent service from `reminder.alerts` (a document's due date or an expected recurring expense is no service): "Next service", the item, the car's make and model and the same due line as the Upcoming card. The medium size on iOS, and the Android widget, add RF32's monthly cost: `expense.summary` for every car this calendar month, in the account's main currency. A tap opens that car's M-Veiculo on its Service segment (`autofolio://vehicle/?segment=service`); with no service planned it opens the app. Everything is formatted in the account's language and units before it is pushed. The background is the opaque `card` token, so Reduce Transparency changes nothing, and iOS type follows Dynamic Type. Signing out, or the `widgets.next-service` flag turning off, leaves only the brand; a plate, VIN or note never reaches the widget. See the [widgets package](/docs/packages/widgets). ## Data and isolation [#data-and-isolation] `service`, `reminder` and `vehicle_document` have row-level security by Active Organization; `ledger_entry` and `odometer_reading` are append-only for every role. `reminder.service_id` points at `(service, vehicle)`, so a reminder never names another car's service. No `audit_log` event is written: the ledger row is the money's audit trail, and amounts never go to logs (RNF07). ## Key files [#key-files] - [`apps/mobile/src/features/service/service.hooks.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/service/service.hooks.ts) - [`packages/api/src/features/service/service.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/service/service.service.ts) - [`packages/api/src/features/reminder/reminder.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/reminder/reminder.service.ts) - [`packages/api/src/features/reminder/reminder.jobs.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/reminder/reminder.jobs.ts) - [`packages/api/src/features/vehicle-document/vehicle-document.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/vehicle-document/vehicle-document.service.ts) - [`packages/domain/src/reminder.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/domain/src/reminder.ts) # Showcase links How a public showcase of one car or the garage is built from the passport's machinery, how Live mode keeps it current, and how the page at /g/:token shows it without plate, VIN or prices. ## What it does [#what-it-does] A Showcase ([social-sharing spec §2](https://github.com/CarlosZiegler/autofolio/blob/main/docs/specs/social-sharing.md)) is for showing cars, where a [Passport](/docs/features/passport) is for selling one. It is a public page of one car or of cars of the garage the owner picks, sold ones included (never a wished-for or archived car). It always shows make, model, year, the years with each car and the owner's first name; the owner adds photos and milestones (on by default), distance and the full name (off). Prices, plate, VIN, address, documents and receipts are never offered, and `materializePassport` from `@autofolio/domain` throws if a forbidden field is asked for. It reuses the passport's machinery rather than a copy of it: the 256-bit token, its SHA-256 as the only stored form and its eight-character hint (`passport.rules.ts`), photo copies under the link's own prefix (`showcases//.jpg`), the public page's rate limit (`consumeRateLimit`, 60 openings a minute per address) and one neutral answer for revoked, expired and unknown links. Creating one writes a ★ `consent_record` (`showcase_share`, version `showcase-2026-10`). ## The table [#the-table] `showcase` holds the scope (`vehicle` or `garage`), the cars' ids in the owner's order, the fields, `live`, the snapshot, a view counter, an optional expiry and `revoked_at`. Row-level security shows a link to the Member who made it and to the household's owners and admins; a Member inserts one only for cars they may see, and the procedure also needs the `owner` role on every car (`requireVehicleRole("owner", "vehicleIds")`). Tenant traffic may change `live`, revoke (which drops the snapshot) and rotate a replayed create's token; views and the live refresh are system paths. CHECKs refuse plate, VIN and address keys in the snapshot and a revoked row that keeps one. The snapshot (`ShowcaseSnapshot` in `showcase.rules.ts`) stores each car's Ownership Periods, so the page counts whole years to today or the sale, and its milestones: the day it joined, every whole year with it and its sale. ## Live mode [#live-mode] Off, a link stays as it was created. On, the cron tick (`pendingShowcases` in `showcase.jobs.ts`) rebuilds it at most every 15 minutes from the cars as they stand: a new gallery photo is copied in, a deleted one's copy is removed, new milestones appear. A car that left the household drops out. The same job removes an expired link's photo copies and snapshot; the row stays "expired". ## The public page [#the-public-page] `/g/:token` in `apps/web` (Social-Public) needs no account. Its loader calls `showcase.open { token }`, a public procedure on the system path (SystemReason `showcase.open`) that counts the view and answers the view or `null`. It shows "Showcase shared by" the owner's name, the garage in numbers, a card per car with its photos, years, distance when shared and milestones, the privacy line and "Start your garage". It speaks the visitor's language among pt, en and de, else the owner's. It is black and white unless the owner turned on "Colour on the showcase too" ([Appearance](/docs/features/appearance), `account_preference.showcase_color`, ADR-0016): then each car in the view carries its cover's colour, kept in the snapshot, and the page takes the first one that tints. Like `/p/`, responses under `/g/` carry `X-Robots-Tag: noindex`, `Cache-Control: private, no-store` and `Referrer-Policy: no-referrer`, `robots.txt` disallows the path and the log scrubber redacts the token. ## Procedures [#procedures] | Procedure | Failures | | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | | `showcase.create` `{ id?, scope, vehicleIds, fields, live, days }` | `INPUT_VALIDATION`, `FORBIDDEN` (not owner of a car), `NOT_FOUND` (car), `CONFLICT` (wished-for or archived car) | | `showcase.list` `{ limit? }` | `INPUT_VALIDATION` | | `showcase.setLive` `{ id, live }` | `NOT_FOUND` (link), `CONFLICT` (revoked or expired) | | `showcase.revoke` `{ id }` | `NOT_FOUND` (link) | | `showcase.open` `{ token }` | `INPUT_VALIDATION`, `TOO_MANY_REQUESTS` | ## In the app [#in-the-app] Behind the `social.showcase` flag (off by default; the dev seed turns it on): M-Colecao's share button (its "Public link" choice when `social.moments` is on too) and Profile open Showcase links (Social-Showcase, `autofolio://dev/Social-Showcase`), with each link's views, Live switch and Revoke, and "New showcase link". M-Veiculo's more sheet shares one car. The link is shown once, in the passport's Link created sheet. # Sign-in and security P-Entrar's passkey-first sign-in, Apple and Google setup, P-Seguranca, new sign-in alerts, the app switcher cover and the household's 2FA requirement for sharing. ## What it does [#what-it-does] Privacy spec §3 ([spec](https://github.com/CarlosZiegler/autofolio/blob/main/docs/specs/privacy-and-people.md)), no flag: - **P-Entrar** (`/sign-in`): Continue with passkey (the white pill), Continue with Apple and Continue with Google when the server has their credentials, then _Use email and password_, which opens the email form (`/sign-in-email`). On iOS Google shows only beside Apple (App Store guideline 4.8); Apple shows only on iOS. A device without passkeys makes the email link the white pill. Sign-up keeps Apple, Google and email. - **P-Seguranca** (`/security`, from P-Central and Account): passkeys by name, two-step verification, _Apple and Google_ (the linked accounts, `/linked-accounts`), signed-in devices with the last new device or country, the new sign-in alert switch, this device's Face ID lock (iOS) and _Hide amounts in the app switcher_, and for the household's owners and admins the 2FA requirement for sharing. ## New sign-in alerts [#new-sign-in-alerts] Better Auth's `session.create` hook records each User's device (`deviceLabel`: browser and system, e.g. "Safari · macOS", or "iOS"/"Android" for the app) and country (`cf-ipcountry`, on Workers only) in `sign_in_device`, one row per pair. A new pair after the first sends the alert email in the account's language (`@autofolio/email/sign-in-alert`): device, country, time, and where to sign that device out. The first sign-in never alerts, and `accountPreference.signInAlerts` (on by default) turns the email off. No IP address or city is stored or sent. ## The 2FA requirement [#the-2fa-requirement] `organization.require_second_factor`, off by default, is switched by owners and admins with `security.setRequirement` (audited as `org.second_factor_changed`, metadata `secondFactor`). While it is on, `requireSecondFactorToShare` (`platform/rpc/require-second-factor.ts`) answers `SECOND_FACTOR_REQUIRED` to a User with neither two-step verification nor a passkey on `passport.create`, `showcase.create`, `inviteLink.create` and `vehicleMember.set` when it grants a car (taking access back is never blocked). The app's error toast for that code opens Security; P-Seguranca shows a notice while the User lacks both. A solo User is never asked unless they turn it on. ## App switcher cover [#app-switcher-cover] A device setting like the Face ID lock (`app-lock.switcher.ts`, SecureStore): while it is on, `SwitcherCover` covers the app whenever it is not active, so the iOS switcher's snapshot shows no amounts. Off by default; not offered on web. ## Apple and Google setup [#apple-and-google-setup] The buttons stay hidden until the server has credentials: `appConfig.get` returns `socialProviders`, the providers whose id and secret are both set (`packages/auth/src/social-providers.ts`). Per environment (local, preview, production), set these as Worker secrets or in `apps/web/.env`: | Variable | Value | | ----------------------------- | -------------------------------------------------------------------------------------------------- | | `GOOGLE_CLIENT_ID` | OAuth client of type _Web application_ (Google Cloud console → Credentials) | | `GOOGLE_CLIENT_SECRET` | That client's secret | | `APPLE_CLIENT_ID` | The Services ID (e.g. `io.zietec.autofolio.signin`) | | `APPLE_CLIENT_SECRET` | The client secret JWT signed with the Sign in with Apple key (`.p8`); it expires within six months | | `APPLE_APP_BUNDLE_IDENTIFIER` | The iOS bundle id (`io.zietec.autofolio`), so a native Apple ID token is accepted | Redirect URIs to register, one per origin the server answers on (`BETTER_AUTH_URL`): - Google, _Authorized redirect URIs_: `https:///api/auth/callback/google` (and `http://localhost:3005/api/auth/callback/google` for local). - Apple, the Services ID's _Return URLs_: `https:///api/auth/callback/apple`; its _Domains_ list the origin's host. Apple refuses `localhost`: test it on a preview or a tunnel. The app needs nothing per provider: Better Auth's Expo plugin opens the provider in an auth session and returns to the app scheme (`NATIVE_APP_SCHEME`, trusted through `nativeTrustedOrigins`). Apple posts back from `https://appleid.apple.com`, which `social-providers.ts` adds to the trusted origins. An Apple or Google sign-in with an email that already has an account links to it when the provider vouches for the email (`account.accountLinking`, no trusted providers); a User links or unlinks one later from Security → Apple and Google, and can never unlink the last way to sign in. ## Procedures [#procedures] | Procedure | Who | What | | ------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `security.overview` | Member | Signed-in devices, the last new device (country named in `locale`), the requirement, whether the User holds a passkey, whether they manage the household | | `security.setRequirement` | Owner or admin, audited | Turns the household's 2FA requirement on or off | ## Key files [#key-files] - `packages/api/src/features/security/` and `packages/api/src/platform/rpc/require-second-factor.ts` - `packages/auth/src/sign-in-device.ts`, `packages/email/src/sign-in-alert.ts`, `packages/db/src/schema/security.ts` - `apps/mobile/src/features/auth/auth.entry.screen.tsx`, `apps/mobile/src/features/settings/settings.security.screen.tsx`, `apps/mobile/src/features/settings/settings.linked-accounts.screen.tsx`, `apps/mobile/src/features/app-lock/app-lock.switcher-cover.tsx` # Spreadsheet import How an owner's old spreadsheet (CSV) becomes ledger entries: the mapping and preview on the device, the leased import job, RF10 duplicates and an undo that deletes nothing. ## What it does [#what-it-does] Profile → Import spreadsheet (US42, RF43) brings an owner's history in from a CSV. The app reads the file on the device, detects which column holds the date, amount, currency, category, vendor, odometer, note and car, and previews the first lines with what is wrong with each. Import uploads the file and queues an Import Run of kind `ledger`; the leased import job (`imports.jobs.ts`) writes the readable lines as expenses. Undo reverses the whole batch. ## One reader for the preview and the job [#one-reader-for-the-preview-and-the-job] `@autofolio/domain/spreadsheet` is client-safe and holds every rule, so the preview is what gets imported: - `parseCsv`: RFC 4180 with the header line's delimiter (`,`, `;` or tab), a BOM and CRLF; - `detectColumns`: header words in Portuguese, English and German, whole words first; - `detectDateOrder` and `parseDate`: ISO dates, or day-first and month-first by the first day above 12, else the account's locale; - `parseAmount`: `1.234,56`, `1,234.56`, `R$ 89,90` as integer minor units (RN02); blanks, zero and negatives are refused; - `parseCategory`: the categories by their words, anything else is `other`; - `readRows`: each line as a draft or its problems (`date`, `amount`, `currency`, `vehicle`, `odometer`, `duplicate`). A line names its car by nickname, make and model, or model; a line without one takes the car chosen in the app. Its currency must be the car's (a foreign amount needs a rate, RF23). A line repeating an earlier one, or an expense the ledger already has with the same amount, date and vendor, is a likely duplicate (RF10, `findLikelyDuplicate`). Every line with a problem is skipped and reported, never imported silently. ## Procedures [#procedures] | Procedure | What it does | | --------------------- | ----------------------------------------------------------------------------------------------------------------- | | `imports.startLedger` | Records an Import Run of kind `ledger` for an uploaded CSV (`fileId`) with its mapping; idempotent by request key | | `imports.known` | The expenses of some cars between two dates: what the preview's duplicate check reads | | `imports.list` | Recent runs of one kind (`todos` by default) with their counts, skipped lines and `undone` | | `imports.undo` | Reverses a finished ledger run; only its importer or an owner or admin of the household | ## The job [#the-job] The run's mapping is validated again by `importLedgerOptions`. The job writes as the run's creator (`tenantStep`), on the cars they may log on: with an ownership period, and with an `editor` or `owner` grant unless they manage the household ([family garage](/docs/features/family-garage)). It reads the cars' standing expenses in the file's date range, then in one transaction inserts a `ledger_entry` per line (source `import`, `import_run_id` the run, the period holding its date) and its `expense` row, appends the odometer readings that are not lower than the one before their date (RN07), and marks the run succeeded with `inserted_count`, `skipped_count` and the first 200 skipped lines. At most 2,000 lines and 1 MiB per file. ## Undo [#undo] Undo appends, for each expense of the run that still stands, a `correction` entry to zero with the reason, source `import` and the run's id, and points the expense at it. An expense whose current entry is such a correction no longer stands: M-Gastos, M-Historico and duplicate checks leave it out, and raw ledger readers that follow corrections count it as zero. The originals stay in the ledger (ADR-01); undoing twice changes nothing. ## Key files [#key-files] - [`packages/domain/src/spreadsheet.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/domain/src/spreadsheet.ts) - [`packages/api/src/features/imports/imports.ledger.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/imports/imports.ledger.ts) - [`packages/api/src/features/imports/imports.contract.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/imports/imports.contract.ts) - [`apps/mobile/src/features/imports/imports.screen.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/imports/imports.screen.tsx) # Cinematic Studio How an ordinary photo of the car gets a cinematic background and light while the car's own pixels are checked against the original, its plate blurred and the original kept. ## What it does [#what-it-does] From a gallery photo (a street, a parking lot, the plate visible), the Studio replaces only the background and the light (AI features spec §2). The person picks a scene chip (rooftop at sunset, night garage, mountain road, rainy waterfront, black studio, underground car park) and a format (cover 4:3, hero, story 9:16, 1:1); three variations follow. Each variation keeps the car: the model is asked to change nothing of it, and every answer is checked against the original before anyone sees it. The plate is always blurred, the picture carries an "AI-edited" badge and a link to its original, and the original is never replaced. The Studio never removes scratches, dents or dirt: the prompt forbids it, the fidelity check rejects a changed car, and there is no free-text prompt to ask for it. `ai.studio` gates it, with an image model in `AI_MODELS` (`studio`) and its provider's key ([AI foundation](/docs/features/ai)). The locate step uses `studioLocate` (any text model with vision; it falls back to `AI_PROVIDER`/`AI_MODEL`). ## Pipeline [#pipeline] 1. **Locate** (`studio.create`, on the request): the photo goes to the vision model with a fixed schema: the car's box and every plate's box in thousandths. A car smaller than 4 % of the photo, or none, is `BAD_REQUEST` and counts nothing. Only then does the request count **one** Studio image against the plan's quota (`QUOTA` past it), and it is queued. 2. **Generate** (the cron tick's `studio` job, one request per tick): three edits of the original in parallel (`generateImage` with the photo as an image part: OpenAI's edit endpoint, Gemini's native image models), each with a 90 s deadline (`STUDIO_CALL_MS`; one gpt-image-2 edit took 18 s alone, three in parallel take longer) inside the Studio's own 150 s of the tick (`STUDIO_JOB_MS`), and never retried. 3. **Fidelity check** (`studio.fidelity.ts`): the original's car core is found in the answer by sliding its brightness over the picture at sizes from 0.5× to 1.6× (another format or framing moves the car). The comparison keeps only the car's pixels: a k-means model of the ring around its box marks the background. Structure is the normalised correlation of the car's **details** (brightness minus its local mean: body edges, wheels, windows, lights), matched finely around that place: the scene's light falling off across the body and its reflections barely move it, a redrawn wheel or another car's lines do. Colour is the mean CIELAB a*b* distance (a warmer light moves it a little, a repaint a lot). Thresholds live in `FIDELITY` (`studio.rules.ts`): structure at least 0.45 (faithful gpt-image-2 answers scored 0.54–0.76, other cars 0.33 at most), colour at most 18. Each variation's numbers (and nothing of the car or plate) are logged as `studio.fidelity`. 4. **Plate blur**: every plate on the car, grown by 35 %, is placed where the check found the car and blurred pixel by pixel (three box-blur passes, radius half the plate's height). The picture is written as a PNG without metadata. 5. **Store**: each kept picture becomes a private uploaded file of the requester (`orgs//studio//…`) and a `studio_variation`. With none kept, the request fails (`fidelity` when answers came back but changed the car, else `provider`). No second round runs on its own: it would double the paid calls behind one Studio image without the person asking. The result screen offers "Try another scene", back to Studio-Pick, which shows the Studio images left before the next Generate. Pixels are handled without a native library: the original is read at 1/8 scale from its JPEG (the cover-colour decoder, EXIF orientation applied), the answer as a PNG through `DecompressionStream`. shortcut: a JPEG answer is discarded as unreadable; add a decoder if a chosen model only answers JPEG. ## Quota [#quota] One request is one Studio image (Free 3, Plus 60 a month), whatever the variations: three pictures to choose from are one try, and at most three image calls (a tick interrupted mid-generation retries the request, at most `STUDIO_ATTEMPTS` claims). A request that ends without variations still counts, as every provider call is billed (ADR-0017); the next try is the person's own choice on Studio-Pick, with the images left in view. ## Data and isolation [#data-and-isolation] `studio_request` (photo, scene, format, `regions`, `state` `queued`/`ready`/`failed`, `failure`, `rounds`, lease) and `studio_variation` (its uploaded file and position) belong to the Organization and the car; row-level security shows them to the Members who see the car. Tenant traffic only reads and queues; the job writes on the system path. `vehicle_photo.ai_edited` and `original_photo_id` mark a saved Studio picture; deleting the original keeps the picture and its badge. Requests older than 7 days are deleted with the files nobody saved. A passport carries Studio pictures only when the owner ticks "Studio photos" on M-Vender, each labelled "AI-generated background · original car". Overlays are never stored: the gallery keeps the clean picture, so no lettering or emblem reaches a passport or a showcase. ## Procedures [#procedures] | Procedure | Who | Failures | | ------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `studio.create` `{ id, scene, format, platform }` | an editor of the car; owners and admins | `BAD_REQUEST` (no car), `QUOTA`, `TOO_MANY_REQUESTS`, `NOT_FOUND` (off, no model, not the household's photo, a Studio picture), `FORBIDDEN` | | `studio.get` `{ id }` | every Member who sees the car | `NOT_FOUND` | | `studio.latest` `{ vehicleId }` | every Member who sees the car | none: the newest request or null | | `studio.save` `{ id, cover }` | an editor of the car | `CONFLICT` (gallery full, US05), `NOT_FOUND`, `FORBIDDEN` | ## App [#app] - **Studio-Pick** (`screen-Studio-Pick`, `/studio-pick/?vehicleId=`): the photo, scene chips (`studio-cena-`), format (`studio-formato`), what changes and what stays, the Studio images left, and Generate (`studio-gerar`). From M-Galeria's Studio button (`galeria-studio`). - **Studio-Result** (`screen-Studio-Result`, `/studio/`): progress while queued (about a minute; the gallery's `galeria-studio-ultimo` row reopens it), the failure and "Try another scene", then Studio | Original (`studio-comparar`), the variations, "Model name lettering" (`studio-letreiro`), "Brand emblem" (`studio-emblema`, only with `ai.studio.brand-emblem`), "Save and use as cover" (`studio-capa`), Save to gallery (`studio-salvar`) and Share (`studio-compartilhar`). **Ap-Studio-Emblema** is this screen with its overlays (`?lettering=1`). - **Studio-Story** (`screen-Studio-Story`, `/studio-story/`): the variation as a 9:16 story with the chosen overlays and the badge, made on the phone and shared like a [share card](/docs/features/moments). The brand emblem is a vetted SVG per make from a licensed set, overlaid by the app, never generated; the set is empty until a legal review, so the switch says no emblem is licensed yet. `autofolio://dev/Studio-Pick`, `Studio-Result`, `Studio-Story` and `Ap-Studio-Emblema` open on the seeded M3 request (its variations are the M3's photos as stand-ins). ## Blind model test [#blind-model-test] The model is chosen by a blind test on 30 real photos; car fidelity beats background beauty: ```sh pnpm --filter @autofolio/api studio:blind-test \ --models gemini:gemini-3.1-flash-image,openai:gpt-image-2 --scene night_garage --format cover ``` It writes `sheet.html` (each photo with the candidates as A, B, C in a fresh order, rated 1–5 for the car, then the scene), `fidelity.csv` (the check's structure and colour per answer, to tune `FIDELITY`) and `key.sealed.json` (which model is which letter; open it after rating). Run it only with providers the owner approved for real photos. It also writes `regions.json` (the locator's boxes), so the answers can be checked again offline, for free, after changing `FIDELITY`: ```sh pnpm --filter @autofolio/api studio:fidelity-replay ``` The replay checks every original against every picture in the folder: its own answers (`-