# CLAUDE.md

Loyalty platform: API-first loyalty engine embedded into the company's products and sold as SaaS.
Plan: `docs/roadmap.md` (work stage by stage, update statuses there). Decisions: `docs/adr/`.
Research behind the product: `docs/research/report.md`. The team reads Russian: docs and UI texts are in
Russian, code and identifiers in English.

## Environment (Windows + Laragon)

Every PowerShell session starts with `. .\scripts\dev\env.ps1` (PHP 8.5, Composer, Node 22, PostgreSQL 17
first in PATH). Never switch Laragon's global PHP version: other projects depend on it.

- `composer check` — Pint, Larastan (level 8, plus the PHP SDK at PHP 8.2), Deptrac, Pest, the TypeScript SDK tests
  (Node 22.18+). Must be green before a stage item is done.
- `composer test`, `composer lint`, `composer fix`, `composer analyse`, `composer deptrac`.
- `composer migrate` — migrations run as the schema owner (`--database=pgsql_owner`), never as the app role.
- `.\scripts\dev\postgres.ps1 start|stop|status|psql`; roles and databases: `php artisan db:bootstrap`.
- Horizon/Octane need `pcntl` (Linux only); they are verified in CI/Docker, not locally.
- A local staff account: `php artisan staff:create <email> --name=...`, then `php artisan staff:grant <email> --platform
  --role=admin` (or `--merchant=<id>` / `--partner=<id>`); sign in at `/console` or `/merchant` and set up the
  authenticator app. Or `php artisan db:seed --class=LocalCabinetSeeder` (local only, idempotent): an operator and a
  demo merchant's owner with the password in that file. Local `.env` has `SESSION_SECURE_COOKIE=false` (http) and no
  `CONSOLE_DOMAIN` (required outside development). Filament's assets are published by `php artisan filament:assets`
  (composer post-autoload-dump) and are not in git.

## Architecture rules

- Modules live in `app-modules/<name>` (`Modules\<Name>`). Other modules may use only
  `Modules\<Name>\Contracts\*` and `Modules\<Name>\Events\*`; the kernel is usable everywhere. New module:
  `php artisan make:module <name>`, `composer update modules/<name>`, add it to `deptrac.php`.
- No Eloquent relations across modules; reference by id and go through a contract.
- Tenant-owned table: `TenantSchema::tenantKey($table)`, `TenantSchema::foreignWithinTenant(...)` for
  references, `Rls::enable('<table>')` after `Schema::create`; unique indexes start with `tenant_id`.
  Model extends `Modules\Kernel\Database\TenantModel`. Append-only tables: `Immutability::appendOnly()`,
  immutable columns: `Immutability::columns()`. Never edit a committed migration; add a new one.
- Points change only through `Modules\Ledger\Contracts\Ledger` (ADR-0003). Query-builder rows are read
  with `Modules\Kernel\Database\Row`; timestamps are written with `Modules\Kernel\Time\SqlTime`.
- Money and points are integers in minor units (`Money`, `IntMath`, `Allocator`); floats are forbidden.
- Rules reach receipts only through published snapshots (`RuleSets::effective`); every change that affects
  receipts (base rules, campaign publication or status) goes through `SnapshotPublisher` under the program lock.
  The engine (`Modules\Rules\Contracts\Calculator`) is pure: no database, clock or randomness. A change of
  its behaviour updates the golden vectors in `app-modules/rules/tests/golden` (expected values computed by
  hand, see the README there) and the CHANGELOG.
- Current time only via `Modules\Kernel\Time\Clock`; timestamps are `timestamptz` (precision 6).
- API errors: throw a subclass of `ProblemException` with a stable `code`; never return ad-hoc error JSON. A new code
  goes into `x-problem-codes` of the spec of every API that can answer with it (checked against every answer in tests
  and against the sources by `tests/Architecture/ProblemCodeCatalogTest.php`). A code no API answers with yet goes
  into `Tests\Support\ProblemCodes::INTERNAL` with the reason.
- The API specifications are the source of truth: `Tests\TestCase` validates every `/api/v1` response against
  `docs/api/runtime-v1.yaml` and every `/api/management/v1` response against `docs/api/management-v1.yaml` (and
  successful JSON request bodies), and an architecture test compares the routes of each API with its operations.
  Change the spec together with any endpoint; test-only routes live under `<api prefix>test/`. In YAML flow
  mappings (`{ ... }`) quote descriptions that contain commas.
- Every Management API route group runs `management.audit` (`:reads` where responses carry personal data or codes),
  `management.auth`, `throttle:management`, `management.scope:<scope>` and, for a merchant's data,
  `management.merchant`, in this order; a new route name goes into `AuditAction` of the spec
  (`tests/Architecture/ManagementAuditTest.php`). Cabinets write to the same log through
  `Modules\Audit\Contracts\AuditLog` (ADR-0010).
- Sandbox (ADR-0012, `LOYAL_SANDBOX=true`, a separate deployment): a contract that reaches real people or money
  (messages, payments, documents) extends `Modules\Kernel\Sandbox\RealWorldEffect` and its module registers a double
  with `Sandbox::double()`; code that accepts a member's phone or e-mail calls `Sandbox::assertTestPhone()` /
  `assertTestEmail()` (staff e-mails are real in the sandbox too, ADR-0013). Credential formats (with the `test_` marker) live in `Modules\Access\Internal\CredentialFormat`.
- SDKs (ADR-0011): after any spec change run `composer sdk:generate` and commit the result; never edit
  `sdk/php/src/Generated` or `sdk/typescript/src/generated`. The generator is a closed world: a new schema construct
  needs the generator extended first (`sdk/generator/README.md`). Both SDKs implement the contract of `sdk/README.md`,
  pinned by `sdk/conformance` vectors: a behaviour change updates the contract, a vector and both SDKs. The PHP SDK
  targets PHP 8.2 (no `mb_trim`, typed constants, `#[\Override]`; its own Pint config) and uses no platform code; the
  TypeScript SDK uses erasable syntax only and no runtime dependencies.
- Staff (ADR-0013, module `identity`): accounts and e-mails only through `Modules\Identity\Contracts\StaffUsers` (the
  e-mail is encrypted with a `platform`-scope blind index and leaves the module masked); access is decided per request
  by `StaffAccess`, which never touches the tenant context; team rules (last owner, own membership,
  `team.manage_owners`) live in the membership services, not in the UI; platform operators hold no team memberships.
  Mail to staff is a queued notification, encrypted (`ShouldBeEncrypted`), after commit, in Russian (`locale('ru')`);
  a link in a mail is built from APP_URL (`Modules\Cabinet\Kit\PanelHosts::forMail`), never from the request's host;
  text someone else typed goes into a mail through `MailText::literal()`. People join teams by `Invitations` (accepted
  through the team's own service on behalf of the inviter); tests read the link from
  `Notification::assertSentOnDemand(InvitationMail::class, …)`.
- Cabinets (ADR-0014, module `cabinet`): Filament pages, schemas and custom-data tables over module contracts; no
  Filament resources over other modules' models. The merchant comes from the URL as `MerchantWorkspace` (never queried);
  `ApplyMerchantContext` sets the tenant context on every page and Livewire request. Every cabinet capability is a
  contract method that validates its input (parity with the APIs). Merchant and staff texts are rendered escaped only.
  Middleware that must also guard Livewire updates is registered persistent (Livewire matches it by class). Livewire
  polling (`wire:poll`) counts as staff-session activity: the update replays the page's `identity.session` check.
  Every change of data or of someone else's access made from a cabinet is a `Kit\Actions\GuardedAction` with a
  permission, a `CabinetAction` (new names go into `AuditAction` of the Management spec, then `composer sdk:generate`)
  and its declared audit input; every cabinet page uses `GuardsCabinetPage` (it also refuses crafted mounts of any
  method but a public argument-free action factory, and of record actions without a proper record key). Name the page's
  factory methods so they do not end in `Action` (Filament would resolve `{name}Action()` itself). A secret shown once
  (a key's token, a webhook's signing secret) goes through `Kit\Pages\Concerns\RevealsSecretOnce` — a protected
  property, never a public one, a notification or the session. Own-account pages
  (profile, sessions) write to the security log instead; the invitation page, whose person is not signed in yet, writes
  its entry through `CabinetAuditor::recordInvitationAccepted`. Problem titles shown in cabinets live in
  `cabinet::problems` (equal to the API catalogs, plus internal codes).
- Merchant statuses (ADR-0015): `suspended` only reads — Management API changes answer 403 `merchant_suspended`
  (`RequireMerchant`; the only exceptions, with a reason, in `RequireMerchant::WRITES_WHILE_SUSPENDED`), the cabinet is
  read-only, tills keep working; `closed` is refused everywhere, its keys too. A till key also needs an active terminal
  (`api_key_candidates` returns both statuses); a status is checked by `MerchantData::isOpen()`, never as "not
  closed". Every cabinet table with filters cleans them with `Kit\Tables\FilterState` (in `handleTableFilterUpdates`,
  `updatedTableDeferredFilters`, and `bootedInteractsWithTable` when they come from the address, `#[Url]`) and a
  searchable one keeps `tableSearch` a text: Filament fails rendering a list. Keyset cursors over `(time, id)` are read
  with `Cursor::timeAndId` (a crafted cursor is a 400, not a database error).
- The system connection (`pgsql_system`) is used only in the files listed in
  `tests/Architecture/SystemConnectionTest.php`; add a new one there with its purpose.
- No personal data or secrets in logs, events or exception messages. Domain events (`Modules\<Name>\Events`) carry
  ids and amounts only and are dispatched inside the transaction of the change, so listeners succeed or roll back
  with it. A new integration event: dispatch a domain event, map it in `Modules\Outbox\Internal\OutboxRecorder`
  (and `OutboxServiceProvider::RECORDED`), add its type to `EventTypes` and describe it in `docs/api/webhooks.md`.

## Testing gotchas

- Feature tests run as the app role inside a rolled-back transaction, so RLS applies. Use
  `actingAsTenant()` / `tenantContext()` from `tests/Pest.php`.
- `systemConnection()` is a separate session and does not see the test transaction: commit fixtures through
  it and delete them in `finally` (see `app-modules/tenancy/tests/Feature/SystemRoleTest.php`).
- Tests that need several sessions or system processes (concurrency, `ledger:*` commands) live in
  `app-modules/*/tests/Integration`: data is committed, every test creates its own tenant.
- Ledger tests run `set constraints all immediate` so the balance trigger fires before the rollback.
  `Modules\Ledger\Tests\Support\LedgerScenario` sets up a tenant, point type and member.
- `Modules\Members\Tests\Support\MembersScenario` sets up a tenant, a program with published consent
  documents and a `FakeCodeSender` (captures codes). Install fakes before resolving the services that use them.
- PII keys for tests are fixed in `phpunit.xml`; never log or return decrypted phone/e-mail (use masked values).
- `json_encode` writes `/` as `\/`: to look for a base64 secret in a Livewire snapshot, encode it with
  `JSON_UNESCAPED_SLASHES` (or `serialize()`), and read a secret from where it is kept rather than by a regex over HTML.
- `Modules\Processing\Tests\Support\CheckoutScenario` sets up a program with published rules, a till and a
  member with 1000 points; checkout tests run `set constraints all immediate` like ledger tests. HTTP tests
  that send whole-second times travel the clock a minute past the rules publication.
- `Modules\Rules\Tests\Support\TestRules::make([...dotted overrides])` builds a rule set for engine tests,
  `TestRules::campaign($id, [...])` a campaign definition; `RulesScenario` sets up a program with published
  base rules and runs campaigns through the real services (`runningCampaign([...])`).
  `TestRules::tiers([...])` is the standard tier set (base 3 %, silver 5 %, gold 7 %) and `TestRules::bonuses([...])`
  the standard welcome, birthday and referral bonuses; pass them as `tiers:` / `bonuses:` to
  `RulesScenario::start()` / `CheckoutScenario::start()`. `TestRules::stampCard($pointTypeId)` is a coffee stamp
  card (LATTE, ESPRESSO, every sixth free); its point type is a separate `PointType` with precision 0. `Modules\Tiers\Tests\Support\TiersScenario` records
  purchases and returns directly; tier tests freeze the clock with `travelTo()` and move it with `travel()`.
  Pest resolves closures in datasets after `beforeEach` with `$this` bound, unless the test parameter is
  typed `Closure`.
- Audit entries of a partner outside its merchants have no tenant and are read through the system role, so a
  Feature test cannot see them: test them in `tests/Integration`.
- Integration tests commit partners and merchants that stay until the next test process migrates afresh, so a test
  run after them in the same process (a run of a few modules) sees them: a test that searches the directory looks for a
  word of its own (`tenancy/tests/Feature/DirectoryTest.php`).
- `Tests\Support\Sdk\KernelTransport` sends the PHP SDK's requests through the HTTP kernel of the test (see
  `tests/Feature/Sdk`); `sdk/php/examples/quickstart.php` (`loyalQuickstart()`) sets up a merchant end to end.
- Sandbox-mode tests live in `tests/Sandbox` (`Tests\SandboxTestCase` boots the app with `LOYAL_SANDBOX=true`); they use
  test phones `+7900000NNNN`, e.g. `CheckoutScenario::start(memberPhone: '+79000000001')`.
- `Modules\Identity\Tests\Support\StaffScenario` sets up a partner with a merchant, a direct merchant and another
  partner's merchant, and makes staff with roles (`merchantMember()`, `partnerMember()`, `operator()`) through the
  command-line actor. Staff passwords hash with cheap Argon2id settings in tests (`phpunit.xml`). Statements expected
  to fail in the database (RLS, triggers) run inside `DB::transaction()`, so the failure only rolls back a savepoint.
- `Modules\Cabinet\Tests\Support\CabinetScenario` wraps StaffScenario for the cabinets: `withMfa()`, `code()`,
  `signIn()` (a staff session in a fresh Laravel session) and `livewireUpdate()` / `refreshLivewire()`, which send
  Livewire updates through HTTP with all middleware and a browser's headers: `Livewire::test` skips middleware and its
  request has no session (what `withSession` stored is still read by `session()`), so tenant, session and password-hash
  checks of the cabinets are tested over HTTP. Filament page tests
  call `Filament::setCurrentPanel()` and, for pages inside a merchant, `setTenant()`; the profile has no tenant. Panel
  hosts (`CONSOLE_DOMAIN`) are tested in `tests/ConsoleHost` (`Tests\ConsoleHostTestCase`). `RecordingAuditLog::install()`
  keeps the audit records a test wrote (console entries have no tenant, so RLS hides them from feature tests);
  `GuardedActionProbe` and `MerchantActionProbe` are pages with guarded actions; `CabinetScenario::wrongCode()` is a
  code the authenticator surely refuses. Filament renders an open action's modal as a partial of the update, outside
  `html()`: assert it with `assertMountedActionModalSee()` / `…DontSee()` / `…DontSeeHtml()`. `CabinetPrincipal`
  remembers a user's platform access for
  the request: a `Livewire::test` that changes the role of the same user mid-test sees the old one until
  `app()->forgetScopedInstances()`.
- The tenant context cannot change inside a nested DB transaction (by design).
- `Model::shouldBeStrict()` is on outside production: eager-load relations (`->load()`), no lazy loading.

## Windows PowerShell 5.1 gotchas

- `Set-Content -Encoding utf8` / `Out-File` write a BOM, which breaks `declare(strict_types=1)` in PHP files.
  Write files with the editor tools or `[IO.File]::WriteAllText($path, $text, [Text.UTF8Encoding]::new($false))`.
- `Start-Process -Wait` also waits for child processes (a started server never returns).
- Commands that pipe `Remove-Item` or pass it a path in a variable are blocked by the harness; use literal paths.
- In the Bash tool a heredoc with non-ASCII text (Russian, ₽, arrows) fails with "unexpected EOF", and `\\`
  in a heredoc may collapse to `\` (breaks composer.json); write such files with the Write/Edit tools.

## Database gotchas

- `migrate:fresh` drops tables but not functions: migrations `drop function if exists` before `create function`
  (`create or replace` cannot change a function's result type).
- The immutable columns of a table are the arguments of its `{table}_immutable_columns` trigger, which ignores names it
  does not know: to add one, drop the trigger and make it again with the whole list, and test every column.
- Runtime API tests: the request resets the tenant context, so count rows afterwards inside
  `tenantContext()->run($tenantId, ...)` (without it RLS returns zero rows and the assertion passes vacuously).

## Git

Do not commit or push unless asked. Commit messages end with the attribution lines from the system reminder.
