# B2B Tenancy + account Federation — Status & Verification Handoff

**Date:** 2026-06-11
**Branch:** `feat/b2b-tenancy` (131 commits ahead of `master`) — **HELD pending review. No merge, no deploy.**
**Scope:** Records what was *verified on HEAD* this session — the federation/tenant-isolation gate, the account↔mathapi crypto boundary, the ratified seat-billing model, and the open two-sided steps. Companion to the design RFC (`b2b-tenancy-model-2026-06-06.md`) and the frozen contracts (`docs/CONTRACT.md`, `docs/TRANSCRIPT-TIERS.md`).

> This is a status/verification doc, not a design change. It asserts only what was checked against the current tree on 2026-06-11.

---

## 1. Gate result — GREEN on HEAD

Federation + tenant-isolation suite, run against the live MySQL `api` schema (not the prior commit's claim):

```
Tests: 10 passed (30 assertions)

tests/Feature/Federation/AccountTokenValidatorTest .... 3/3 ✓
tests/Feature/Federation/FederationApiTest ........... 4/4 ✓   (incl. tamper-rejection)
tests/Feature/Federation/ReconcileExternalIdsTest .... 2/2 ✓
tests/Feature/Dashboards/TenantIsolationTest ......... 1/1 ✓   (zero cross-org rows)
```

The two isolation properties that matter both pass on HEAD:

1. **Verified-claim scoping + tamper rejection** — a token scoped to org A, requesting `/api/licensee/dashboard?org_id=999&org_ref=<orgB>`, returns **only org A's roster**. Client-supplied org id/ref are ignored; scope derives solely from the signed `org_ref` claim → `organizations.external_id`.
2. **Read-model zero-leak** — `AttainmentReadModel` returns zero rows belonging to any other org.

Auth-layer guards also green: **401** (missing/garbage/wrong-secret token), **409** (unreconciled `org_ref`), **403** (non-AGS role on the licensor portfolio).

### 1.1 Reproducing the gate on the home Windows PC

The home PC's local XAMPP `api` DB is an **old schema dump**, so `TenancyTestCase` skips by default ("Local MySQL `api` DB not reachable"). To run:

1. Start XAMPP `mysqld` (`C:\xampp\mysql\bin\mysqld.exe`).
2. Override creds for the run — `.env` carries a password but XAMPP `root` has none; Laravel's dotenv is immutable so shell env wins:
   ```
   export DB_USERNAME=root DB_PASSWORD= DB_HOST=127.0.0.1 DB_PORT=3306
   ```
3. Apply the additive tenancy/federation migrations by explicit `--path` (the dump predates them): organizations, memberships, houses.org_id, accreditation_policies/params, risk_thresholds, maxile_levels, maxile_snapshots, assessment_sessions, user_field/track/skill_levels, external_ids on users+organizations, user_cursor.
4. Align three columns the current OTP-only schema expects but the old dump lacks: `users.password` → `NULL DEFAULT NULL`; `users.is_admin` → `DEFAULT 0`; `users.date_of_birth` → `NULL`.
5. `php artisan test tests/Feature/Federation tests/Feature/Dashboards/TenantIsolationTest.php`

All of the above is **local dev-DB only and additive** — it brings the stale dump in line with the current schema. The repo working tree is not modified by any of it.

---

## 2. Crypto boundary — account ↔ mathapi (verified by inspecting both repos)

Three **independent** channels. Do not conflate them when touching one.

| # | Channel | Algorithm | Key | Issuer → Verifier |
|---|---|---|---|---|
| 1 | Federation / SSO data-API token | **HS256** | per-app shared secret (`ClientApp.jwt_secret` ↔ `ACCOUNT_MATH_JWT_SECRET`) | account `SsoTokenService` → mathapi `AccountTokenValidator` |
| 2 | Accredited transcript / record credential | **RS256** | account's RSA private key (`storage/app/keys`) | account `IssuanceService::sign()` → anyone, via published JWKS |
| 3 | Certified-checkpoint attestation | **RS256** | mathapi's RSA private key | mathapi → account `CheckpointAttestationVerifier` (RP) |

### 2.1 mathapi side
- HS256 is exclusively the **federation RP validator** (`AccountTokenValidator`, `ValidateAccountToken` middleware).
- mathapi contains **zero transcript-signing code** — it "renders nothing, issues nothing" (TRANSCRIPT-TIERS §5). `grep` for `RS256|openssl_sign|private_key|JWKS` over `app/` + `config/` returns nothing.

### 2.2 account side
- **Transcripts sign RS256 with the private key**: `IssuanceService::sign()` → `JWT::encode($claims, privatePem($kid), 'RS256', $kid)`. Keypair generated/rotated via `php artisan records:keygen` (`RecordKeyStore`, 2048-bit RSA).
- **Public key exposed for external verification**: `JwksService::publicJwks()` (public material only — `n`/`e`, `alg=RS256`) served at **`GET /api/.well-known/jwks.json`**. The verify landing page checks signatures **client-side in the visitor's browser** against that JWKS.
- **The HS256 reconciliation never touched the signing path.** The account-side federation commits (`b75a18b`, `c653a9f`, `9336250`, `408aba9`) modified only `SsoTokenService`, `ReconciliationController`, `User`/`Organization` models, org migrations, `CONTRACT.md`, and tests. A path-filtered git log over `IssuanceService.php` / `JwksService.php` / `RecordKeyStore.php` / `RecordsKeygen.php` returns **no federation commit** — the only commit touching them is the original records-engine build.

### 2.3 Load-bearing guard — DO NOT REMOVE
account's `CheckpointAttestationVerifier` **pins the algorithm to RS256** (`new Key($pem, 'RS256')`), with a comment that an `alg:none` / HS256 confusion attack cannot downgrade verification. **This pin and its comment are permanent and load-bearing (ruled 2026-06-11).** Never relax to a multi-algorithm `Key` set; never delete the comment; do not "tidy" it in a refactor.

---

## 3. Seat billing — org-pool model (ratified; supersedes RFC §6)

Centre seat billing **decouples seats-owned from seats-assigned**. The webhook touches no roster rows.

1. **Purchase → pool.** `StripeWebhookController` handles `customer.subscription.{created,updated,deleted}` with `metadata.type='centre_seats'` → `OrgBillingService::reconcileFromSubscription()` sets the org-level pool (`organizations.seats_purchased` / `seats_expiry`) and writes a `SeatTransaction` ledger row. Signature verification stays mandatory (`constructEvent`).
2. **Assignment → drawdown.** An admin assigns a student via `EnrolmentService`, which writes the `house_role_user` seat row (`payment_status='active'`, `expiry_date` defaulting to the pool's `seats_expiry`). **Cap-enforced**: throws on "no active seat pool" and when `seatsUsed() >= seats_purchased`.
3. **Access → entitlement.** `EntitlementService::centreSeat()` reads that `house_role_user` row (active `payment_status` + `expiry_date >= today`).

Loop closes: **purchase → pool → cap-enforced assignment → entitlement**, with seats-owned and seats-assigned as independent quantities. RFC §6's original webhook-fan-out is annotated as superseded (commit `c0173ff2`).

---

## 4. Open items — owned, NOT to be defaulted by code

- **Key population (federation Gates 2/3)** — `ReconcileExternalIds` is plumbed but **unrun**; `external_id` is mostly NULL on both `users` and `organizations`. It is a **sequenced two-sided step**: account returns `public_id`s (org by key/slug, user by email); mathapi matches and backfills. **Not done until a real student + org resolve end-to-end across both systems.** Pam runs this with account.
- **D5 — cross-org progress visibility for dual-enrolled minors** — **ruled opt-out**: default-on cross-org visibility, with a per-org restrictable toggle (`organizations.settings.progress_isolation`, RFC §C.5). To be implemented default-on/restrictable when next dispatched. Not built yet.

---

## 5. Branch / deploy state

- `feat/b2b-tenancy` is **held** — no merge to `master`, no deploy. Prod tracks a different branch entirely and is unaffected.
- Local commits are ahead of `origin/feat/b2b-tenancy` (unpushed, by design while held).
- Only doc changes were committed this session (`c0173ff2`, RFC §6 supersede note). No application code changed.

---

## 6. Key file index

| Concern | File |
|---|---|
| Federation token validation (RP, HS256) | `app/Services/Federation/AccountTokenValidator.php`, `app/Http/Middleware/ValidateAccountToken.php` |
| Reconciliation backfill | `app/Console/Commands/ReconcileExternalIds.php`, `app/Services/Federation/AccountReconciliationClient.php` |
| Data-API endpoints | `routes/api.php` (`/api/licensee/dashboard`, `/api/licensor/portfolio`); `app/Http/Controllers/API/LicenseeDashboardController.php`, `LicensorPortfolioController.php` |
| Read-model | `app/Services/Dashboards/AttainmentReadModel.php` |
| Seat billing | `app/Services/Tenancy/OrgBillingService.php`, `EnrolmentService.php`; `app/Services/Entitlement/EntitlementService.php`; `app/Http/Controllers/StripeWebhookController.php` |
| Teacher dashboards | `app/Filament/Teacher/**`, `app/Filament/Pages/LicensorDashboard.php`, `app/Filament/Resources/OrganizationResource*` |
| Contracts (frozen) | `docs/CONTRACT.md`, `docs/TRANSCRIPT-TIERS.md` |
| Design RFC | `docs/sprints/b2b-tenancy-model-2026-06-06.md` |
| account-side (separate repo `c:\projects\account`) | `app/Services/Records/IssuanceService.php`, `JwksService.php`, `RecordKeyStore.php`, `CheckpointAttestationVerifier.php`; `app/Services/Sso/SsoTokenService.php` |
