authoring-vertical-slice · git:20261102.d0f625e · 2026-11-02 · sha256 b04777b5af0ec2f1
authoring-vertical-slice git:20261102.d0f625eA
Immutable. This exact content never changes and is served at /api/v1/blob/b04777b5af0ec2f1.
---
name: authoring-vertical-slice
description: >
The migration -> RLS -> ./client read -> tRPC procedure -> web screen -> mobile screen ->
test recipe for shipping one whole feature slice through the Next 16 web + Expo 57 mobile
+ Supabase monorepo in a single turn. Use when asked to add a feature, procedure, or screen
end-to-end.
allowed-tools: Read, Grep, Glob, Bash(node ${CLAUDE_PLUGIN_ROOT}/skills/authoring-vertical-slice/scripts/scaffold-slice.mjs:*)
argument-hint: "[feature-name]"
---
# Authoring a vertical slice
One backend, two surfaces: a `packages/verticals/<slice>` domain, a tRPC procedure in
`@app/api` that `apps/web` serves and `apps/mobile` consumes, and a Server Action for the
web write. Build the slice in this strict order. Each layer has a lazy reference file — read
it before writing that layer (progressive disclosure keeps context lean). Delegate the two
non-trivial layers to the named subagent.
## Step 0 — read the contracts surface FIRST, and stop if the operation already exists
Before you write anything, read what the API already exposes:
- `tools/generated/action-inventory.json` — every tRPC procedure `appRouter` exposes, one
committed row per procedure (emitted by `tools/gen-action-inventory.mjs`, regen-diffed by
the `contracts` gate).
- `tools/generated/event-catalog.json` — every event the platform and vertical catalogs
declare (emitted by `tools/gen-event-catalog.mjs`).
- `packages/api/src/routers/*.ts` — the routers themselves (`notes.ts` is the worked
example, `system.ts` the health/`me` pair).
If an equivalent operation already exists, STOP and reuse it — a second `list`/`create` for
the same entity is exactly the drift the "one operation, two callers" rule exists to prevent.
Only when nothing covers the need do you scaffold a new slice.
## The order
1. **Migration + RLS** — read `references/migration-rls.md`. The desired shape is a
declarative table in `supabase/schemas/<NN>_<slice>.sql`; the APPLIED change is a NEW
timestamped, append-only migration `supabase/migrations/<timestamp>_<slice>.sql` carrying
a NOT NULL `org_id` tenant key (`owner_id` is nullable attribution that decides nothing),
`ENABLE` + `FORCE ROW LEVEL SECURITY`, four per-operation policies (`TO authenticated`)
scoped by org through the hoisted helpers, `org_id = ANY((SELECT
private.member_org_ids())::uuid[])` to read and the `private.member_ranks()` rank floor
to write (the only two forms the `tenancy` gate accepts), the `freeze_org_id` trigger, a
LEADING `org_id` index, `REVOKE ALL` from `anon`, `service_role` AND `authenticated`,
and then the exact `authenticated` grants the policies admit (a GRANT removes nothing —
see `references/migration-rls.md`). Never a per-user `owner_id = (SELECT auth.uid())`
scope: it passes every cross-tenant test while hiding rows from the colleagues who
should share them. Never a GUC — the helpers resolve membership from the request's
verified JWT (`auth.uid()` reads `request.jwt.claims`), never from an application-set
identity value.
Delegate to the `migration-rls-author` subagent.
2. **RLS tests** — read `references/tests.md`. TWO twins, both run by `pnpm test:rls`:
pgTAP under `supabase/tests/*.sql` (the structural + isolation suites read back what the
database compiled and prove the empty-set principle through raw role-switch), and the
supabase-js client suite under `tests/rls/` (the same boundary through the real PostgREST
+ GoTrue transport a Class-A mobile write takes — tenant B cannot read A). Add the table
to BOTH registries: `rls_targets` in `supabase/tests/rls_structure.test.sql` AND
`ISOLATION_TARGETS` in `tests/rls/db-context.ts`, then regenerate the exact-privilege
assertions with `node tools/gen-grant-assertions.mjs` (never by hand).
3. **`./client` data function** — read `references/dal-dto.md`. The vertical's Metro-safe
barrel (`packages/verticals/<slice>/src/client.ts`) exports the DIRECT RLS READ: it TAKES
a per-request Supabase client, returns zod DTOs from `@app/contracts` wrapped in the
`ActionOutcome` envelope from `@app/errors`, never raw rows and never a thrown domain
failure. Writes stay off `./client` (they set an owner column and emit events — server
barrel only). Delegate to the `dal-author` subagent. Every vertical's `./client` key needs
an entry in the census `tools/exports-walls.json`, Class-B included, and that entry is a
human's step (see Scaffold).
4. **tRPC procedure (+ optional web Server Action)** — same reference. Add the procedure to
`packages/api/src/routers/<slice>.ts` on `orgProcedure` (READS INCLUDED — the acting org
is WHICH DATA a read is about), name an input schema, hand the call to the vertical. If the
web surface writes this entity, add the twin Server Action at
`apps/web/app/actions/<slice>.ts` — SAME contract, SAME vertical implementation, SAME
envelope, different transport. It takes the org as a BOUND argument (the route's slug,
parsed with `OrgSlug`), resolves user and org with `requireOrgContext`, narrows the
client with `to<Slice>Port` from `apps/web/lib/app-data/<slice>-port.ts` (the one cast
apps/web has) and revalidates the org's path. Then the MAIN THREAD regenerates the
committed inventories: `pnpm gen` (the `contracts` gate regen-diffs
`tools/generated/*.json`; the `parity` gate holds the mobile ledger to the action
inventory). Class-B is the DEFAULT — mobile writes through the procedure. Class-A (mobile writes DIRECT to Supabase via the vertical
`./client`) is an explicit, reasoned opt-in, never the reflex: it widens what the
`./client` barrel carries, so the census entry's reason is a security review.
5. **Web screen** — a segment under the org scope,
`apps/web/app/(protected)/o/[orgSlug]/<slice>/`: `page.tsx`, its `page.meta.ts` (id,
`titleKey`, the three state test ids) and `loading.tsx`. The segment IS the tenant
selector; the page reads `orgSlug` from `params`. Read via `apps/web/lib/app-data/<slice>.ts`
(the RSC read seam: `requireOrgContext(orgSlug)` -> `to<Slice>Port(gate.data.client)` ->
vertical `./client`, scoped to the RESOLVED org -> match the outcome -> a render model),
NEVER a Supabase query in a Server Component and NEVER a `fetch()` to the app's own
`/api/trpc`. Render each state as `data-testid={meta.states.<key>}`, add the `titleKey` to
`apps/web/lib/i18n/catalog.ts`, regenerate the registry (`node tools/gen-web-routes.mjs`)
and name one state id in a spec under `apps/web/e2e`: `route-manifest` holds all four.
Writes go through the Server Action from step 4. `getUser()`/`getClaims()`
server-side for rendering decisions — never `getSession()` (it decodes an
attacker-controlled cookie without verifying the signature). RLS-scoped reads are never
cached on a shared key.
6. **Mobile screen** — read `references/mobile-screen.md`. An expo-router screen under
`apps/mobile/app/**` composing a `apps/mobile/src/features/<slice>/` feature; data via the
mobile tRPC client (`src/lib/trpc/client.ts`, reached through `useApi()` and folded to the
envelope by `callProcedure`) for a Class-B slice, or the vertical `./client` for a Class-A
read. REGISTER the screen in `src/routes.ts` (id, titleKey, path, file, state testIDs) and
render its root as `<Screen testID="<route-id>-screen">` — the device lane asserts that
container id for every `ROUTES` entry. Styling ONLY through `@app/design-tokens` (via
`useThemedStyles((palette) => ...)`); every string a catalog key; a11y from the
`src/components` primitives. BEFORE composing, read the `designing-mobile-ui` skill's
checklist for the surface type.
7. **Tests + provenance + green gate** — read `references/tests.md`. Unit tests in the right
runner (vitest for pure domain/logic; jest-expo for RN components/screens; fast-check for
any parser), each holding the per-file coverage floor. Delegate to the `test-author`
subagent. Then provenance, in this order: (a) `// SOURCE:` (`--` in SQL) on every
non-trivial decision; (b) emit the ADR via `/adr <slice>` so
`docs/adr/<YYYYMMDD>-<slice>.md` exists and reconciles with the inline citations; (c) run
`/verify-citations` and require `CITATIONS: CLEAN`. Finish only when the ADR exists,
citations are CLEAN, the `design-reviewer` answers `PASS` on any UI the slice touched, and
`pnpm validate`, `pnpm test:rls`, `pnpm test`, and `pnpm test:mobile` are all green. The
Stop hook re-runs the same steps; do not stop on a red build or with provenance
incomplete.
## Scaffold
The MAIN THREAD scaffolds the empty skeleton (the `dal-author` subagent has no Bash):
```
node ${CLAUDE_PLUGIN_ROOT}/skills/authoring-vertical-slice/scripts/scaffold-slice.mjs <slice>
```
`<slice>` is a single kebab-case argument (e.g. `release-notes`). The script is idempotent:
it writes a file only if it does not already exist. It writes the worked example's shape: the
vertical's `src/` with every data seam (`data/{port,rows,errors,query-probes,<slice>}.ts`), the
router, the Server Action, the web segment `apps/web/app/(protected)/o/[orgSlug]/<slice>/`
(`page.tsx`, `page.meta.ts`, `loading.tsx`), the read seam `apps/web/lib/app-data/<slice>.ts`,
`apps/web/lib/app-data/<slice>-port.ts` (the one narrowing function; no other stub casts) and
the mobile feature. A stub that cannot compile until the vertical resolves is comment-only.
Each step the script must not take itself is a printed `next:` line (among them the catalog
key, the registry regen, the browser spec, the query probes and the vertical's
`package.json`); until they are done, `route-manifest` and `query-shapes` red with the
findings those lines name. One `next:` line is a human's, not yours: the census entry for the
new `./client` key. `tools/exports-walls.json` is harness-owned and hash-pinned, so the write
guard denies your edit. Hand the `{package, reason}` entry to a human, who adds it under
security-owner review and re-records the file's sha256 in `.harness/manifest.json` in the same
reviewed commit; until then `boundaries` reds the package as NOT sanctioned. It deliberately
does NOT create the
migration file — `supabase/migrations/*` is append-only (timestamped, applied history), so a
pre-created stub could never be filled in and would only invite a hand-edit of applied
history. Compose the migration completely with `supabase migration new <slice>`, then write
it once.
## IP boundary
Keep reusable platform abstractions separate from bespoke feature code. A vertical MUST NOT
import another vertical — cross-feature logic belongs in `packages/shared`, promoted
deliberately. Never bake customer content into shared modules.