# Review of "Kapitano Integration Platform — Multi-Store Approach"

**Date:** 2026-10-03. **Reviewed:** `kapitano_multi_store_integration_approach.md`.
**Verified directly in the code:** signature scheme, `config/integration.php`, the routes file
(`routes/store-integration.php`), the `orders_store_external_unique` index, the cross-tenant tests,
and the absence of request-ids / sandbox / a list endpoint. Webhook headers and event names come from
the earlier read of `StoreWebhookService` and `StoreWebhookEvent`.

**Production hardening and performance:** see [`integration-production-readiness.md`](integration-production-readiness.md) (adds a verified P0 defect and the go-live gate).

---

## 0. Verdict

1. **The principles are right** — one canonical contract, many stores, stores map their own data,
   tenant isolation, idempotency, reliable webhooks, reconciliation, versioning, gated one-API-at-a-time
   delivery. Keep all of them.
2. **The architecture is redundant.** Almost everything the proposal builds as a *new platform in
   front of Logistic* **already exists inside Logistic**, which is itself the multi-tenant Store
   Integration API. Putting a second multi-tenant API in front of it duplicates auth, idempotency,
   outbox, retries, webhooks and logging, creates two sources of truth for every order, and adds a new
   single point of failure — for no capability the store can see.
3. **The real gaps are small** (§3). Close them inside Logistic and ship a **store integration kit**
   (reference client + conformance test + docs). That gives "one store or a thousand, no code change"
   with a fraction of the surface to build and run.

> **Principle to adopt: don't build the platform; harden the one that exists and make it easy to
> consume.** Revisit a separate platform layer only when one of the triggers in §9 is true.

## 1. What already exists (mapping the proposal to the code)

| Proposal | Already in Logistic | Evidence |
|---|---|---|
| §4 Store registration | Portal registration → admin review → approve/reject/suspend | `POST /api/store-portal/auth/register`, `/api/dashboard/store-clients/{uuid}/approve`; admin `/stores` and the new store portal |
| §5 Tenant isolation | Every order carries `store_client_id`; another store's order answers **404** `item_not_found` (not 403) | `StoreOrderLifecycleTest:85`, `StoreOrderUpdateTest:310`, `StorePortalTenantIsolationTest` |
| §6.4 `UNIQUE(store_id, external_order_id)` | Exactly that | `orders_store_external_unique (store_client_id, external_order_id)` |
| §6.5 / §12 Idempotency scoped per store | 3 layers: signature nonce, `Idempotency-Key` ledger (24 h, payload hash), DB unique index. Same key+body → 200 `Idempotency-Replayed`; same key, different body → 409 | `OrderIntakeService`, `integration_requests` |
| §10 Per-store credentials | Sanctum token per `StoreClient` + HMAC signing secret + optional IP allowlist; rotation with 24 h overlap | `StoreClient`, `SignatureService`, `config/integration.php` |
| §11 Scopes | `orders:read`, `orders:write` | `StoreClient::ABILITY_*` |
| §14–16 Signed webhooks, retry, dead letter | `X-Kapitano-Signature: t=…,v1=…`, 6 attempts with backoff, then `dropped`; `webhook_deliveries` log; replay from the portal | `StoreWebhookService`, `StorePortalOrderController` |
| §22 Versioning | `/api/integration/v1` | routes file |
| §23 Error contract | One envelope, branch on `MessageDebug` keys (401/403/404/409/422/429/503 catalogue) | `store-api-ar.md`, OpenAPI |
| §21 OpenAPI docs | Generated spec, checked against real responses | `storage/api-docs/integration-docs.json`, `/api/documentation/integration` |
| §27 Per-store rate limit | 300 req/min per store client, configurable | `INTEGRATION_RATE_LIMIT` |
| §26 Kill switch / pause | `INTEGRATION_ACCEPT_ORDERS=false` → 503 `integration_paused` without revoking tokens | `config/integration.php` |
| §34 Ops view | Admin integration log + per-store pages; store portal webhook log | `kapitano-admin` `/integration`, `/stores/[uuid]` |
| §33 E2E test harness | `integration:simulate-store`, `integration:webhook-sink`, and the 21 Sep live run (110/113 steps passed) | `app/Console/Commands/Integration`, `Desktop/Kapitano/live-integration-2026-09-21` |

## 2. What to remove from the proposal (over-engineering)

| Proposal item | Why it is not worth it |
|---|---|
| **A new Integration Platform in front of Logistic** (§2, §28, §37) | Duplicates everything in §1. Two idempotency layers that can disagree, two webhook pipelines to map events and `sequence` across, **two copies of customer PII**, and a new component that must be up for any order to flow. |
| `integration_orders` table (§6.4) | A second copy of order state → drift and a reconciliation job whose only purpose is to repair the drift it caused. Logistic's `orders` row *is* the record; the store keeps its own link. |
| Platform-side `outbox_messages` and queue (§6.6, §13, §16) | An outbox belongs on the **store** side (where "accepted but not yet sent" can exist). Logistic already answers synchronously (201 + `uuid`) and already has a webhook outbox. |
| Async "accepted/pending" create (§13) | Breaks the current simple contract: a synchronous 201 with a `uuid` is easier for stores and for reconciliation. Keep it synchronous. |
| `client_id` / `client_secret` / OAuth2 client-credentials (§10, §38 D2) | Bearer token **+ HMAC per request + replay protection + IP allowlist** is already stronger than a typical OAuth client-credentials setup. OAuth adds a token endpoint and expiry handling for no gain here. |
| `store_integrations` (multiple integrations per store, §6.2) and `permissions` tables (§6) | Speculative. One `StoreClient` per store with two abilities covers the need. Add when a second integration type exists. |
| A `Domain/Infrastructure/Connectors` directory tree (§36) | Structure for a codebase that is not needed yet. |
| Managed connectors for Shopify/WooCommerce/Legacy (§17–18) | Valuable **only when a real merchant needs one**, and then as a separate, optional client — never part of the core. Don't build speculatively. |
| A full operational dashboard (§34) | Most of it exists. Add a few per-store counters, not a new product. |

## 3. Real gaps — keep and add (smallest change that closes each)

Priority: **P0** = production outage waiting to happen, fix first; **P1** = needed before kapitano_web goes live; **P2** = needed before the second external
store; **P3** = nice to have.

| P | Gap (verified) | Smallest fix |
|---|---|---|
| **P0** | **Store API tokens expire after 30 days** (`sanctum.expiration = 43200` applies to every token; verified with a 31-day-old and a 200-day-old token — both rejected). Every store would be cut off a month after onboarding, including tokens generated from the portal. | Exempt store-client tokens from the global age rule, give them an explicit `expires_at` (default 365 d), show it in the portal, notify the owner at 30/7 days, add a distinct `token_expired` error. Details in the production-readiness doc, V1. |
| **P1** | **No `X-Request-Id`.** Cannot trace one store call through logs, webhooks and support tickets. | Accept an incoming `X-Request-Id` or generate one; log it everywhere; return it in the response header and the envelope; include it in webhook headers and `integration_requests`. |
| **P1** | **No way to detect missed updates except per-order reads.** Webhooks can be lost; there is no list/since-cursor endpoint. | `GET /orders?updated_since=<iso>&cursor=…&limit=` (store-scoped, read ability), ordered by `updated_at,id`. One endpoint makes store-side reconciliation O(1) per sweep instead of O(open orders). |
| **P1** | **Known contract defects** (found by your 21 Sep run): two *different* signed reads in the same second are refused as `signature_replayed` (signature covers only timestamp+body); first webhook retry comes at ~60 s not 10 s; suspended store sees 401 not 403. | Decide and fix: add a `v2` signature over method+path+body accepted alongside `v1`; make `backoff[0]` real; document the 401 behaviour. Without the first, any client that polls will hit it. |
| **P1** | **No test that the same `external_order_id` can exist in two stores** (the proposal's most important isolation case). Cross-tenant 404s are covered; this is not. | One feature test: two clients, same `external_order_id`, both 201, each only sees its own, and each webhook goes to its own URL. |
| **P2** | **No sandbox.** Every store tests against production data and dispatch. | A `mode` on `StoreClient` (`sandbox`/`live`). Sandbox orders are never offered to captains; a simulator advances them (reusing `integration:simulate-store`) and fires real signed webhooks. Same URL, token prefix distinguishes mode. Don't build a separate environment. |
| **P2** | **No conformance check for onboarding.** "Go live" is a judgement call. | An artisan/portal action `integration:conformance <slug>` that runs the standard scenario (create, retry same key, duplicate id, read, webhook receipt, cancel) against a store's sandbox and prints pass/fail. This replaces the proposal's manual "Validation" step. |
| **P2** | **Per-store health is not summarised.** | On the admin store page and the portal home: orders last 24 h, failed intakes, dropped webhooks, last successful call. Counters only. |
| **P3** | No published **deprecation policy** for `v1`→`v2`. | A short section in the docs: ≥ 6 months notice, additive changes allowed in v1, breaking changes only in a new prefix. |
| **P3** | No SDK / examples. | A PHP and a JS reference client (sign, retry, idempotency, webhook verify) + a Postman collection. This is what actually makes integration "dynamic" for developers. |
| **P3** | Customer PII retention on webhook payload logs. | Reuse `integration:prune` (30-day default); confirm retention with whoever owns data policy. |

## 4. Corrections: where the proposal differs from the real contract

The proposal says it needs the "final Logistic API contract before implementation". **It exists.**
Use it; do not design a parallel one.

| Proposal | Actual |
|---|---|
| Resource keyed by `external_order_id` in the path | Keyed by Logistic **`uuid`**; lookup by your id via `GET /orders?external_order_id=` |
| `pickup.latitude/longitude`, `dropoff.latitude/longitude` optional-sounding | `pickup.lat/lng` and `dropoff.lat/lng` **required** (no coordinates → order can't be dispatched or priced) |
| `payment.amount` | `payment.method` (`cash_on_delivery`/`prepaid`), `payment.status`, and `payment.amount_to_collect` (required for COD, dropped for prepaid) |
| Items need `sku` | `items[].name` + `quantity` required; `sku`, `unit_price`, `variant`, `image_url` optional |
| Customer has name + phone | Also `customer.external_id` required; phone is normalised to `+digits`, **no country code is added** |
| Webhook headers `X-Kapitano-Timestamp` + `X-Kapitano-Signature` | Single `X-Kapitano-Signature: t=<unix>,v1=<hex>[,v1=<old>]` (HMAC of `"{t}." + rawBody`), plus `X-Kapitano-Event`, `-Event-Id`, `-Delivery-Attempt` |
| Events `order.created / in_transit / failed` | `order.received / on_the_way / delivery_failed` (+ `order.updated`, `order.address_details`, `integration.ping`); events carry a per-order `sequence` the proposal omits |
| Errors `{success:false, error:{code,…}}` | Existing envelope `{Model,Status,Message,MessageDebug,…}` — branch on `MessageDebug` keys. Changing it would break v1. |
| Statuses: pending → … | 8 states with a defined transition table; `accepted`/`pending` are never announced |
| Store registers via `POST /stores/register` returning secrets | Portal register creates a *pending* store; token and keys are generated by the owner in the portal after approval (built this week) |

Also missing from the proposal and important in practice: **money rules** (SYP only, new pound, the
delivery fee overridden by area pricing), **address/area resolution** (price and ETA depend on accurate
coordinates), and the **wrapped-order** two-leg flow. These are where real integrations fail.

## 5. What the proposal gets right (keep, verbatim)

- Stores map their own data to a canonical contract (§7–8, §29).
- No store-specific branches in core code (§18, §35).
- Tenant isolation tested explicitly (§32), `(store, external_id)` uniqueness (§5).
- Don't rely only on webhooks; reconcile (§25).
- Versioned API; OpenAPI as the source of truth (§21–22).
- Sandbox before production (§19–20).
- **Implement one slice at a time with a gate and evidence (§39).**

## 6. The "simple but enterprise" architecture

```
 Store A (any platform) ─┐   each store runs its own:
 Store B ────────────────┤     • mapper (its data → canonical order)
 kapitano_web ───────────┤     • outbox + retries + idempotency keys
 Store N ────────────────┘     • webhook receiver (verify, dedupe, order by sequence)
            │  signed HTTPS (token + HMAC + Idempotency-Key + X-Request-Id)
            ▼
 ┌─────────────────────────────────────────────────────────────┐
 │ Kapitano Logistic — Store Integration API  /api/integration/v1 │  ← the existing platform
 │  tenant auth · validation · idempotency · intake · webhooks   │
 │  retries · dead-letter · logs · kill switch · rate limit      │
 │  + P1/P2 additions: request-id, updated_since list, sandbox   │
 └─────────────────────────────────────────────────────────────┘
        ▲
        └── Store Integration Kit (shipped, not a service):
            reference clients (PHP/JS) · Postman · conformance command · docs
```

One API, one source of truth per order, one webhook pipeline. Reliability that must live near the
store's data (outbox, mapping, reconciliation) lives with the store; reliability that must be uniform
(auth, idempotency, delivery, audit) lives in Logistic.

## 7. kapitano_web, as the first client

kapitano_web is simply the first store. The earlier approach (separate **store-adapter**, read-only
DB, real HTTP, safe mode, gated API by API) is the correct "reference store client" and is
**consistent with this review**: the adapter is *store-side* code, not part of the platform. The
changes this review makes to that plan:

- Rename it a **reference store client** and build it as the template other stores can copy (its
  client library becomes the PHP kit in P3).
- It consumes the same contract — no special endpoints for kapitano_web in Logistic.
- Add **P1 items 1, 3, 4** (request-id, signature/backoff defects, same-id-two-stores test) **before**
  its API 1 gate, and the `updated_since` list **before** its API 3 gate.

## 8. Revised delivery order (still one gate at a time)

| Step | Work | Gate |
|---|---|---|
| T0 | P1 fixes in Logistic: request-id; signature `v2` + backoff decision; same-id-two-stores test | Tests green; contract doc updated |
| 1 | kapitano_web client foundation + **`GET /health`** | Signed call works; wrong/stale/replayed signature rejected |
| 2 | **`POST /orders`** (needs the shop→branch map and drop-off fallback decisions) | Real confirmed order in Logistic; retry creates nothing new |
| T1 | `GET /orders?updated_since` | Cursor pagination test; no cross-store leakage |
| 3 | Read / lookup / reconcile sweep | A deliberately stale link is repaired |
| 4 | Webhook receiver | Signature, duplicate, out-of-order, rotation, outage — all pass |
| 5 | **Cancel** (≈31% of web orders cancel) | Double-cancel harmless |
| 6 | Payment | Online-paid order shows paid |
| 7 | Edit (PATCH) | Each lock returns its documented 409 |
| T2 (before store #2) | Sandbox mode + `integration:conformance` + per-store counters | A new store passes conformance without help |
| T3 | SDKs, Postman, deprecation policy | — |

## 9. When to build a separate platform layer

Not now. Revisit if **any** of these becomes true:

- A **second logistics backend** must sit behind the same store-facing API.
- The public edge must be **isolated for security/scale** (separate WAF, DDoS posture, independent
  deploy cadence) from the dispatch system.
- Many stores need **managed connectors** with their own lifecycle.
- Logistic's API must change in ways the stable public contract can't absorb (an anti-corruption layer).

Until then, a proxy adds cost and failure modes without adding capability.

## 10. Answers to the proposal's open decisions (D1–D10)

| # | Decision | Recommendation |
|---|---|---|
| D1 | Public API domain | The existing Logistic host under `/api/integration/v1` (e.g. a dedicated hostname for the same app). No second service. |
| D2 | Auth | Keep Bearer token + HMAC + IP allowlist. No OAuth. |
| D3 | Webhook signature | Keep `X-Kapitano-Signature: t=,v1=` (already published). Add `v2` for requests only if the same-second defect is fixed that way. |
| D4 | Registration | Registered → reviewed → approved (already the behaviour). |
| D5 | Sandbox | Yes, as a mode on the same API (P2), before store #2. |
| D6 | Order identity | `(store_client_id, external_order_id)` + `external_order_number` (already enforced). |
| D7 | Canonical order | **Already defined** — see §4 and `integration-docs.json`. Fix the doc drift, don't redesign. |
| D8 | Logistic contract | **Published and checked against real responses.** Treat as v1. |
| D9 | Connectors | None now; build one only for a named merchant, outside the core. |
| D10 | Version lifecycle | Additive in v1; breaking changes only in v2; ≥ 6 months notice. |

## 11. Success criteria (trimmed to what is verifiable)

- A new store registers, is approved, and generates its own credentials with no code change **(done)**.
- The same `external_order_id` in two stores creates two orders, each visible only to its owner
  **(add test)**.
- Duplicate requests never create duplicate orders **(done; keep tested)**.
- A request can be traced end to end by one `X-Request-Id` **(add)**.
- A store can recover from lost webhooks without per-order polling **(add `updated_since`)**.
- A new store passes the conformance run unaided **(add)**.
- No store-specific code anywhere in Logistic **(true today; keep it that way)**.
