# Plan — Plugin-Backend Core Platform (`plugin-backend-core`)

Source of truth: `docs/specs/2026-07-03-plugin-backend-core-design.md` (§ refs below).
Executor: `run-plan-codex.js` (codex multi-wave, integration branch `plan/plugin-backend-core`, land via PR to `main`).

## Standing rules for every task

- **Platform module tasks (Waves 1–2): FIRST load and follow** the platform repo's own `CLAUDE.md`, `.claude/skills/`, `docs/standards/coding-standard.md`, `docs/specs/2026-06-13-monorepo-architecture-synthesis.md`, and platform memories. ESM-only, tsup, `catalog:`/`workspace:^` deps, layer DAG L0→L3, seam = `src/index.ts`, no `node:` builtins (use `crypto.subtle`/`URL`/`fetch`), money = BigInt minor units, one vitest per export.
- **DB-backed tests use a real pglite DB** — mirror `packages/ledger/src/test-fixture.ts` (`createTestDb()` + `@electric-sql/pglite` + drizzle). NEVER mock the DB for guarded-debit / idempotency / balance tests. Add `@electric-sql/pglite` to devDeps where a table is exercised.
- **Every new subpath export** → after adding it to `package.json#exports`, run `pnpm gen:subpath-exports` so `check:subpath-exports` (part of `pnpm verify` → `gate`) passes.
- **Every package task adds a changeset** (`.changeset/<name>.md`, `patch` for new pkgs = `minor`) — release hygiene. App tasks (`@app/press-zone`) are private → NO changeset.
- **Envelope (app):** success `{ "data": … }`, failure `{ "error": { "code", "message" } }` — symmetric, every route.
- **No stubs / placeholders in deliverable code.** A route not yet mounted is fine (mount is Task 22); an empty handler body is not.
- Acceptance command is the ONLY orchestrator-side gate (no `gate0_cmd` — worktrees have no `node_modules`; the implementer installs and runs acceptance itself). Name exact test cases in every acceptance.

---

## Wave Plan

| Wave | Tasks | Parallelism | Theme |
|------|-------|-------------|-------|
| 1 | t1–t6 | 6 ∥ (file-disjoint) | Module leaves + app scaffold |
| 2 | t7–t9 | 3 ∥ | Provider adapters |
| 3 | t10 | 1 | App domain schema |
| 4 | t11–t15 | 5 ∥ (disjoint files) | App shared libs |
| 5 | t16–t21 | 6 ∥ (disjoint route files) | App route modules |
| 6 | t22 | 1 | Mount + cron + queue + wrangler |
| 7 | t23–t24 | 2 ∥ | Admin SPA shells |
| 8 | t25 | 1 | HTTP integration (mounted app, no browser) |

Execution strategy: **dag-parallel** (multiple waves carry ≥2 disjoint tasks). Deps are strictly lower-wave (avoids `wave||1` collapse; a wave's `blocked_by` are all COMMITTED before it runs).

---

## WAVE 1 — Module leaves + app scaffold

### Task 1: entitlements package (NEW)
**Wave:** 1
**Blocks:** t10, t12, t17, t21
**Blocked-by:** —
**Files (create):** `packages/entitlements/{package.json,tsconfig.json,src/index.ts,src/types.ts,src/errors.ts,src/schema.ts,src/test-fixture.ts,src/index.test.ts}`, `.changeset/pbc-entitlements.md`

**Contract** (spec §12.1):
- `can(db, account: string, feature: string): Promise<boolean>` — true iff a live (non-expired) grant covers `feature` for `account`.
- `quota(db, account: string, feature: string): Promise<{ limit: number; used: number; remaining: number }>`.
- `setTier(db, account: string, tier: string): Promise<void>` — upsert the account's tier + the feature grants that tier implies; idempotent.
- Drizzle table `entitlements` (schema.ts): `account` (text), `feature` (text), `tier` (text), `grantedAt` (timestamp), `expiresAt` (timestamp, nullable). Exported for host composition. PK/unique on `(account, feature)`.
- Typed error union `EntitlementError` (errors.ts).

**Behavior:**
- `can` false when grant absent or `expiresAt` in the past; true otherwise.
- `setTier` re-run with same tier is a no-op net effect (upsert, no duplicate rows).
- No wall-clock in module logic beyond `expiresAt` comparison — accept a `now` param or use a passed clock so tests are deterministic.

**Acceptance:**
- `pnpm turbo typecheck build test --filter=@platform-modules/entitlements --concurrency=4` exits 0, zero warnings.
- Tests (pglite, mirror ledger `test-fixture.ts`): `can` true for live grant / false for missing / false for expired; `quota` arithmetic (limit−used=remaining); `setTier` idempotent (2× → identical rows); expired-grant boundary at exact `now`.

### Task 2: tax taxability seam (extend `@platform-modules/tax`)
**Wave:** 1
**Blocks:** t15
**Blocked-by:** —
**Files (create):** `packages/tax/src/taxability.ts`, `packages/tax/src/taxability.test.ts`, `.changeset/pbc-tax-taxability.md`
**Files (modify):** `packages/tax/package.json` (add `./taxability` export), then run `pnpm gen:subpath-exports`

**Contract** (spec §12.5):
- `resolveTaxability(input: { supplier: TaxParty; customer: TaxParty; supplyType: 'goods' | 'services' | 'digital' }): { treatment: 'standard' | 'zero_rated' | 'exempt'; reason: string }`.
- `TaxParty = { country: string }`.
- Sits ABOVE the existing `rates-table` engine: caller maps `treatment` → concrete rate via the rates table (taxability decides *whether/kind*, rates-table decides *how much*).
- `exempt` is a **reserved, v1-unreached** member of the `treatment` union (no rule below produces it yet — future Israeli-exemption-category / EU-OSS work per spec §13). Do not invent a rule to reach it; do not fabricate a runtime test for it.

**Behavior:**
- Supplier IL + customer IL → `standard` (18% via rates-table).
- Supplier IL + customer non-IL (export) → `zero_rated`.
- Pure function, no IO.

**Acceptance:**
- `pnpm turbo typecheck build test --filter=@platform-modules/tax --concurrency=4` exits 0, zero warnings; `check:subpath-exports` green.
- Tests: IL→IL standard; IL→US zero_rated; `treatment` resolves to correct rate when composed with `rates-table`; exhaustiveness test on the treatment switch/mapping (compile-time `never`-check covering `standard`/`zero_rated`/`exempt`) proving the seam handles the full union without a fabricated exempt business rule.

### Task 3: invoicing package core (NEW)
**Wave:** 1
**Blocks:** t7, t10, t15
**Blocked-by:** —
**Files (create):** `packages/invoicing/{package.json,tsconfig.json,src/index.ts,src/types.ts,src/errors.ts,src/port.ts,src/mock-provider.ts,src/test-fixture.ts,src/index.test.ts}`, `.changeset/pbc-invoicing.md`

**Contract** (spec §12.5):
- `InvoiceProvider` port (port.ts): `issue(credential: unknown, spec: DocumentSpec): Promise<{ documentId: string; documentNumber: string; documentUrl: string }>`.
- `DocumentSpec` (types.ts): customer party, line items (`{ description, quantity, unitAmountMinor: bigint, taxTreatment }`), currency, `idempotencyKey: string`, docType.
- `issueInvoice(db, provider: InvoiceProvider, credential, spec): Promise<{ ok: true; result: {…} } | { ok: false; error: InvoiceError }>` — persists an idempotency record keyed on `idempotencyKey`; a repeat key returns the stored result WITHOUT calling the provider again.
- `MockInvoiceProvider` (mock-provider.ts) for tests.

**Behavior:**
- First call with a key → provider.issue → persist → return result.
- Second call, same key → return stored result, provider.issue NOT invoked (assert call count).
- Provider throw → `{ ok: false, error }`, no idempotency record written (retry allowed).
- Money in BigInt minor units.

**Acceptance:**
- `pnpm turbo typecheck build test --filter=@platform-modules/invoicing --concurrency=4` exits 0, zero warnings.
- Tests (pglite): issue via mock returns doc; same `idempotencyKey` twice → provider called once, identical result; provider error → `ok:false` + no record.

### Task 4: billing/subscriptions subpath (extend `@platform-modules/billing`, NEW `./subscriptions`)
**Wave:** 1
**Blocks:** t8, t10, t17, t18
**Blocked-by:** —
**Files (create):** `packages/billing/src/subscriptions/{index.ts,types.ts,port.ts,ingest.ts,test-fixture.ts,index.test.ts}`, `.changeset/pbc-billing-subscriptions.md`
**Files (modify):** `packages/billing/src/index.ts` (extend exported `ProviderEvent` union with `kind:'settlement'`), `packages/billing/package.json` (add `./subscriptions` export), then `pnpm gen:subpath-exports`

**Contract** (spec §12.3, §12.2):
- `SubscriptionProvider` port (port.ts): `createSubscription(input): Promise<Subscription>`, `cancelSubscription(id): Promise<void>`, `getSubscription(id): Promise<Subscription>`.
- `Subscription` state type (types.ts): id, status, currentPeriod, planId.
- `ProviderEvent` union extended: `{ kind: 'settlement'; chargeKey: string; subscriptionId: string; period: string; amountMinor: bigint }` — `chargeKey = <subscriptionId>:<period>` (stable, drives idempotency).
- `ingestWebhook(deps, rawEvent): Promise<ProviderEvent>` (ingest.ts) — maps a provider webhook to a `ProviderEvent`; for recurring settlement it posts the money-audit `ledger_entries` row itself via `confirmSettlement` (idempotent on `chargeKey`). Deps have NO `IntentStore` (settlement is provider-initiated, not app-one-shot).

**Behavior:**
- `createSubscription` idempotent on a caller idempotency key (repeat → same subscription, no double-create).
- `parseWebhook`→settlement yields a STABLE `chargeKey` for the same (sub, period) across redeliveries.
- Money-audit posting is idempotent on `chargeKey` (2× ingest of same charge → one `ledger_entries` row).

**Acceptance:**
- `pnpm turbo typecheck build test --filter=@platform-modules/billing --concurrency=4` exits 0, zero warnings; `check:subpath-exports` green.
- Tests (pglite): settlement webhook → event with `chargeKey=sub:period`; double-ingest same charge → single `ledger_entries` row; `createSubscription` idempotent; `ProviderEvent` union typechecks with settlement kind.

### Task 5: auth/api-keys subpath (extend `@platform-modules/auth`, NEW `./api-keys`)
**Wave:** 1
**Blocks:** t9, t10, t11, t13
**Blocked-by:** —
**Files (create):** `packages/auth/src/api-keys/{index.ts,types.ts,errors.ts,schema.ts,test-fixture.ts,index.test.ts}`, `.changeset/pbc-auth-api-keys.md`
**Files (modify):** `packages/auth/package.json` (add `./api-keys` export), then `pnpm gen:subpath-exports`

**Contract** (spec §12.9):
- Key format `<prefix>.<secret>` (Bearer). `prefix` is a public lookup handle; `secret` is never stored raw.
- `issueApiKey(db, input: { owner: string; scopes: string[] }): Promise<{ id: string; key: string }>` — `key` returned once; store `prefix` + `crypto.subtle` hash of secret + scopes.
- `verifyApiKey(db, presented: string): Promise<{ owner: string; scopes: string[] } | null>` — split on `.`, look up by `prefix`, constant-time compare hash; null on any mismatch.
- Drizzle table `api_keys` (schema.ts, module-owned).

**Behavior:**
- Issue→verify roundtrip returns owner+scopes.
- Wrong secret / unknown prefix → null.
- Hash via `crypto.subtle` (no `node:crypto`).

**Acceptance:**
- `pnpm turbo typecheck build test --filter=@platform-modules/auth --concurrency=4` exits 0, zero warnings; `check:subpath-exports` green.
- Tests (pglite): issue→verify roundtrip; wrong secret → null; unknown prefix → null; scopes preserved; stored secret ≠ raw.

### Task 6: app scaffold (NEW `@app/press-zone`)
**Wave:** 1
**Blocks:** t10, t22
**Blocked-by:** —
**Files (create):** `apps/press-zone/{package.json,tsconfig.json,wrangler.toml,vitest.config.ts,src/index.ts,src/http.ts,src/config.ts,src/errors.ts,src/db.ts,src/mw/rate-limit.ts,src/routes/health.ts,src/http.test.ts,src/config.test.ts}`

**Contract** (spec §7, §8, §11 — built per platform coding-standard, NOT copied from any `apps/*` distro):
- Hono app; worker entry (`src/index.ts`) exports `{ fetch, scheduled, queue }` (scheduled/queue handlers wired to a router filled in later tasks — no stub bodies; a thin real dispatcher is fine).
- `http.ts`: `ok(data)` → `{ data }`, `fail(code, message, status)` → `{ error: { code, message } }` with status; a Hono error handler mapping thrown `AppError`→envelope. **Serialize via a BigInt→decimal-string replacer** (money is BigInt minor units; a raw `JSON.stringify` on a BigInt field throws `TypeError` at runtime while passing typecheck) — every envelope goes through it.
- `config.ts`: typed env/secret loader from wrangler bindings; throws on missing required.
- `db.ts`: drizzle client over the Hyperdrive/Postgres binding.
- `mw/rate-limit.ts`: per-key fixed-window limiter over KV binding.
- `routes/health.ts`: `GET /health` → `{ data: { ok: true } }`.
- `wrangler.toml`: bindings (Postgres/Hyperdrive, KV, Queue producer+consumer, cron trigger) declared.

**Behavior:**
- Envelope shapes exactly as spec; error handler never leaks internals (maps to `{code,message}`).
- Rate-limit blocks the N+1th request in a window (429 envelope).
- config parse fails loudly on missing required var.

**Acceptance:**
- `pnpm turbo typecheck build test --filter=@app/press-zone --concurrency=4` exits 0, zero warnings.
- Tests: `ok`/`fail` envelope shapes; a `bigint` field in `ok(data)` serializes to a decimal string (no throw); `/health` 200 `{data:{ok:true}}`; rate-limit allows N, blocks N+1 with 429 envelope; config throws on missing var.

---

## WAVE 2 — Provider adapters

### Task 7: invoicing/morning adapter (`@platform-modules/invoicing` `./morning`)
**Wave:** 2
**Blocks:** t15
**Blocked-by:** t3
**Files (create):** `packages/invoicing/src/morning/{index.ts,client.ts,types.ts,index.test.ts}`, `.changeset/pbc-invoicing-morning.md`
**Files (modify):** `packages/invoicing/package.json` (add `./morning`), then `pnpm gen:subpath-exports`

**Contract** (spec §12.6):
- `MorningProvider` implements `InvoiceProvider` (Task 3 port).
- Credentials `{ apiUser: string; apiPass: string; companyId: string }`.
- `issue(credential, spec)` → `{ documentId, documentNumber, documentUrl }` via Morning (Green Invoice) REST API: token exchange (`apiUser`/`apiPass` → bearer) then create-document; web-standard `fetch`.
- Typed error union for auth failure / API error.

**Behavior:**
- Token exchange then document create; response mapped to the 3-field result.
- API non-2xx → typed error (no throw of raw fetch error).
- Morning is an INVOICE ISSUER only — no payment logic here.

**Acceptance:**
- `pnpm turbo typecheck build test --filter=@platform-modules/invoicing --concurrency=4` exits 0, zero warnings; `check:subpath-exports` green.
- Tests (fetch mocked): token exchange → bearer used on create call; success maps response→doc; API error → typed error; credential shape enforced.

### Task 8: billing/paypal adapter (`@platform-modules/billing` `./paypal`)
**Wave:** 2
**Blocks:** t17, t18, t21
**Blocked-by:** t4
**Files (create):** `packages/billing/src/paypal/{index.ts,client.ts,webhook.ts,types.ts,index.test.ts}`, `.changeset/pbc-billing-paypal.md`
**Files (modify):** `packages/billing/package.json` (add `./paypal`), then `pnpm gen:subpath-exports`

**Contract** (spec §12.4):
- `PaypalProvider` implements `PaymentProvider` AND `SubscriptionProvider` (Task 4 port).
- `parseWebhook(headers, rawBody): Promise<ProviderEvent>` — verifies PayPal webhook signature (web-standard `crypto.subtle`), maps to `ProviderEvent` (`settlement` for recurring charge, `refund` for reversal) with stable `chargeKey`.
- `createSubscription` / `cancelSubscription` / `getSubscription` via PayPal Subscriptions API.
- `refund(chargeId): Promise<…>`.

**Behavior:**
- Signature verify: valid → parsed event; invalid → typed error, NEVER a parsed event.
- Recurring charge → `settlement` event with `chargeKey=<sub>:<period>`.
- Refund webhook → `refund` event.
- All HTTP via `fetch`; no `node:` builtins.

**Acceptance:**
- `pnpm turbo typecheck build test --filter=@platform-modules/billing --concurrency=4` exits 0, zero warnings; `check:subpath-exports` green.
- Tests (fetch/crypto mocked as needed): valid sig → settlement event stable chargeKey; invalid sig → error, no event; refund webhook → refund event; createSubscription round-trips.

### Task 9: auth/oauth-provider subpath (`@platform-modules/auth` `./oauth-provider`)
**Wave:** 2
**Blocks:** t10, t16
**Blocked-by:** t5
**Files (create):** `packages/auth/src/oauth-provider/{index.ts,schema.ts,types.ts,pkce.ts,errors.ts,test-fixture.ts,index.test.ts}`, `.changeset/pbc-auth-oauth-provider.md`
**Files (modify):** `packages/auth/package.json` (add `./oauth-provider`), then `pnpm gen:subpath-exports`

**Contract** (spec §12.8):
- `registerClient(db, input): Promise<OAuthClient>`, `getClient(db, id): Promise<OAuthClient | null>`.
- `authorize(db, input: { clientId; redirectUri; codeChallenge; codeChallengeMethod:'S256'; scope }): Promise<{ code: string }>`.
- `exchangeToken(db, input: { code; codeVerifier; clientId }): Promise<{ accessToken; …}>` — verifies PKCE S256, single-use code.
- PKCE helpers (pkce.ts) via `crypto.subtle` (SHA-256 of verifier == challenge).
- Drizzle tables `oauth_clients`, `oauth_codes`, `oauth_tokens` (schema.ts) — **module-owned** (spec §3). May reuse Task 5 api-keys token issuance for the access token.

**Behavior:**
- authorize → code bound to challenge; exchangeToken with correct verifier → token; wrong verifier → error.
- Code single-use (second exchange → error).
- S256 only (plain rejected).

**Acceptance:**
- `pnpm turbo typecheck build test --filter=@platform-modules/auth --concurrency=4` exits 0, zero warnings; `check:subpath-exports` green.
- Tests (pglite): authorize→code; exchange valid verifier→token; wrong verifier→error; code single-use; plain method rejected; registerClient/getClient roundtrip.

---

## WAVE 3 — App domain schema

### Task 10: app domain schema + migration (`@app/press-zone`)
**Wave:** 3
**Blocks:** t11, t12, t13, t14, t15, t20
**Blocked-by:** t1, t3, t4, t5, t9, t6
**Files (create):** `apps/press-zone/src/schema.ts`, `apps/press-zone/drizzle/0001_init.sql`, `apps/press-zone/src/schema.test.ts`
**Files (modify):** `apps/press-zone/package.json` — declare every `@platform-modules/*` dep the app consumes (`entitlements`, `invoicing`, `billing`, `auth`, `tax`, `ledger`, `tenancy`, `audit`) as `workspace:^`, and add `@electric-sql/pglite` to `devDependencies` (this task's `schema.test.ts` is the app's first pglite-backed test; every later app pglite test — t14/t18/t25 — inherits it). Single home for the app's module wiring: Task 6's wave-1 worktree cannot resolve these (modules not yet committed) so it ships framework deps only; this wave-3 task runs after all modules commit, and Waves 4–5 importers inherit the declared graph from the integration branch.

**Contract** (spec §3 — Owner column):
- Compose module-owned tables (import + re-export, do NOT redefine): `entitlements` (Task 1), `api_keys` + `oauth_clients`/`oauth_codes`/`oauth_tokens` (Tasks 5, 9), ledger `walletBalances` + `ledger_entries` (existing `@platform-modules/ledger`), billing subscription tables (Task 4).
- Define app-owned tables: `accounts` (WP-site ↔ platform account), `plugins` catalog, `subscription_packages` catalog, `sites`, `credentials` (site→provider credential link), `seats`/`members` (account membership + role).
- `drizzle/0001_init.sql` = full DDL applying on a fresh DB.
- Document the period-scoped wallet key convention: `ownerId = <account>:<plugin>:<period>` (new period = new key = balance 0; no zeroing).

**Behavior:**
- Every module-owned table present in the composed schema (no missing owner edge).
- Migration applies cleanly on empty pglite.

**Acceptance:**
- `pnpm turbo typecheck build test --filter=@app/press-zone --concurrency=4` exits 0, zero warnings.
- Tests (pglite): migration applies; all module + app tables queryable; wallet-key helper formats `<account>:<plugin>:<period>`.

---

## WAVE 4 — App shared libs (disjoint files)

### Task 11: mw/auth — session + tenancy principal
**Wave:** 4
**Blocks:** t16, t17, t19, t20
**Blocked-by:** t10, t5
**Files (create):** `apps/press-zone/src/mw/auth.ts`, `apps/press-zone/src/mw/auth.test.ts`

**Contract** (spec §5 axes 1–2 setup):
- Hono middleware `requireAuth`: resolves a principal from session cookie OR `Authorization: Bearer` (api-key via Task 5 `verifyApiKey`), attaches `{ account, userId, scopes }` to context; 401 envelope on failure.
- Loads tenancy capabilities via `@platform-modules/tenancy` `resolveCapabilities` and attaches them for downstream access checks.

**Behavior:** valid session/key → principal on ctx; missing/invalid → 401 `{error}`; capabilities resolved once per request.

**Acceptance:** `--filter=@app/press-zone` green; tests: valid bearer→principal; invalid→401; capabilities attached.

### Task 12: mw/access — requirePluginAccess (entitlement ∩ capability)
**Wave:** 4
**Blocks:** t17, t19, t20
**Blocked-by:** t10, t1
**Files (create):** `apps/press-zone/src/mw/access.ts`, `apps/press-zone/src/mw/access.test.ts`

**Contract** (spec §5 3-axis):
- `requirePluginAccess(feature)` middleware: `entitlements.can(account, feature)` false → 402 `PLUGIN_NOT_ENTITLED`; tenancy capability absent → 403 `FORBIDDEN`. (Ledger balance = axis 3, enforced at call time in Task 14/19, not here.)

**Behavior:** entitled+capable → next; not entitled → 402; entitled but not capable → 403.

**Acceptance:** `--filter=@app/press-zone` green; tests: pass path; 402 no entitlement; 403 no capability.

### Task 13: lib/credential — site credential resolution
**Wave:** 4
**Blocks:** t16, t19, t20
**Blocked-by:** t10, t5
**Files (create):** `apps/press-zone/src/lib/credential.ts`, `apps/press-zone/src/lib/credential.test.ts`

**Contract:** `resolveCredential(db, { account, site, provider }): Promise<Credential>` — reads the `credentials` link table; supports api-key-backed site auth (Task 5). Typed not-found error.

**Behavior:** existing link → credential; missing → typed error.

**Acceptance:** `--filter=@app/press-zone` green; tests: resolve hit; miss → error.

### Task 14: lib/wallet — period key + seed + guarded debit
**Wave:** 4
**Blocks:** t17, t18, t19, t21
**Blocked-by:** t10
**Files (create):** `apps/press-zone/src/lib/wallet.ts`, `apps/press-zone/src/lib/wallet.test.ts`

**Contract** (spec §5 axis 3, §6.2/§6.4):
- `periodKey(account, plugin, period): string` → `<account>:<plugin>:<period>`.
- `seedPeriodWallet(db, key, allocation: bigint): Promise<void>` — upsert `walletBalances` row (host seeding; NOT a module grant primitive, NOT `appendEntry`).
- `debitCredits(db, key, amount: bigint): Promise<{ ok: boolean }>` — wraps ledger guarded conditional `UPDATE … WHERE balance >= amount` (race-safe); `ok:false` = insufficient.

**Behavior:** seed then debit within balance → ok; debit over balance → `ok:false`, balance unchanged; **concurrent debits never oversell** (guarded update).

**Acceptance:** `--filter=@app/press-zone` green; tests (pglite, mirror ledger `debit.test.ts`): seed+debit ok; over-balance → false; concurrent double-debit of a 1-credit balance → exactly one succeeds.

### Task 15: lib/billing-doc — taxability → rate → invoice
**Wave:** 4
**Blocks:** t18
**Blocked-by:** t10, t2, t3, t7
**Files (create):** `apps/press-zone/src/lib/billing-doc.ts`, `apps/press-zone/src/lib/billing-doc.test.ts`

**Contract** (spec §6.3):
- `issueSettlementInvoice(db, { supplier, customer, supplyType, lineItems, idempotencyKey }): Promise<Result>` — calls `tax.resolveTaxability` → maps to rate via rates-table → builds `DocumentSpec` → `invoicing.issueInvoice(MorningProvider, cred, spec)`. Idempotent on `idempotencyKey`.

**Behavior:** IL→IL invoice at 18%; export at 0%; repeat idempotencyKey → no second Morning document.

**Acceptance:** `--filter=@app/press-zone` green; tests (mock Morning): domestic 18% line; export 0%; idempotency dedup.

---

## WAVE 5 — App route modules (disjoint route files, mounted in Task 22)

Each route file exports a Hono sub-router; NONE edits `src/index.ts` (mount deferred to Task 22). Middleware applied at mount, so route files do not import each other.

### Task 16: routes/oauth — connect flow
**Wave:** 5
**Blocks:** t22
**Blocked-by:** t9, t11, t13
**Files (create):** `apps/press-zone/src/routes/oauth.ts`, `apps/press-zone/src/routes/oauth.test.ts`

**Contract** (spec §6.1): routes for `authorize` / `callback` / `token` delegating to `@platform-modules/auth/oauth-provider`; PKCE S256; persists the resulting site credential (Task 13). Envelope on all responses.

**Behavior:** authorize→code; token exchange→access token + credential stored; PKCE enforced.

**Acceptance:** `--filter=@app/press-zone` green; tests: authorize returns code; token exchange stores credential; bad PKCE → 400 envelope.

### Task 17: routes/subscribe
**Wave:** 5
**Blocks:** t22
**Blocked-by:** t8, t4, t1, t14, t12, t11
**Files (create):** `apps/press-zone/src/routes/subscribe.ts`, `apps/press-zone/src/routes/subscribe.test.ts`

**Contract** (spec §6.2): subscribe route → `PaypalProvider.createSubscription` → `entitlements.setTier` → `seedPeriodWallet` (first period allocation). Idempotent per account+package.

**Behavior:** subscribe → subscription created, tier set, first-period wallet seeded with allocation; re-subscribe idempotent.

**Acceptance:** `--filter=@app/press-zone` green; tests (mock PayPal): happy path sets tier + seeds wallet; idempotent repeat; failure rolls back (no tier/wallet on provider error).

### Task 18: routes/webhooks — ingest + dispatch
**Wave:** 5
**Blocks:** t22
**Blocked-by:** t8, t4, t14, t15
**Files (create):** `apps/press-zone/src/routes/webhooks.ts`, `apps/press-zone/src/routes/webhooks.test.ts`

**Contract** (spec §6.3 — CORRECTED money path):
- Verify + `billing.ingestWebhook` (via `PaypalProvider.parseWebhook`) → `ProviderEvent`. The module already posted the money-audit `ledger_entries` row (Task 4 `confirmSettlement`).
- `dispatch(event)` does ONLY: credit reset via NEW period wallet (`seedPeriodWallet(next period)`) + `issueSettlementInvoice` (Task 15). Idempotent on `chargeKey`/`period` — NEVER on `eventId`, NEVER re-posts money.
- `refund` event → issue credit-note (Morning) + adjust; idempotent.

**Behavior:** settlement → new-period wallet seeded + invoice issued, money NOT re-posted; **double-delivered webhook → single effect** (idempotent on chargeKey/period); refund → credit-note once.

**Acceptance:** `--filter=@app/press-zone` green; tests (pglite + mock PayPal/Morning): settlement seeds next-period wallet + one invoice; redeliver same chargeKey → no second seed/invoice, no extra `ledger_entries`; refund → one credit-note.

### Task 19: routes/plugin — metered call gate
**Wave:** 5
**Blocks:** t22
**Blocked-by:** t12, t14, t11, t13
**Files (create):** `apps/press-zone/src/routes/plugin.ts`, `apps/press-zone/src/routes/plugin.test.ts`

**Contract** (spec §5, §6.4): metered endpoint guarded by `requireAuth`→`requirePluginAccess(feature)`→`debitCredits(currentPeriodKey, cost)`; on debit `ok:false` → 402 `INSUFFICIENT_CREDITS`; on downstream failure after debit → compensating refund credit.

**Behavior:** entitled+capable+funded → executes + debits; insufficient credits → 402, no execution; downstream error → credits refunded.

**Acceptance:** `--filter=@app/press-zone` green; tests: happy path debits once; insufficient → 402 no debit-of-record; downstream failure refunds.

### Task 20: routes/account — customer dashboard API
**Wave:** 5
**Blocks:** t22
**Blocked-by:** t10, t11, t12, t13
**Files (create):** `apps/press-zone/src/routes/account.ts`, `apps/press-zone/src/routes/account.test.ts`

**Contract** (spec §9): read/manage subscriptions, sites, seats/members+roles, invoices, current credit balance — all scoped to the authenticated account (tenancy). Envelope responses.

**Behavior:** returns only the caller's account data; role changes enforce capability; cross-account access → 403.

**Acceptance:** `--filter=@app/press-zone` green; tests: list scoped to account; cross-account read → 403; member role update path.

### Task 21: routes/staff — staff admin API
**Wave:** 5
**Blocks:** t22
**Blocked-by:** t14, t1, t8
**Files (create):** `apps/press-zone/src/routes/staff.ts`, `apps/press-zone/src/routes/staff.test.ts`

**Contract** (spec §9): staff-only ops behind a staff-capability gate — adjust credits (`seedPeriodWallet`/manual entry), issue refund (`PaypalProvider.refund` + credit-note), tier override (`entitlements.setTier`), read audit log (existing `@platform-modules/audit`). Every mutation writes an audit entry.

**Behavior:** non-staff → 403; each op audited; refund reflects in balance + credit-note.

**Acceptance:** `--filter=@app/press-zone` green; tests: non-staff 403; credit adjust audited; refund path.

---

## WAVE 6 — Mount + cron + queue

### Task 22: server mount + scheduled + queue + wrangler finalize
**Wave:** 6
**Blocks:** t23, t24, t25
**Blocked-by:** t6, t16, t17, t18, t19, t20, t21
**Files (modify):** `apps/press-zone/src/index.ts`, `apps/press-zone/wrangler.toml`
**Files (create):** `apps/press-zone/src/cron.ts`, `apps/press-zone/src/queue.ts`, `apps/press-zone/src/index.test.ts` (integration)

**Contract** (spec §7, §8):
- Two mount groups. **Public group** (NO `requireAuth`): `routes/webhooks` (signature-verified, provider-initiated — a 401 here would drop PayPal settlement/idempotency), `routes/oauth` `authorize`/`callback`/`token` (this platform IS the authorization server; `/token` is a back-channel call from the plugin backend carrying `code` + PKCE `code_verifier` + `client_id` — there is NO platform user session at that point, so behind `requireAuth` it would 401 and break the connect flow; `authorize`/`callback` handle session/consent inside the route via redirect, not via a 401-returning middleware), `routes/health`. **Authenticated group** (`requireAuth`, then per-route `requirePluginAccess`): `routes/subscribe`, `routes/plugin`, `routes/account`, `routes/staff`. `rate-limit` applies to ALL groups first.
- `scheduled(event)` → `cron.ts` (period-rollover housekeeping / reconciliation as spec §8 defines — NOT credit zeroing).
- `queue(batch)` → `queue.ts` async job consumer (webhook side-effects / long jobs).
- Finalize `wrangler.toml` bindings (DB/Hyperdrive, KV, Queue producer+consumer, cron).

**Behavior:** full request pipeline works end-to-end against pglite; unmounted-route 404 envelope; middleware order enforced; **public group reachable without a session/bearer** (unauthenticated webhook POST and `/oauth/token` POST are NOT 401), authenticated group rejects missing principal with 401.

**Acceptance:** `pnpm turbo typecheck build test --filter=@app/press-zone --concurrency=4` exits 0, zero warnings; integration test drives health + one guarded route through the mounted app; asserts an unauthenticated webhook POST AND an unauthenticated `/oauth/token` POST each reach their handler (not 401) while an unauthenticated authenticated-group route returns 401.

---

## WAVE 7 — Admin SPA shells (thin, real API calls)

### Task 23: customer dashboard SPA shell
**Wave:** 7
**Blocks:** —
**Blocked-by:** t22
**Files (create):** `apps/press-zone-web/{package.json,vite.config.ts,index.html,src/main.tsx,src/api.ts,src/routes/dashboard.tsx,src/routes/dashboard.test.tsx}`

**Contract** (spec §10): React+Vite SPA at `apps/press-zone-web` (own workspace member — `@app/press-zone-web`; the workspace glob is `apps/*`, so a nested `apps/press-zone/web` would NOT be a member and turbo would not see it). Consumes `@platform-modules/auth-react` + `@platform-modules/billing-react`; screens — login/connect, subscribe/plan, credits+usage, invoices, sites/seats. Real API calls to Wave-5 routes via `api.ts` (envelope-aware). Thin shell — core screens wired, not exhaustive polish.

**Behavior:** authenticated user sees own account; subscribe + credits + invoices render from API.

**Acceptance:** `pnpm turbo build test --filter=@app/press-zone-web --concurrency=4` exits 0, zero warnings; render tests for login + credits screens with mocked API.

### Task 24: staff SPA shell
**Wave:** 7
**Blocks:** —
**Blocked-by:** t22
**Files (create):** `apps/press-zone-web/src/staff/{index.tsx,accounts.tsx,accounts.test.tsx}`

**Contract** (spec §10): staff area inside the same `@app/press-zone-web` member — accounts search, credit adjust, refunds, audit log view; consumes Task 21 routes. Thin shell.

**Behavior:** staff-gated screens; credit-adjust + refund + audit render from API.

**Acceptance:** `pnpm turbo build test --filter=@app/press-zone-web --concurrency=4` exits 0, zero warnings; render test for accounts + credit-adjust with mocked API.

---

## WAVE 8 — HTTP integration

### Task 25: HTTP integration flows
**Wave:** 8
**Blocks:** —
**Blocked-by:** t22
**Files (create):** `apps/press-zone/tests/integration/{connect,subscribe,webhook-idempotency,metered-debit,staff-refund}.test.ts`

**Contract** (spec §13): five end-to-end flows driven at the HTTP layer via `app.request(...)` against the mounted Hono app (Task 22) over pglite, with mocked external providers (PayPal/Morning) — NOT a browser tool. These are JSON-API flows: connect (OAuth), subscribe (tier+wallet), webhook idempotency (double-deliver → single effect), metered debit (+ insufficient-credits 402), staff refund → credit-note. Browser rendering is already covered by the SPA render tests (Tasks 23–24); no Playwright/served-app/browser-install in a bare worktree.

**Behavior:** each flow asserts the spec's success + idempotency guarantees at the HTTP boundary.

**Acceptance:** `pnpm turbo test --filter=@app/press-zone --concurrency=4` green (integration suite included); the five flows pass; webhook double-delivery asserts single wallet seed + single invoice + no extra `ledger_entries`.
