# Kiosk Plan 2 — API Data, Loyalty, Buys, Queue — Design Addendum

**Date:** 2026-06-03
**Status:** Approved (design) — revised after codex spec review (2026-06-03)
**Parent spec:** `alqove-inflow/docs/superpowers/specs/2026-06-02-kiosk-buy-submission-design.md`
**Repo:** `alqove-api` (Laravel 11) — code lands here.
**Branch:** `feature/kiosk-plan2-design`
**Predecessor:** Plan 1 (PR #2 → squash `1359b34`) — `kiosk_devices`, `AuthenticateKioskDevice` middleware, principal-scoped `IdempotencyMiddleware`, `users.phone`/`users.email-nullable`/`users.loyalty_points`.

## Summary

This addendum locks the remaining open design questions for **Plan 2** of the kiosk → server integration, so the implementation plan can be written without ambiguity. The parent spec is the source of truth for everything not restated here. Plan 2 delivers the API-side data layer and three endpoints (`members/lookup`, `buys` submit, `buys/{id}/status`) plus the cross-cutting middleware to make them safe.

Plan 2 does **not** touch the Avalonia kiosk (Plans 3 + 4) and does **not** ship the seller-side staff UI that transitions buys through their post-`queued` lifecycle (future plan).

## Revision history

- **2026-06-03 v1** — initial draft, 6 brainstorm answers locked.
- **2026-06-03 v2** — revised after codex review found 6 BLOCKERs, 7 IMPORTANTs, 1 NIT. Major changes: `IdempotencyMiddleware` global prepend removed; `buys.idempotency_key` UNIQUE becomes compound `(store_id, idempotency_key)`; signature disk switched from `public` to `local`; `KioskCustomerResolver` narrowed to actual `User` columns (`name`, `email` only); marketplace-registration Case B requires `password === null` (account-takeover prevention) and a new Case B' for already-claimed phones; migration order swapped (buys before loyalty_transactions); LoyaltyWriter API takes explicit `int $points`; `BuyIntakeService` adds `lockForUpdate` on user row for join-race; phone logging hashes upgraded to HMAC-SHA-256; daily aggregate lookup cap added; explicit `kioskDevice()`/`kioskStore()` Request macros now in scope; body+header idempotency-key equality enforced; signature attach declared synchronous; `loyalty_transactions` append-only via override pattern (not events); email UNIQUE collisions caught in resolver.
- **2026-06-03 v3** — revised after second codex pass (verified 14/15 prior findings RESOLVED; 1 PARTIAL; 5 new). Changes: `throttle:kiosk-lookup` actually attached to the `members/lookup` route (the limiter definition alone didn't enforce); `buys.request_fingerprint` column added — on durable pre-check a stored-vs-incoming fingerprint mismatch returns **HTTP 409** so same-idempotency-key + different-payload no longer silently replays the old response (canonical RFC behavior); registration merge Cases A and B catch `users.email` UNIQUE races (not only the phone-null path); daily lookup cap wording fixed (every call counts, not just distinct phones); `NonKioskIdempotencyRegressionTest` rewritten to enumerate all routes and assert `IdempotencyMiddleware` is on the kiosk group only.
- **2026-06-03 v4** — revised after third codex pass (5 prior findings all RESOLVED; 3 new: 2 IMPORTANT + 1 NIT). Changes: the `RateLimiter::for('kiosk-lookup', ...)` closure now reads phone from `$r->query('phone')` with a defensive `try/catch (InvalidPhoneException)` so an invalid input doesn't 500 the limiter (since throttle runs before FormRequest); `BuyIntakeService` step 6 catch-and-reload path explicitly **rolls back the transaction before** reloading and returning 200/409, so a rejected different-body submission cannot leave behind customer-resolution side effects; `BuyDto::canonicalFingerprintPayload()` canonicalization rules now spelled out (recursive `ksort`, `JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE`, missing-field policy treated as if `null` was sent).

## Decisions made in this addendum

| # | Decision | Choice |
|---|---|---|
| Q1 | Kiosk-side field-collision policy on match-or-create-by-phone | **Conservative:** fill nulls only; never overwrite a non-null user field from kiosk input. On new-user create, if email collides on UNIQUE, drop the email and log to `kiosk-conflict` channel. |
| Q2 | Null-email social-auth (`Apple "Hide my Email"`) | **Allow null:** `AuthService::findOrCreateSocialUser` signature becomes `?string $email`; the email-fallback user lookup is skipped when null; new user is created with `email = null`; re-find on subsequent logins via `social_accounts.(provider, provider_id)`. |
| Q3 | Phone normalizer | **Custom US-only:** small `app/Support/PhoneNormalizer.php` (strip non-digits, validate length 10 or 11, prepend `+1`). No dependency. International expansion is a future migration. |
| Q4 | Store suspension / soft-delete enforcement | **Group middleware on all `/v1/kiosk` routes:** new `EnsureKioskStoreActive` (alias `kiosk.store.active`) returns **HTTP 423 Locked** when the device's store is suspended or soft-deleted. Applies to reads and writes. |
| Q5 | `IdempotencyMiddleware` wiring | **Group middleware on `/v1/kiosk` ONLY** (revised after codex review). Plan 2 **removes** the current global `api` prepend of `IdempotencyMiddleware` (Plan 1 left it there — see `api/bootstrap/app.php:42-45`) and registers it as alias `idempotency`, applied per-route-group **after** `kiosk.device` + `kiosk.store.active`. The global-prepend wiring is unsafe: a cached 2xx replay returns before the store-active check runs, so a buy cached when a store was active could replay 200 after the store is suspended. Safe no-op on GETs and on writes without the `Idempotency-Key` header. The compound `UNIQUE (store_id, idempotency_key)` on `buys` is the durable, store-scoped backstop beyond the 24h cache TTL. |
| Q6 | Signature storage disk | **`local` disk** (revised after codex review). The `signature` collection on `Buy` uses Laravel's `local` disk (`storage/app/`), which is **not** web-reachable through the `public/storage` symlink. Customer wet signatures are forensic-only PII; the original v1 choice of `public` disk + "no URLs by convention" relied on a convention-based guarantee that does not actually prevent direct-URL access if a media id is guessed or leaked. `local` removes that risk structurally. Operator audit access via `Storage::disk('local')->get(...)` from an out-of-band CLI tool (out of scope for Plan 2). |
| Enum | `BuyStatus` values | `remote_check_in \| appointment \| queued \| sorting \| sorted \| in_progress \| quoted \| voided \| no_buy \| accepted \| declined`. Terminal set: `{voided, no_buy, accepted, declined}`. Kiosk submit always creates `queued`; other values exist for future seller-side transitions. Enum exposes `displayName(): string` returning the human label. |
| Enum | `BuySource` values | `kiosk \| qr_code \| mobile`. Plan 2 only creates `kiosk`. `web` deferred. `mobile` covers iOS and Android — if per-platform analytics ever matter, add a separate `client_platform` column later. |
| Queue | What counts as "ahead of me" | **All non-terminal buys** created with earlier `(created_at, id)` than the subject buy, scoped to the same `store_id`. Terminal = `{voided, no_buy, accepted, declined}`. |
| Default | Rate limits on `members/lookup` | **60/min per device + 10/min per phone-HMAC + 500 calls/day per device.** Beyond the global API throttle. Every lookup call counts toward the daily cap — we deliberately do not maintain a server-side distinct-phone set; total-call ceiling is the simpler, sufficient lever. The daily cap thwarts stolen-token enumeration (60/min × 1440 = ~86k/day without it). Alert at half-cap (250 calls/day/device) via `Log::channel('kiosk-alert')->warning(...)`. **Wired as `throttle:kiosk-lookup` on the route** (v3 fix — the `RateLimiter::for(...)` definition alone does not enforce anything). |
| Default | `members/lookup` audit shape | **`Log::channel('kiosk-lookup')->info([...])` only** — no audit table in Plan 2. Phone is hashed via **HMAC-SHA-256 with `config('app.key')`** before logging. Plain `sha256` of a US phone is reversible in seconds against the ~10^10 phone-number space; HMAC with the server-side key prevents external rainbow-table lookup if logs are exfiltrated. Raw phone is never persisted to logs. |
| Default | `store_settings` validation ranges | `join_points`: 0–10000, `promo_points`: 0–10000, `minutes_per_buy`: 1–120. |
| Default | `UserResource` change | Add `'loyalty_points' => $this->loyalty_points`. One line. Keep `email` as-is (null serializes as JSON `null` — no guard needed). |
| Default | `LoyaltyWriter` API shape | Per-reason methods, mirroring `LedgerWriter` style: `recordJoin(User $u, Buy $b, int $points): LoyaltyTransaction` and `recordPromo(User $u, Buy $b, int $points): LoyaltyTransaction`. **Points are passed explicitly** — the writer must not infer them from `Buy.points_earned` (which holds only the total) or query `store_settings`. Each method opens no transaction of its own — callers (specifically `BuyIntakeService`) wrap the call in the outer transaction. |
| Default | Request accessors | **`Request::macro('kioskDevice')` and `Request::macro('kioskStore')`** are registered in a new `KioskServiceProvider::boot()` (Plan 2 scope). Each returns the value previously bound by middleware on `$request->attributes` (`kiosk_device` / `kiosk_store`). The spec uses `$request->kioskDevice()` / `$request->kioskStore()` throughout — the macros are what make those callable. |

## Data model

### `buys` table

Per parent spec lines 122-138, with column types adjusted to repo conventions (UUID PKs via `HasUuid` trait; money would be cents-as-`unsignedInteger` but Plan 2 has no money columns on `buys` — that may come later).

| column | type | notes |
|---|---|---|
| `id` | uuid (HasUuid) | |
| `store_id` | foreignUuid → stores | derived from device, never from request body |
| `user_id` | foreignUuid → users | the matched/created customer |
| `source` | string, default `'kiosk'` | cast to `BuySource` enum |
| `status` | string, default `'queued'` | cast to `BuyStatus` enum |
| `first_name`, `last_name` | string | snapshot at buy time |
| `address`, `city`, `state`, `dl_number`, `email` | string, nullable | snapshot at buy time (**buy-row only**; not written to `users`) |
| `opt_loyalty`, `opt_txn`, `opt_promo` | boolean | consent snapshot |
| `points_earned` | unsignedInteger | computed server-side |
| `terms_version` | string | Terms of Sale signed |
| `idempotency_key` | string | durable dedupe (uniqueness is compound — see indexes below) |
| `request_fingerprint` | string (char(64)) | sha256 of canonical-JSON of the request body excluding only `idempotency_key`. **`signature_png_base64` IS included** in the fingerprint: the kiosk's durable outbox stores the original signature bytes for any retry of the same key, so a byte-identical replay matches; ANY change to body content (including signature) is a genuinely different submission and warrants 409. Used by the durable pre-check to distinguish "true replay" (return 200) from "key reuse with different payload" (return 409). v3 addition. |
| `signature_state` | string | cast to `SignatureState` enum (`present`, `missing`) |
| `created_at`, `updated_at` | timestamps | |
| `deleted_at` | softDeletes | |

Indexes:
- **`unique (store_id, idempotency_key)`** — durable, store-scoped dedupe (revised after codex review). A leaked or replayed key cannot return another store's buy because the lookup is composite.
- `(store_id, status, created_at, id)` — queue computation (status filter on non-terminal + ordering).
- `(user_id, created_at)` — customer's buy history.

Model:
- `HasUuid`, `HasFactory`, `SoftDeletes`, `InteractsWithMedia` (single-file collection `signature` on the `local` disk — see §"Signature storage").
- `casts`: `source => BuySource::class`, `status => BuyStatus::class`, `signature_state => SignatureState::class`, opt flags `boolean`.
- Relations: `store()`, `user()`. `kioskDevice()` is **not** stored on the buy in Plan 2 (server-derived store is sufficient; per-device attribution is a future analytics concern).

### `loyalty_transactions` table

Per parent spec lines 103-120, mirroring `seller_ledger` exactly.

| column | type | notes |
|---|---|---|
| `id` | uuid (HasUuid) | |
| `user_id` | foreignUuid → users | |
| `store_id` | foreignUuid → stores, nullable | |
| `buy_id` | foreignUuid → buys, nullable | |
| `points` | integer (signed) | + earn, − redeem |
| `reason` | string(32) | cast to `LoyaltyReason` enum (`join`, `promo`) |
| `created_at` | timestamp, `useCurrent()` | immutable; **no `updated_at`** |

Indexes:
- `(user_id, created_at)` — history queries.
- `unique (buy_id, reason)` — replay/race cannot grant the same earn twice; this is the structural guard against same-buy replays.

Model:
- `public $timestamps = false;` (only `created_at`).
- **Append-only via overriding `update()` and `delete()` to throw a domain exception**, mirroring `SellerLedger` at `app/Models/SellerLedger.php:72-81`. The `LoyaltyWriter` service uses raw `DB::insert(...)` to bypass the model when creating new rows. (Codex NIT correction: I previously misdescribed this as "via model events"; the local pattern is method overrides.)
- `HasUuid`, `HasFactory`.
- Cast `points => 'integer'` (signed), `reason => LoyaltyReason::class`.

**One-join-per-user enforcement** is procedural (no portable cross-DB partial unique index): `BuyIntakeService` step 3.5 holds `lockForUpdate` on the user row before computing `$join`. The "no prior `join` row for this user" check then runs under that lock, serializing concurrent first-buys for the same phone. Combined with `unique (buy_id, reason)` (same-buy replay guard), the join invariant is safe.

### `store_settings` additions

Three new columns added to the existing `store_settings` table via a Plan 2 migration:

| column | type | default | validation |
|---|---|---|---|
| `join_points` | unsignedInteger | 250 | 0–10000 |
| `promo_points` | unsignedInteger | 100 | 0–10000 |
| `minutes_per_buy` | unsignedInteger | 8 | 1–120 |

Migration: `add_kiosk_loyalty_to_store_settings_table`. Update `StoreSettings` model `$fillable` + `$casts` (all three cast to `integer`). Update the seller-facing store-settings request validation and the resource so owners can view/edit them. Factory + seeder populate sensible defaults.

### `users` ripple (one line)

- `UserResource` adds `'loyalty_points' => $this->loyalty_points`. No other changes; `phone` is already there from Plan 1.

### Enums

`app/Support/Enums/`:

```php
enum BuyStatus: string {
    case RemoteCheckIn = 'remote_check_in';
    case Appointment   = 'appointment';
    case Queued        = 'queued';
    case Sorting       = 'sorting';
    case Sorted        = 'sorted';
    case InProgress    = 'in_progress';
    case Quoted        = 'quoted';
    case Voided        = 'voided';
    case NoBuy         = 'no_buy';
    case Accepted      = 'accepted';
    case Declined      = 'declined';

    public function displayName(): string { /* maps to "Remote Check-In", "No Buy", etc. */ }
    public function isTerminal(): bool { /* true for Voided/NoBuy/Accepted/Declined */ }

    /** @return list<self> */
    public static function nonTerminal(): array { /* the 7 non-terminal values */ }
}

enum BuySource: string {
    case Kiosk   = 'kiosk';
    case QrCode  = 'qr_code';
    case Mobile  = 'mobile';

    public function displayName(): string { /* "Kiosk", "QR Code", "Mobile" */ }
}

enum LoyaltyReason: string {
    case Join  = 'join';
    case Promo = 'promo';
}

enum SignatureState: string {
    case Present = 'present';
    case Missing = 'missing';
}
```

## Customer matching & user lifecycle

### `KioskCustomerResolver::resolveByPhone(string $phoneE164, array $snapshot): User`

Used by `BuyIntakeService` inside the buys transaction.

**Scope correction (codex BLOCKER):** the `users` table only has `name`, `email`, `phone` (Plan 1 added the last two). The kiosk snapshot fields `first_name`, `last_name`, `address`, `city`, `state`, `dl_number` are **buy-row snapshot fields only** — they are not written to `users`. Only `name` (synthesized as `trim("$first_name $last_name")`) and `email` are candidates for filling on the user.

Behavior:

1. Open `DB::transaction` (or assume one from caller).
2. `$user = User::where('phone', $phoneE164)->lockForUpdate()->first()`.
3. **If found:**
   - **`name`:** if at least one of `$snapshot['first_name']` / `$snapshot['last_name']` is non-null AND `$user->name` is null or empty-string, set `$user->name = trim("{$snapshot['first_name']} {$snapshot['last_name']}")`. Never overwrite a non-empty name. (Q1 conservative.)
   - **`email`:** if `$snapshot['email']` is non-null AND `$user->email` is null, run the UNIQUE-collision sub-check: `User::where('email', $snapshot['email'])->where('id', '!=', $user->id)->exists()`.
     - If a collision is found → do **not** set; log `Log::channel('kiosk-conflict')->info(['phone_hmac' => hash_hmac('sha256', $phoneE164, config('app.key')), 'reason' => 'email_collision_on_existing_user', 'matched_user_id' => $user->id])`.
     - Otherwise → set `$user->email = $snapshot['email']`.
   - **Save defensively:** wrap `$user->save()` in `try/catch (QueryException $e)` for the `users.email` UNIQUE constraint. A concurrent marketplace-registration or another kiosk update could claim the email between the pre-check and the save (codex IMPORTANT). On caught UNIQUE: revert email to null on the model, log `'reason' => 'email_collision_race_on_save'`, re-save.
   - Return the user.
4. **If not found:**
   - Build `$attrs = ['phone' => $phoneE164, 'name' => trim("{$snapshot['first_name']} {$snapshot['last_name']}")]`. (Empty `name` is fine if both first/last are blank.)
   - If `$snapshot['email']` is non-null AND `User::where('email', $snapshot['email'])->exists()` is false, include it; otherwise drop and log `'reason' => 'email_collision_on_create'`.
   - `$user = User::create($attrs)`. Catch `QueryException` and distinguish:
     - `users.phone` UNIQUE → another transaction inserted first; reload by phone (`User::where('phone', $phoneE164)->lockForUpdate()->first()`) and restart at step 3 with the existing row (race resolution).
     - `users.email` UNIQUE → email was claimed between pre-check and INSERT; retry the create with email dropped from `$attrs` and log `'reason' => 'email_collision_race_on_create'`.
   - `$user->assignRole('buyer')` (matches `AuthService::register`).
   - `app(SeedDefaultPreferences::class)->forUser($user)` (matches `AuthService::register`).
   - Return the user.

Notes:
- The conservative policy is enforced **here**, not in the controller, so it can't be bypassed.
- Race condition (phone): two concurrent same-phone submits both pass step 2 with no row → both attempt INSERT → first wins, second hits UNIQUE violation → catch + reload + replay the field-fill logic on the now-existing row.
- Race condition (email): a concurrent marketplace registration or kiosk write could claim the email between our pre-check and INSERT/SAVE → catch on UNIQUE, drop email, log, retry. Tested via feature concurrency tests in both directions.

### Marketplace registration merge rule (Plan 2 owns this)

Plan 1 only delivered the schema (`users.phone` unique, `email` nullable, `loyalty_points`). The actual phone-match-on-register logic was deferred to Plan 2. Changes:

- `RegisterRequest` accepts an optional `phone` field, validated via `PhoneNormalizer::toE164` (422 on invalid format). When provided, stored normalized.
- **Move the email-uniqueness check out of `RegisterRequest`** (currently `'email' => 'required|email|unique:users,email'`). With phone-based merge, a legitimate claim of a phone-created row may re-use that same row's existing email or set a new email that the row didn't have. The static `unique:users,email` rule prevents this legitimate merge. Uniqueness handling moves into `AuthService::register`, where it can scope-by-id (`Rule::unique('users','email')->ignore($byPhone->id)` when in a claim path) and emit the precise 422 messages below.
- `AuthService::register` signature becomes `register(string $name, string $email, string $password, ?string $phone = null): array`.
- Inside `register`:
  1. Open a transaction.
  2. If `$phone` is null → today's behavior: create new user with email + password + buyer role. Still enforce email-uniqueness here (catch `users.email` UNIQUE on create → 422 "Email already registered").
  3. If `$phone` is provided:
     - `$byPhone = User::where('phone', $e164)->lockForUpdate()->first()`.
     - `$byEmail = User::where('email', $email)->first()`.
     - **Case A — neither matches:** `$byPhone === null && $byEmail === null` → create new user with both phone and email (standard new register). **v3 race guard:** wrap the `User::create(...)` in `try/catch (QueryException)` for `users.email` UNIQUE — a concurrent registration could claim the email between our `$byEmail` check and INSERT → on caught UNIQUE: return **422 "Email already registered"** (equivalent to Case D).
     - **Case B — phone matches an UNCLAIMED row, email is free or same row:** `$byPhone !== null && $byPhone->password === null && ($byEmail === null || $byEmail->id === $byPhone->id)` → **claim/merge** the existing phone-row: set `email` (replaces null or same value), set `password = Hash::make($plain)`, ensure `buyer` role assigned. Return that user. **The `password === null` precondition is critical** (codex v1 BLOCKER): without it, this path would overwrite an existing real account's password — an account-takeover vector through the registration endpoint. **v3 race guard:** wrap `$byPhone->save()` in `try/catch (QueryException)` for `users.email` UNIQUE — a concurrent transaction could claim the email between our `$byEmail` check and SAVE → on caught UNIQUE: return **422 "Email and phone race; please retry"** (or roll back and re-evaluate as Case C if a re-read shows `$byEmail` now pointing to a different user).
     - **Case B' — phone matches a CLAIMED row:** `$byPhone !== null && $byPhone->password !== null` → **422** "Phone is already registered to an account. Please sign in or reset your password." Never overwrite an existing account's credentials via the registration endpoint.
     - **Case C — phone matches AND email matches a DIFFERENT user:** `$byPhone !== null && $byEmail !== null && $byEmail->id !== $byPhone->id` → **422** "Phone and email belong to different accounts." The `same-phone wins` rule from the parent spec cannot resolve two distinct rows.
     - **Case D — phone is free, email is taken:** `$byPhone === null && $byEmail !== null` → **422** "Email already registered." (Today's behavior preserved.)
- `AuthController::register` passes `$request->validated('phone')` through.
- Tests (`RegistrationMergeTest`): one per Case A, B (unclaimed merge), **B' (claimed → 422 takeover prevention)**, C, D, plus a phone-null path regression (existing behavior unchanged), plus a regression that today's `unique:users,email` semantic still 422s when no phone is supplied and email is taken (Case D path). **v3:** add race tests for Case A and Case B that simulate a concurrent email-taking transaction between `$byEmail` check and INSERT/SAVE — assert the caught UNIQUE produces 422, not 500.

### `AuthService::findOrCreateSocialUser` null-email fix (Q2)

Change:

```php
- public function findOrCreateSocialUser(
-     string $provider,
-     string $providerId,
-     string $email,
+ public function findOrCreateSocialUser(
+     string $provider,
+     string $providerId,
+     ?string $email,
      string $name,
      ?string $avatar,
  ): array {
      $socialAccount = SocialAccount::where('provider', $provider)
          ->where('provider_id', $providerId)
          ->first();

      if ($socialAccount) {
          // … unchanged …
      }

-     $user = User::where('email', $email)->first();
+     $user = $email !== null
+         ? User::where('email', $email)->first()
+         : null;

      if (! $user) {
          $user = User::create([
              'name' => $name,
              'email' => $email, // may be null
              'avatar' => $avatar,
              'password' => null,
          ]);
          // … unchanged …
      }
```

`AuthController::socialCallback` already passes `$socialUser->getEmail()` — no change there beyond the now-permitted null.

### `PhoneNormalizer` (Q3)

`app/Support/PhoneNormalizer.php`:

```php
final class PhoneNormalizer {
    public static function toE164(string $input): string {
        $digits = preg_replace('/\D+/', '', $input);
        return match (strlen($digits ?? '')) {
            10 => '+1' . $digits,
            11 => str_starts_with($digits, '1')
                ? '+' . $digits
                : throw new InvalidPhoneException(),
            default => throw new InvalidPhoneException(),
        };
    }
}
```

A `phone` validation rule wrapper translates `InvalidPhoneException` into a 422 with the standard error envelope. Used by every kiosk request that accepts a phone. The kiosk client implements the same algorithm so client + server agree.

Unit tests cover: 10 digits, 11 with leading 1, 11 without leading 1 (throws), 9 digits (throws), formatted input (`(555) 123-4567` → `+15551234567`), already-E.164 input (`+15551234567` → `+15551234567`), leading-zero / international (`+447700900123` → throws; international is out of scope).

## Endpoints (`app/Modules/Kiosk`)

All under the route group:

```php
Route::middleware(['kiosk.device', 'kiosk.store.active', 'idempotency'])->group(function () {
    Route::get('/kiosk/ping', [KioskController::class, 'ping']);

    // v3: throttle must be attached HERE — RateLimiter::for('kiosk-lookup', ...)
    // alone is a definition only; without 'throttle:kiosk-lookup' the limits
    // do not enforce. Per-route so buys/status/ping aren't affected.
    Route::get('/kiosk/members/lookup', [MemberLookupController::class, 'show'])
        ->middleware('throttle:kiosk-lookup');

    Route::post('/kiosk/buys', [BuyController::class, 'store']);
    Route::get('/kiosk/buys/{buy}/status', [BuyController::class, 'status']);
});
```

### `GET /v1/kiosk/members/lookup?phone={raw_or_e164}` — minimal PII

- `MemberLookupRequest` normalizes via `PhoneNormalizer::toE164`; invalid → 422.
- Rate limit (v4 — the closure receives `$r` only; phone must be derived from the request, NOT from a controller-scoped variable):
  ```php
  RateLimiter::for('kiosk-lookup', function (Request $r) {
      // Derive the phone key defensively: throttle middleware runs BEFORE
      // the FormRequest, so an invalid phone here would 500 the limiter.
      // On invalid input, fall back to the raw query string so a key-feeder
      // can't bypass the per-phone limit by sending malformed values.
      $raw = (string) $r->query('phone', '');
      try {
          $phoneKey = PhoneNormalizer::toE164($raw);
      } catch (\Throwable) {
          $phoneKey = $raw;
      }

      return [
          Limit::perMinute(60)->by('device:'.$r->kioskDevice()->id),
          Limit::perMinute(10)->by('phone:'.hash_hmac('sha256', $phoneKey, config('app.key'))),
          Limit::perDay(500)->by('device-daily:'.$r->kioskDevice()->id),
      ];
  });
  ```
  Exceeded → 429. **The daily 500-call cap thwarts stolen-token enumeration** (without it: 60/min × 1440 = ~86k/day; with it: a leaked token can't sweep more than 500 calls/day). Note: per-day is keyed on device alone — every lookup call counts, not just distinct phones (we don't want to maintain a server-side distinct-phone set; the cap on total calls is the simpler, sufficient lever).
- **Alert path:** when a device crosses 250 calls in a calendar day (half cap), emit `Log::channel('kiosk-alert')->warning(['device_id' => $deviceId, 'event' => 'lookup_threshold', 'calls_today' => $count])`. The alert channel feeds Ops monitoring (channel configuration out of scope for Plan 2).
- Audit: `Log::channel('kiosk-lookup')->info(['device_id' => $deviceId, 'phone_hmac' => hash_hmac('sha256', $phoneE164, config('app.key')), 'found' => $found])`. **Raw phone is never logged. HMAC-SHA-256 with `config('app.key')`** prevents an attacker who exfiltrates the log from rainbow-tabling US phone numbers (10^10 entries hash in seconds against plain SHA-256).
- Response:
  ```json
  { "data": { "found": true, "first_name": "Maya", "last_initial": "C", "loyalty_points": 1340 } }
  ```
  or `{ "data": { "found": false } }`. No address/DL/email returned ever. (The `loyalty_points` field is kept per the parent spec for the kiosk greeting; codex flagged that even this is enumeration-able by a stolen token, but the daily 500-cap is the chosen mitigation rather than reducing response payload.)

### `POST /v1/kiosk/buys` — idempotent

Request shape (per parent spec lines 165-168):

```json
{
  "idempotency_key": "uuid",
  "phone": "+15551234567",
  "first_name": "Maya",
  "last_name": "Chen",
  "address": "…", "city": "…", "state": "…", "dl_number": "…", "email": "…",
  "opt_loyalty": true, "opt_txn": true, "opt_promo": true,
  "terms_version": "2026-06-01",
  "signature_png_base64": "iVBORw0KGgo…"
}
```

Header: `Idempotency-Key: {same-uuid}`.

`BuyRequest` (FormRequest):
- Validates required fields, types, `terms_version` non-empty, phone via `PhoneNormalizer`.
- **Enforces `body['idempotency_key'] === header('Idempotency-Key')`** (codex v1 IMPORTANT) — 422 if they differ. This makes the cache-replay path (header-keyed) and the durable-replay path (body-keyed) agree on a single key by construction; the kiosk MUST send the same UUID in both per the parent spec.
- Decodes `signature_png_base64`, sniffs PNG magic bytes (`\x89PNG\r\n\x1a\n`), enforces ≤ 256 KB **decoded**. Decode failure → 422 before any write.

`BuyDto::canonicalFingerprintPayload(): array` (v3, refined in v4) — returns the request body **canonicalized for deterministic hashing**:

- All top-level and nested array/object keys recursively `ksort`-ed.
- `idempotency_key` removed (it's metadata, not content — the fingerprint's purpose is to detect KEY-REUSE with different bodies).
- `signature_png_base64` IS included — the kiosk outbox stores the original bytes durably for any retry of the same key, so a byte-identical retry matches; any change in signature bytes is treated as a different submission (409).
- Missing-field policy: a key that's absent from the incoming request is treated as if `null` was sent (the DTO normalizes by setting any optional field not present in the input to `null` before canonicalization). This ensures `{a: 1, b: null}` and `{a: 1}` produce the same fingerprint, so the kiosk client can omit nullable fields without breaking replay.

The hash is then computed as `hash('sha256', json_encode($canonical, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION))`. The flags ensure (a) no surprise `…` escapes for unicode names, (b) no surprise `\/` escapes for paths, (c) `0.5` doesn't become `0` and break match. The kiosk client implements the same algorithm so client and server fingerprints agree. (v4 codex NIT — the v3 wording said "canonical JSON" but the snippet was plain `json_encode(...)`; now the canonicalization rule is precise and a tester can byte-match it.)

`BuyIntakeService::submit(KioskDevice $device, BuyDto $dto): BuyResponseDto`:

1. **Durable, store-scoped dedupe pre-check with fingerprint match (v3).** `$existing = Buy::where('store_id', $device->store_id)->where('idempotency_key', $dto->idempotencyKey)->first()`. **The `store_id` clause is mandatory** (codex v1 BLOCKER) — without it, a leaked key would surface another store's buy.
   - Compute the incoming request fingerprint: `$incomingFp = hash('sha256', json_encode($dto->canonicalFingerprintPayload(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION))`. The canonicalization rule (recursive `ksort`, `idempotency_key` removed, signature bytes included, missing fields normalized to `null`) is spelled out below in the `BuyDto::canonicalFingerprintPayload()` note — the kiosk client implements the same rule.
   - If `$existing` is null → proceed to step 2 (new buy path).
   - If `$existing !== null && $existing->request_fingerprint === $incomingFp` → **true replay:** build the existing buy's response and return HTTP **200**. No new loyalty.
   - If `$existing !== null && $existing->request_fingerprint !== $incomingFp` → **idempotency-key reuse with different payload:** return HTTP **409 Conflict** with `{ "message": "Idempotency key was previously used with a different request body. Use a new key for a different submission." }`. (v3 codex IMPORTANT — RFC-canonical behavior; otherwise the client would silently get a stale 200 corresponding to the FIRST submission's body.)
2. **Open transaction.**
3. **Customer resolution.** `KioskCustomerResolver::resolveByPhone($e164Phone, $dto->snapshot())` (see §"Customer matching" — only `name` and `email` are written to `users`; the rest is buy-row snapshot only).
4. **Lock the user row for the points-computation window.** `$user = User::lockForUpdate()->findOrFail($user->id)` (codex IMPORTANT). This serializes concurrent first-buys for the same phone so the "no prior `join` row" check in step 5 cannot race. The lock is held until commit (step 8).
5. **Compute `points_earned`.**
   - `$join = 0`. If `$dto->optLoyalty` AND no prior `loyalty_transactions` row exists for this user with `reason = join`: `$join = $storeSettings->join_points`. (Safe to read under the user lock; any concurrent first-buy is waiting on the lock.)
   - `$promo = 0`. If `$dto->optLoyalty && $dto->optPromo`: `$promo = $storeSettings->promo_points`.
   - `$pointsEarned = $join + $promo`.
6. **Insert `Buy`** with `status = queued`, `source = kiosk`, `signature_state = missing`, all snapshot fields (`first_name`, `last_name`, `address`, `city`, `state`, `dl_number`, `email`), opt flags, `points_earned`, `terms_version`, `idempotency_key`, **`request_fingerprint = $incomingFp` (v3)**, `store_id` from device. Catch `QueryException` on compound **`UNIQUE (store_id, idempotency_key)`** → concurrent duplicate within this same store. **(v4) Roll back the open transaction first** (`DB::rollBack()`) so any side effects from step 3 (e.g. an updated `user.name` / `user.email`, a freshly-created user row) are discarded — the losing request must not partially mutate state. Then, **outside the transaction**, reload the existing buy by `(store_id, idempotency_key)`, compare its `request_fingerprint` to `$incomingFp` (200 on match, 409 on mismatch — same semantics as step 1).
7. **`LoyaltyWriter`** — call `recordJoin($user, $buy, $join)` if `$join > 0`; call `recordPromo($user, $buy, $promo)` if `$promo > 0`. **Points are passed explicitly** (codex IMPORTANT — the writer cannot infer per-reason amounts from `Buy.points_earned` alone). Each method inserts the ledger row (`reason`, signed `points`) and runs `User::where('id', $user->id)->increment('loyalty_points', $abs)` (atomic at SQL level). UNIQUE `(buy_id, reason)` guards a same-buy replay.
8. **Commit.** User lock released.
9. **Synchronous signature attach (post-commit).** Decode the PNG bytes once (already validated in `BuyRequest`); call:
   ```php
   $buy->addMediaFromString($pngBytes)
       ->usingFileName("{$buy->id}.png")
       ->toMediaCollection('signature'); // collection bound to disk('local')
   ```
   On success: `$buy->forceFill(['signature_state' => SignatureState::Present])->save()`. On exception: log to channel `kiosk-signature-failure`; leave `signature_state = Missing`. **The HTTP response is built AFTER the attach attempt completes** (codex IMPORTANT — earlier "response built BEFORE attach" wording was self-contradictory; clarified as synchronous and ordered). The returned `Buy` reflects the actual `signature_state`. The kiosk treats 200/201 as success regardless of `signature_state`, so attach failure does not block the flow — but the response is accurate.
10. **Queue position + ETA** computed via `KioskQueueService::positionFor($buy)` after attach so the response includes them.

Response (`201` on create, `200` on durable replay):

```json
{ "data": {
  "buy_id": "uuid",
  "status": "queued",
  "queue_position": 3,
  "estimated_wait_minutes": 24,
  "loyalty_points": 350,
  "points_earned": 350
} }
```

### `GET /v1/kiosk/buys/{buy}/status`

- Route-model-bind `Buy` by id. If `$buy->store_id !== $device->store_id` → 404.
- Returns `{status, queue_position, estimated_wait_minutes}`. Same queue service.

### `KioskQueueService::positionFor(Buy $buy): array{position:int, etaMinutes:int}`

```sql
SELECT count(*) + 1 AS position
FROM buys
WHERE store_id = ?
  AND status NOT IN ('voided','no_buy','accepted','declined')
  AND (created_at, id) < (?, ?)
```

`etaMinutes = position * store_settings.minutes_per_buy`. All time math in UTC.

If `$buy` itself is terminal (`isTerminal() === true`), position returns 0 and etaMinutes returns 0 — the buy is done. The kiosk thank-you flow won't hit this (buys are created `queued`), but the status endpoint will when seller staff close out a buy.

## Cross-cutting middleware

### `EnsureKioskStoreActive` (alias `kiosk.store.active`)

Runs after `kiosk.device`. Loads `$store = Store::withTrashed()->find($request->kioskDevice()->store_id)`. Aborts **423 Locked** with `{ "message": "Store is not accepting kiosk traffic." }` if `$store === null` OR `$store->deleted_at !== null` OR `$store->is_suspended === true`. Otherwise bind `$store` on `$request->attributes->set('kiosk_store', $store)` and continue. The `Request::macro('kioskStore')` exposes it as `$request->kioskStore()` (see §"Request macros").

Tests:
- Active store → 200 on `kiosk/ping`.
- Suspended store → 423 on `kiosk/ping`.
- Soft-deleted store → 423 on `kiosk/ping`.
- 423 fires for lookup, buys submit, and status — proves the middleware is on the group, not per-route.

### `IdempotencyMiddleware` (alias `idempotency`)

**Plan 2 changes the middleware registration in two ways** (codex BLOCKER — Plan 1 left this globally prepended, which races against `kiosk.device` + `kiosk.store.active`):

1. **Remove** the current global `api` prepend at `api/bootstrap/app.php:42-45`:
   ```php
   $middleware->api(prepend: [
       EnsureFrontendRequestsAreStateful::class,
       IdempotencyMiddleware::class,  // <-- remove this entry
   ]);
   ```
   With the global prepend, a cached 2xx replay returns **before** `kiosk.device` and `kiosk.store.active` run; a buy cached when a store was active replays as 200 even after the store is suspended.
2. **Add** the alias `'idempotency' => IdempotencyMiddleware::class` to the `$middleware->alias([...])` block of the same file, and reference it last on the kiosk route group (after `kiosk.device`, after `kiosk.store.active`).

The middleware's runtime behavior (post Plan 1) is unchanged:
- short-circuits on non-write methods,
- short-circuits when no `Idempotency-Key` header is present,
- scopes the cache key by method + path + Authorization + body sha256,
- caches only 2xx responses,
- TTL 24h.

The compound **`UNIQUE (store_id, idempotency_key)`** on `buys` is the durable, store-scoped backstop beyond 24h (covers extended kiosk-offline replay AND prevents a leaked key from surfacing another store's buy).

**Required regression check:** confirm no existing routes outside the kiosk group rely on the global prepend. Plan 1's PR #2 added the global prepend; any existing tests under `tests/Feature/Checkout/` or other modules that exercise `Idempotency-Key` need to keep passing after the alias-only wiring. If a non-kiosk consumer is found that genuinely needs idempotency replay, register the alias on its route group too — do not re-add the global prepend.

### Request macros

Register two `Request` macros in `KioskServiceProvider::boot()` (new provider in Plan 2; `app/Modules/Kiosk/KioskServiceProvider.php`):

```php
Request::macro('kioskDevice', function () {
    return $this->attributes->get('kiosk_device');
});
Request::macro('kioskStore', function () {
    return $this->attributes->get('kiosk_store');
});
```

`AuthenticateKioskDevice` (Plan 1) already binds the device on `$request->attributes` as `kiosk_device`; `EnsureKioskStoreActive` (Plan 2) does the same for the store on `kiosk_store`. The macros are the canonical accessor used throughout this addendum (`$request->kioskDevice()`, `$request->kioskStore()`).

Register the provider in `bootstrap/providers.php`.

## Services & enums layout

```
app/Support/Enums/BuyStatus.php
app/Support/Enums/BuySource.php
app/Support/Enums/LoyaltyReason.php
app/Support/Enums/SignatureState.php
app/Support/PhoneNormalizer.php
app/Support/Exceptions/InvalidPhoneException.php
app/Http/Middleware/EnsureKioskStoreActive.php

app/Models/Buy.php
app/Models/LoyaltyTransaction.php

# Migration order swapped (codex BLOCKER): buys runs BEFORE loyalty_transactions
# so the `buy_id -> buys` FK can resolve on a fresh migrate.
database/migrations/2026_06_03_000001_add_kiosk_loyalty_to_store_settings_table.php
database/migrations/2026_06_03_000002_create_buys_table.php
database/migrations/2026_06_03_000003_create_loyalty_transactions_table.php
database/factories/BuyFactory.php
database/factories/LoyaltyTransactionFactory.php

app/Modules/Kiosk/KioskServiceProvider.php
app/Modules/Kiosk/Controllers/MemberLookupController.php
app/Modules/Kiosk/Controllers/BuyController.php
app/Modules/Kiosk/Requests/MemberLookupRequest.php
app/Modules/Kiosk/Requests/BuyRequest.php
app/Modules/Kiosk/Resources/BuyResource.php
app/Modules/Kiosk/Services/BuyIntakeService.php
app/Modules/Kiosk/Services/KioskQueueService.php
app/Modules/Kiosk/Services/KioskCustomerResolver.php

app/Modules/Loyalty/Services/LoyaltyWriter.php
```

The `Loyalty` module is **new** in Plan 2 (mirrors the existing `Ledger` module layout). It owns `LoyaltyWriter` so any non-kiosk future earn paths share the same writer.

## Signature storage

- `Buy implements HasMedia` + `use InteractsWithMedia`.
- `registerMediaCollections()` declares a `signature` collection, single file, mime-restricted to `image/png`, disk **`local`** (revised after codex review BLOCKER — see Decision Q6). Customer signatures are forensic-only PII and must not live under the `public/storage` symlink path, even by convention.
- The `BuyResource` does not include a signature URL or path — there is no public URL for `local`-disk media. A regression test asserts the JSON response of every buys-related endpoint contains no signature path, URL, or media-id reference (only `signature_state` is allowed).
- Storage path on disk: `storage/app/<media-id>/<buy-id>.png` (spatie default for `local` disk). Not web-reachable.
- Operator audit path: `Storage::disk('local')->get(...)` via an out-of-band CLI tool (out of scope for Plan 2).

## OpenAPI + tests scope

### `contracts/openapi.yaml`

Add the three endpoints with full request/response schemas + error envelopes for 401, 403, 404, 409, 422, 423, 429.

### Feature tests (`tests/Feature/Kiosk/`)

- `MemberLookupTest`: hit → minimal-PII shape (no address/DL/email); miss → `{found:false}`; per-device 60/min limit; per-phone-HMAC 10/min limit; **per-device 500/day limit** (codex v1 IMPORTANT — and v3 confirms the limits actually fire because `throttle:kiosk-lookup` is on the route, NOT only defined via `RateLimiter::for()`); **kiosk-alert channel fires at 250/day half-cap**; phone field is logged via HMAC, not plain sha256 (assert via fake log channel); invalid phone → 422; suspended store → 423. **v3 regression:** assert the route actually has `throttle:kiosk-lookup` in its middleware list — without that, the limits silently do nothing.
- `BuyIntakeTest`:
  - new customer create (`name` synthesized from first/last; address/DL go to `Buy` row only, not `User`);
  - returning customer **conservative field update** (existing non-null email preserved when kiosk sends a different email; existing non-empty name preserved when kiosk sends new first/last);
  - kiosk-provided email DROPPED on UNIQUE collision (new-user path) → buy created with email-on-buy but email NOT on user;
  - **store-scoped idempotent replay** (codex v1 BLOCKER): same body submitted twice from the same device → 200 with same buy; same body submitted from a *different* store's device → fresh 201 (the compound UNIQUE allows the same key per store);
  - **header/body key mismatch:** same Idempotency-Key header but `body.idempotency_key` differs → **422 from BuyRequest** (codex v1 IMPORTANT);
  - **same key + DIFFERENT body content (v3 codex IMPORTANT):** same `Idempotency-Key` header and same `body.idempotency_key` but other body fields differ → **HTTP 409 Conflict** because the durable pre-check finds the existing buy and the stored `request_fingerprint` doesn't match the incoming one. No buy created on the second submission. No new loyalty;
  - idempotent replay returns same buy with HTTP 200 and **no double loyalty**;
  - concurrent same-phone submits create one user (transaction + UNIQUE retry);
  - **concurrent first-buy join race** (codex IMPORTANT): two simultaneous first-ever buys for the same new phone → exactly one `join` ledger row (verified by `lockForUpdate` serialization);
  - one-time `join` (second buy for same user with `opt_loyalty=true` yields no second join row);
  - repeatable `promo`;
  - base64 size > 256 KB → 422 **before any write** (no buy row, no user row created);
  - signature attach failure → buy still created with `signature_state = missing` and 201, response reflects `missing` (codex IMPORTANT);
  - signature attach success → response reflects `signature_state = present`;
  - signature file is on the `local` disk, NOT under `public/storage` (codex BLOCKER);
  - suspended store → 423 (proves the middleware order returns 423 even when a cached buy exists);
  - **cached idempotency replay respects suspension**: a buy submitted while store was active is cached; suspend the store; replay the same request → 423 (not 200 from cache). This is the codex BLOCKER regression test for the global-prepend removal.
- `BuyStatusTest`: store-scoped 404 on cross-store id; queue position with `(created_at, id)` tie-breaker; terminal buys return position 0; non-terminal queue counting excludes voided/no_buy/accepted/declined.
- `IdempotencyWiringTest`:
  - Replays a kiosk POST 201 as 200 on identical body+header (cache hit).
  - Asserts `IdempotencyMiddleware` is NOT in `bootstrap/app.php`'s global prepend (codex v1 BLOCKER regression — assert by reflecting the kernel's middleware list, NOT by string-matching the file).
  - Asserts the `idempotency` alias IS registered.
- `NonKioskIdempotencyRegressionTest` (v3 — codex NIT, sharpened): **enumerate all routes via `Route::getRoutes()` and assert that `IdempotencyMiddleware` (or its alias `idempotency`) appears in the middleware list of ONLY routes inside the `/v1/kiosk` prefix.** Concretely:
  ```php
  foreach (Route::getRoutes() as $route) {
      $hasIdempotency = collect($route->gatherMiddleware())
          ->contains(fn ($m) => str_contains($m, 'IdempotencyMiddleware')
                              || $m === 'idempotency');
      if ($hasIdempotency) {
          $this->assertStringStartsWith('v1/kiosk/', $route->uri());
      }
  }
  ```
  This makes the test non-vacuous: any future code that re-globalizes the middleware or attaches it to a non-kiosk route fails this assertion explicitly.

### Auth-module tests

- `SocialAuthNullEmailTest`: provider returns null email → first call creates a user with `email = null`; second call with same `(provider, provider_id)` returns the same user; non-null email path still attaches to existing email-matched user.
- `RegistrationMergeTest`: Case A (new phone + new email → create), Case B (phone matches UNCLAIMED row → claim/merge email + password), **Case B' (phone matches CLAIMED row → 422 takeover prevention; codex BLOCKER)**, Case C (phone and email match different rows → 422 conflict), Case D (phone free, email taken → 422 standard). Plus a regression: omitting `phone` keeps today's email-only registration semantics, AND email-uniqueness still enforced on the phone-null path.

### Unit tests

- `PhoneNormalizerTest`: all 7 branches above.
- `KioskQueueServiceTest`: tie-breaker stability; non-terminal-only counting; cross-store isolation; ETA math.
- `LoyaltyWriterTest`: per-reason inserts with **explicit `int $points`** (codex IMPORTANT) — assert ledger row + atomic user-points increment; UNIQUE `(buy_id, reason)` blocks a replay; signed-int `points` accepts negatives (forward-looking for redemption).
- `KioskCustomerResolverTest`: name synthesis (first+last → trim concat); skips overwriting non-empty name; sets email only when current is null; drops email on UNIQUE pre-check collision; catches UNIQUE on save (race) and reverts email; catches UNIQUE on create (race) and retries without email.

## Out of scope for Plan 2

- All kiosk-client work (Plans 3 + 4).
- Seller-staff endpoints for transitioning buys through `sorting → sorted → in_progress → quoted → accepted/declined/no_buy/voided`. The enum values exist; the transitions do not.
- The `remote_check_in` flow (no creator path; enum value reserved).
- Per-device buy attribution column (`kiosk_device_id` on buys).
- Per-platform `client_platform` column for mobile.
- Customer-facing "view my buys" endpoints.
- A signature-retrieval / signature-audit endpoint or admin UI.
- A `kiosk_lookup_audit` table (channel log is sufficient).
- Phone normalizer beyond US (defer to future i18n plan).
- Loyalty redemption flow.
- **Re-globalizing `IdempotencyMiddleware`** — deliberately removed in Plan 2 (codex BLOCKER). Future kiosk-adjacent modules that need replay should opt in via the route-group alias.
- Reducing the `members/lookup` response payload further (e.g. dropping `loyalty_points`). Codex flagged enumeration risk; Plan 2's mitigation is the daily-500 cap + alerting. Payload tightening can be revisited if alerting fires in practice.

## Implementation order (for writing-plans consumption)

Suggested groups, each independently testable, in this order:

1. **Enums + PhoneNormalizer.** No dependencies on data layer; all unit tests.
2. **`store_settings` additions.** Migration + model + request validation + resource + factory + seeder. Independent of buys.
3. **`buys` table + Buy model + factory.** Including the compound `UNIQUE (store_id, idempotency_key)` index, the `request_fingerprint` column (v3 codex IMPORTANT — required for the same-key/different-payload 409 semantics), and spatie media-library registration on disk `local` (the signature collection). No controllers yet. (Sequence note: `buys` lands before `loyalty_transactions` so the FK is resolvable — codex v1 BLOCKER fix.)
4. **`loyalty_transactions` table + model + LoyaltyWriter.** Append-only via overriding `update()`/`delete()`; `recordJoin/Promo($u, $b, int $points)`; raw insert. Tests on the writer in isolation.
5. **Auth-module changes (independent of kiosk routes).**
   - `AuthService::findOrCreateSocialUser` null-email fix (signature → `?string`, skip email-fallback when null).
   - `RegisterRequest` accepts optional `phone`; move `unique:users,email` out into `AuthService::register`; implement Cases A/B/B'/C/D.
   - Tests: `SocialAuthNullEmailTest`, `RegistrationMergeTest` (incl. Case B' takeover prevention).
6. **`EnsureKioskStoreActive` middleware + `kiosk.store.active` alias + KioskServiceProvider (with Request macros) + tests.** Independent of buys logic; tested with `kiosk/ping`. The macros (`$request->kioskDevice()`, `$request->kioskStore()`) become available here.
7. **`IdempotencyMiddleware` wiring change** — remove from `api` global prepend, add `'idempotency'` alias, attach to kiosk route group. Run `IdempotencyWiringTest` + `NonKioskIdempotencyRegressionTest`. **This is a cross-cutting change** — must land before any new kiosk write endpoints rely on the cache-replay path. Validate seller/checkout suites still pass.
8. **`KioskCustomerResolver` service + tests.** Uses User model + the new HMAC log channel. Includes race tests for both phone and email UNIQUE collisions.
9. **`KioskQueueService` + tests.** Pure read; uses Buy/StoreSettings.
10. **`BuyIntakeService` + `BuyController::store` + `BuyRequest` + `BuyResource` + tests.** Wires steps 1-9 together; the bulk of the feature. Includes the store-scoped pre-check (compound UNIQUE), `lockForUpdate` on user, synchronous signature attach, body/header key equality, and the cache-replay-after-suspension regression.
11. **`MemberLookupController::show` + `MemberLookupRequest` + rate-limit (3 limits: per-min/per-day/per-phone-HMAC) + audit-log channel + alert channel + tests.** **The `throttle:kiosk-lookup` middleware MUST be attached to the route definition** (v3 codex IMPORTANT — defining `RateLimiter::for('kiosk-lookup', ...)` alone is inert; the throttle middleware is what actually consults the named limiter on each request).
12. **`BuyController::status` + tests.**
13. **OpenAPI updates + final integration test sweep + Pint + PHPStan baseline check.**

The writing-plans skill will break these into checkbox tasks per the subagent-driven-development workflow.

## References

- Parent spec: `alqove-inflow/docs/superpowers/specs/2026-06-02-kiosk-buy-submission-design.md`
- Plan 1 doc (merged): `api/docs/superpowers/plans/2026-06-02-kiosk-plan1-api-auth-idempotency.md`
- Plan 1 PR (merged squash): https://github.com/endevvor/alqove-api/pull/2 → `1359b34`
- `SellerLedger` model (append-only pattern via overrides): `api/app/Models/SellerLedger.php` (esp. lines 72-81)
- `LedgerWriter` service (raw-insert pattern): `api/app/Modules/Ledger/Services/LedgerWriter.php`
- `IdempotencyMiddleware` (post Plan 1): `api/app/Http/Middleware/IdempotencyMiddleware.php`
- Global middleware registration (Plan 1, to be modified by Plan 2): `api/bootstrap/app.php` (lines 42-53)
- `AuthService` (pre Plan 2): `api/app/Modules/Auth/Services/AuthService.php`
- `AuthController` (pre Plan 2): `api/app/Modules/Auth/Controllers/AuthController.php`
- `Store` model (suspension/soft-delete columns): `api/app/Models/Store.php`
- `SocialAccount` model (re-find link for null-email social auth): `api/app/Models/SocialAccount.php`
- `StoreSettings` model (target of Plan 2 column additions): `api/app/Models/StoreSettings.php`
- Codex spec review (this revision's driver): `/tmp/codex-plan2-spec-review.md`
