Files
agentic-dev/docs/superpowers/plans/2026-05-06-plan-9-io-unification.md
Danijel Martinek 88db39b3a3 docs(plan): Plan 9 — input/output unification implementation plan
9 tasks, TDD throughout, one commit per task:

1. Refactor changelog scaffold
2. core-shared/trpc/define-error-middleware.ts factory + tests
3. auth migration (3 use cases — schemas + presenter + procedures.ts + router + ./ui + tests)
4. blog migration (3 use cases)
5. marketing-pages migration (2 use cases)
6. navigation migration (1 use case)
7. media migration (3 use cases)
8. Final verification + boundary sweep
9. ADR-013 + final changelog summary

Every step has concrete code; no placeholders. Self-review shows full
spec coverage (R1–R30) with each rule mapped to one or more tasks.

Spec: docs/superpowers/specs/2026-05-06-input-output-unification-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:29:28 +02:00

2723 lines
100 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Plan 9 — Input/Output Unification + Presenter Pattern + Feature-Scoped Error Mapping + Public-Surface Cleanup
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make every use case the single source of truth for its input AND output contract. Schemas live in the use-case file, are imported by both the controller and the tRPC procedure, and the use-case body runtime-validates output via `.parse(...)`. Controllers gain co-located `function presenter(...)` (Lazar pattern). Domain errors map to `TRPCError` via per-feature middleware (`defineErrorMiddleware` factory in `core-shared/trpc/`). Per-feature public surface is split: root `.` = contracts; new `./ui` subpath = UI artifacts.
**Architecture:** Use-case file becomes the only place an input/output shape is defined; everywhere else imports `xInputSchema` / `xOutputSchema` / `XInput` / `XOutput`. Each feature gets `integrations/api/procedures.ts` exporting an `xProcedure` built from `t.procedure.use(defineErrorMiddleware([[ErrorCtor, "TRPC_CODE"], ...]))`. Routers swap `publicProcedure``xProcedure` and `.input(z.object({...}))``.input(xInputSchema)`. Each feature's `src/index.ts` keeps only contracts (types, errors, schemas, `IXUseCase` aliases, router type, constants). Query builders move to `src/ui/index.ts` exposed via the new `./ui` subpath.
**Tech Stack:** TypeScript, Zod 3, tRPC 11, InversifyJS 6, Vitest 3, `@repo/core-shared`, `@repo/core-testing`.
**Spec:** `docs/superpowers/specs/2026-05-06-input-output-unification-design.md` — read first if any task is unclear.
**Branch:** `feature/io-unification` (or execute on `main` directly per project convention).
**Refactor changelog:** Maintain `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md` throughout — every task ends with a changelog update.
---
## Cross-cutting conventions (re-read at start of every task)
- **TDD always** — failing test, run, RED, implement, run, GREEN, refactor, commit.
- **Source files use relative imports**; test files use `@/` alias.
- **No central error map** — `core-shared` MUST NOT enumerate any feature's error class. Each feature's `procedures.ts` owns its tuples.
- **Object-shaped inputs only** — every `xInputSchema` is a `z.ZodObject` (use `z.object({}).strict()` for void; wrap primitive args in an object).
- **Presenter always for non-void output** — even identity `(x) => x` (R11). Void use cases skip the presenter (R12).
- **Don't touch CLAUDE.md / AGENTS.md / docs/guides/** during this plan — those updates are deferred per spec §10.
- **Commit per task** with the message in each task's final step.
- **After each task** — `pnpm typecheck && pnpm lint && pnpm test && pnpm turbo boundaries`. All green.
- **Update the refactor changelog** at the end of each task before commit.
---
## Task 1: Refactor changelog scaffold
**Files:**
- Create: `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md`
- [ ] **Step 1: Verify the directory exists**
```bash
ls docs/superpowers/refactor-logs/
```
Expected: existing `2026-05-05-lazar-pattern-conformance.md` is listed; the directory exists.
- [ ] **Step 2: Create the changelog file**
Write `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md` with this exact content:
```markdown
# Refactor Changelog — Input/Output Unification + Presenter + Error Middleware + Public-Surface Cleanup
**Started:** 2026-05-06
**Spec:** [2026-05-06-input-output-unification-design.md](../specs/2026-05-06-input-output-unification-design.md)
**Plan:** [2026-05-06-plan-9-io-unification.md](../plans/2026-05-06-plan-9-io-unification.md)
**Branch:** feature/io-unification
This document captures every architectural change made during Plan 9
execution, organized by category. After the plan is merged, use the
"Doc update checklist" at the bottom to update external docs in a
single follow-up pass — combined with the still-pending Plan 8
doc-update items so docs are written once for the post-Plan-9 state.
---
## 1. Files added
(populated as work progresses)
## 2. Files modified
(populated as work progresses)
## 3. Pattern changes (code-level)
### 3.1 Use-case files — input + output schemas + runtime parse
(populated when use cases are migrated)
### 3.2 Controller files — presenter + unknown input + view return type
(populated when controllers are migrated)
### 3.3 tRPC integration — feature-scoped procedures, schema reuse from use cases
(populated when routers are migrated)
## 4. Error-middleware adoption
(populated when defineErrorMiddleware is wired in core-shared and consumed by features)
## 5. Public-API surface
### 5.1 ./ui subpath added per feature
(populated as features adopt the subpath)
### 5.2 Feature root index.ts cleanup
(populated as features clean up their root exports)
## 6. Test additions
### 6.1 R25 — output-validation tests (use case)
(populated when use cases gain malformed-input "repo lied" tests)
### 6.2 R26 — router error-mapping tests
(populated when feature routers gain TRPCError-translation tests)
### 6.3 R27/R28 — presenter shape tests
(populated when controllers with non-identity presenters add view-shape assertions)
## 7. Open issues / deferred decisions
(populated as encountered)
---
## Doc update checklist (deferred — combined with paused Plan 8 doc pass)
After Plan 9 lands, the still-pending Plan 8 doc-update items resume
and now also pick up Plan 9 rules. Each item below covers BOTH plans'
material so we touch every file once.
- [ ] `CLAUDE.md` — Key Conventions: append schema-in-use-case rule, presenter rule, controller `unknown` input rule, `./ui` subpath rule, schema-export-from-root rule
- [ ] `AGENTS.md` (root) — Per-Package Conventions: document `./ui` subpath, schema reachability from feature root, factory-bound use cases / controllers (carry-over from Plan 8)
- [ ] `docs/guides/adding-a-feature.md` — restructure to use the Plan-9 use-case template (with input + output schemas + parse), the Plan-9 controller template (with presenter), and the new `procedures.ts` step (per-feature error map)
- [ ] `docs/guides/tdd-workflow.md` — update mock decision tree + worked example to factory injection (Plan 8) + R25 output-validation test pattern (Plan 9) + R26 router-error-mapping test pattern (Plan 9)
- [ ] `docs/guides/testing-strategy.md` — Mocking section: direct factory injection (Plan 8); R25/R26/R27/R28 test obligations (Plan 9)
- [ ] `docs/architecture/vertical-feature-spec.md` — §6 file shape: schemas + presenter + procedures.ts; §10 testing: R25/R26/R27/R28
- [ ] `docs/architecture/overview.md` — data-flow box: add input schema, output schema, presenter, error-middleware lanes
- [ ] `docs/architecture/dependency-flow.md` — verify (no expected change beyond a `./ui` mention)
- [ ] `docs/decisions/adr-012-lazar-conformance.md` — append note that input/output unification + presenter + error middleware land in ADR-013
- [ ] `docs/decisions/adr-013-input-output-unification.md` — created in this plan's final task (Task 9); link from prior ADRs
- [ ] Per-feature `AGENTS.md` (auth/blog/media/marketing-pages/navigation) — Plan 8 file paths + Plan 9 schema/presenter/procedures patterns
- [ ] `packages/core-testing/AGENTS.md` — note R25/R26 test patterns
- [ ] `packages/core-shared/AGENTS.md` — document `defineErrorMiddleware` and the `t` re-export
- [ ] Plan 8 plan/spec — add a one-line annotation that some controller/router patterns shifted in Plan 9; link to this changelog
---
## Notes for the doc-update pass author
When updating external docs, apply these substitutions globally:
| Old reference | New reference |
|---|---|
| Use case takes `{ x }` directly typed | Use case takes `XInput` (`z.infer<typeof xInputSchema>`); schema lives in same file |
| Use case returns `Promise<Entity>` | Use case returns `Promise<XOutput>` validated by `xOutputSchema.parse(...)` |
| Controller imports `z` and defines local `inputSchema` | Controller imports `xInputSchema` from the use-case file |
| Controller input typed `Partial<z.infer<...>>` | Controller input typed `unknown` |
| Controller returns `Promise<Entity>` | Controller has `function presenter(value: XOutput)`; returns `Promise<ReturnType<typeof presenter>>` (or `Promise<void>` for void use cases) |
| Router uses `publicProcedure` from `core-shared/trpc/init` | Router uses feature's `xProcedure` from `./procedures` |
| Router redefines input schema as `z.object({...})` in `.input(...)` | Router uses `.input(xInputSchema)` imported from the use-case file |
| Domain error reaches the wire as a generic `TRPCError({ code: "INTERNAL_SERVER_ERROR" })` | Domain error mapped to specific code by `defineErrorMiddleware` in `procedures.ts` |
| Frontend imports `articleBySlugQuery` from `@repo/blog` | Frontend imports from `@repo/blog/ui` |
```
- [ ] **Step 3: Verify the file is readable**
```bash
ls -la docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md
head -20 docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md
```
- [ ] **Step 4: Commit**
```bash
git add docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md
git commit -m "$(cat <<'EOF'
docs(refactor-log): scaffold Plan 9 input/output unification changelog
Empty section template plus the doc-update checklist that the
post-Plan-9 follow-up pass will work through (combined with the still-
pending Plan 8 items so docs are written once).
Spec: docs/superpowers/specs/2026-05-06-input-output-unification-design.md §10, R30.
EOF
)"
```
---
## Task 2: `core-shared` plumbing — `defineErrorMiddleware`
**Files:**
- Modify: `packages/core-shared/src/trpc/init.ts` (export `t` instance)
- Create: `packages/core-shared/src/trpc/define-error-middleware.ts`
- Create: `packages/core-shared/src/trpc/define-error-middleware.test.ts`
- Modify: `packages/core-shared/package.json` (add export `./trpc/define-error-middleware`)
- [ ] **Step 1: Read the current `init.ts`**
```bash
cat packages/core-shared/src/trpc/init.ts
```
Expected to see (verify exact content before editing):
```typescript
import { initTRPC } from "@trpc/server";
import superjson from "superjson";
const t = initTRPC.create({
transformer: superjson,
});
export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;
```
- [ ] **Step 2: Add `t` to the exports of `init.ts`**
Edit `packages/core-shared/src/trpc/init.ts`. Change `const t = initTRPC.create(...)` to `export const t = initTRPC.create(...)`. Final file:
```typescript
import { initTRPC } from "@trpc/server";
import superjson from "superjson";
export const t = initTRPC.create({
transformer: superjson,
});
export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;
```
- [ ] **Step 3: Write the failing test**
Create `packages/core-shared/src/trpc/define-error-middleware.test.ts`:
```typescript
import { describe, expect, it } from "vitest";
import { TRPCError } from "@trpc/server";
import { t } from "@/trpc/init";
import { defineErrorMiddleware } from "@/trpc/define-error-middleware";
class FooNotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = "FooNotFoundError";
}
}
class FooBadRequestError extends Error {
constructor(message: string) {
super(message);
this.name = "FooBadRequestError";
}
}
const errorRouter = t.router({
notFound: t.procedure
.use(
defineErrorMiddleware([
[FooNotFoundError, "NOT_FOUND"],
[FooBadRequestError, "BAD_REQUEST"],
]),
)
.query(() => {
throw new FooNotFoundError("nope");
}),
badRequest: t.procedure
.use(
defineErrorMiddleware([
[FooNotFoundError, "NOT_FOUND"],
[FooBadRequestError, "BAD_REQUEST"],
]),
)
.query(() => {
throw new FooBadRequestError("oops");
}),
unmapped: t.procedure
.use(defineErrorMiddleware([[FooNotFoundError, "NOT_FOUND"]]))
.query(() => {
throw new Error("plain");
}),
});
describe("defineErrorMiddleware", () => {
it("translates a mapped error to TRPCError with the configured code", async () => {
const caller = errorRouter.createCaller({});
await expect(caller.notFound()).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
it("translates a different mapped error to a different code", async () => {
const caller = errorRouter.createCaller({});
await expect(caller.badRequest()).rejects.toMatchObject({
code: "BAD_REQUEST",
});
});
it("rethrows unmapped errors without translation", async () => {
const caller = errorRouter.createCaller({});
await expect(caller.unmapped()).rejects.toBeInstanceOf(Error);
// Not a TRPCError — propagates so tRPC default handling kicks in
await expect(caller.unmapped()).rejects.not.toBeInstanceOf(TRPCError);
});
it("preserves the original error as the cause", async () => {
const caller = errorRouter.createCaller({});
try {
await caller.notFound();
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(TRPCError);
const trpcErr = e as TRPCError;
expect(trpcErr.cause).toBeInstanceOf(FooNotFoundError);
expect((trpcErr.cause as Error).message).toBe("nope");
}
});
});
```
- [ ] **Step 4: Run the test to verify RED**
```bash
pnpm test --filter @repo/core-shared -- define-error-middleware
```
Expected: FAIL with `Cannot find module '@/trpc/define-error-middleware'` (or equivalent — the module doesn't exist yet).
- [ ] **Step 5: Implement `defineErrorMiddleware`**
Create `packages/core-shared/src/trpc/define-error-middleware.ts`:
```typescript
import { TRPCError } from "@trpc/server";
import type { TRPC_ERROR_CODE_KEY } from "@trpc/server/rpc";
import { t } from "./init";
type ErrorCtor = new (...args: never[]) => Error;
/**
* Build a tRPC middleware that translates domain errors to TRPCError.
*
* Each tuple pairs a constructor with a TRPC error code. The middleware
* runs the procedure body inside a try/catch; on `instanceof Ctor`,
* it throws a TRPCError with the configured code and the original error
* preserved as `.cause`. Unmapped errors propagate untouched (tRPC's
* default INTERNAL_SERVER_ERROR handling applies).
*
* Owned by features: each feature passes its own constructors in.
* core-shared never enumerates feature-specific error classes.
*/
export function defineErrorMiddleware(
map: ReadonlyArray<readonly [ErrorCtor, TRPC_ERROR_CODE_KEY]>,
) {
return t.middleware(async ({ next }) => {
try {
return await next();
} catch (e) {
if (e instanceof Error) {
for (const [Ctor, code] of map) {
if (e instanceof Ctor) {
throw new TRPCError({ code, message: e.message, cause: e });
}
}
}
throw e;
}
});
}
```
- [ ] **Step 6: Run the test to verify GREEN**
```bash
pnpm test --filter @repo/core-shared -- define-error-middleware
```
Expected: 4 tests pass.
- [ ] **Step 7: Add the new module to `package.json` exports**
Edit `packages/core-shared/package.json`. Add a new entry under `exports`:
```jsonc
{
"exports": {
".": "./src/index.ts",
"./payload": "./src/payload/index.ts",
"./trpc/init": "./src/trpc/init.ts",
"./trpc/context": "./src/trpc/context.ts",
"./trpc/define-error-middleware": "./src/trpc/define-error-middleware.ts"
}
}
```
- [ ] **Step 8: Verify boundaries + typecheck**
```bash
pnpm install
pnpm typecheck
pnpm lint
pnpm test
pnpm turbo boundaries
```
All green. If `pnpm install` is a no-op because `package.json` only added an `exports` entry, that's fine.
- [ ] **Step 9: Update changelog**
Append to `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md`:
Under §1 Files added:
```
- packages/core-shared/src/trpc/define-error-middleware.ts — middleware factory mapping [ErrorCtor, TRPC_CODE] tuples to TRPCError translation
- packages/core-shared/src/trpc/define-error-middleware.test.ts — 4 tests covering mapped translation, multiple codes, unmapped passthrough, cause preservation
```
Under §2 Files modified:
```
- packages/core-shared/src/trpc/init.ts — `t` instance now exported (was internal const) so feature procedures.ts can do `t.procedure.use(...)`
- packages/core-shared/package.json — added "./trpc/define-error-middleware" subpath export
```
Under §4 Error-middleware adoption:
```
- core-shared infrastructure landed; feature routers will adopt in Tasks 3-7.
- Discriminator: `instanceof Ctor` (not error name string), so duck-typing is impossible — features pass their own class constructors.
- Cause preservation: TRPCError carries the original domain error in `.cause` for client structured-error inspection.
```
- [ ] **Step 10: Commit**
```bash
git add packages/core-shared docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md
git commit -m "$(cat <<'EOF'
feat(core-shared): add defineErrorMiddleware factory + export t
Factory takes [[ErrorCtor, TRPC_CODE], ...] tuples and returns a tRPC
middleware that translates matching domain errors to TRPCError. Discrim-
inates by instanceof; preserves original error as cause; unmapped
errors propagate.
core-shared never enumerates feature errors — each feature passes its
own constructors in via integrations/api/procedures.ts (Tasks 3-7).
Also exports the `t` instance from trpc/init.ts so feature procedure
files can do t.procedure.use(...).
Refactor log: §1, §2, §4
Spec: R13R17
EOF
)"
```
---
## Task 3: Migrate `auth` feature
**Files:**
- Modify: `packages/auth/src/application/use-cases/sign-in.use-case.ts`
- Modify: `packages/auth/src/application/use-cases/sign-in.use-case.test.ts`
- Modify: `packages/auth/src/application/use-cases/sign-up.use-case.ts`
- Modify: `packages/auth/src/application/use-cases/sign-up.use-case.test.ts`
- Modify: `packages/auth/src/application/use-cases/sign-out.use-case.ts`
- Modify: `packages/auth/src/application/use-cases/sign-out.use-case.test.ts`
- Modify: `packages/auth/src/interface-adapters/controllers/sign-in.controller.ts`
- Modify: `packages/auth/src/interface-adapters/controllers/sign-in.controller.test.ts`
- Modify: `packages/auth/src/interface-adapters/controllers/sign-up.controller.ts`
- Modify: `packages/auth/src/interface-adapters/controllers/sign-up.controller.test.ts`
- Modify: `packages/auth/src/interface-adapters/controllers/sign-out.controller.ts`
- Modify: `packages/auth/src/interface-adapters/controllers/sign-out.controller.test.ts`
- Create: `packages/auth/src/integrations/api/procedures.ts`
- Modify: `packages/auth/src/integrations/api/router.ts`
- Modify or Create: `packages/auth/src/integrations/api/router.test.ts` (add R26)
- Create: `packages/auth/src/ui/index.ts`
- Modify: `packages/auth/src/index.ts` (export schemas)
- Modify: `packages/auth/package.json` (add `./ui` subpath)
### 3.A — sign-in use case (input + output schemas + R25 test)
- [ ] **Step 1: Read current sign-in use case**
```bash
cat packages/auth/src/application/use-cases/sign-in.use-case.ts
```
Note current input shape (`{ username, password }`) and current return type (`Promise<{ session: Session; cookie: Cookie }>`).
- [ ] **Step 2: Write a failing R25 output-validation test FIRST**
Edit `packages/auth/src/application/use-cases/sign-in.use-case.test.ts`. Add a new test (keep existing tests intact):
```typescript
import { signInOutputSchema } from "@/application/use-cases/sign-in.use-case";
// ... (preserve other imports)
describe("signInUseCase output validation (R25)", () => {
it("throws when authenticationService returns a malformed session", async () => {
const users = new MockUsersRepository();
const seed = userFactory.build({ username: "alice" });
await users.createUser(seed);
const auth = {
verifyPassword: async () => true,
// session missing required fields → should fail signInOutputSchema.parse
createSession: async () => ({ session: { id: 123 }, cookie: null }),
} as unknown as IAuthenticationService;
const useCase = signInUseCase(users, auth);
await expect(useCase({ username: "alice", password: "x" })).rejects.toThrow(/parse|invalid/i);
});
it("exports an output schema that mirrors the success shape", () => {
expect(signInOutputSchema).toBeDefined();
const parsed = signInOutputSchema.safeParse({
session: { id: "s1", userId: "u1", expiresAt: new Date() },
cookie: { name: "session", value: "s1", attributes: {} },
});
expect(parsed.success).toBe(true);
});
});
```
(Add `IAuthenticationService` to the imports if it's not already there; resolve from `@/application/services/authentication.service.interface`.)
- [ ] **Step 3: Run RED**
```bash
pnpm test --filter @repo/auth -- sign-in.use-case
```
Expected: new tests FAIL — `signInOutputSchema` is not exported.
- [ ] **Step 4: Update `sign-in.use-case.ts` with input + output schemas + parse**
Replace `packages/auth/src/application/use-cases/sign-in.use-case.ts` with:
```typescript
import { z } from "zod";
import { AuthenticationError } from "../../entities/errors/auth";
import { cookieSchema } from "../../entities/models/cookie";
import { sessionSchema } from "../../entities/models/session";
import type { IUsersRepository } from "../repositories/users.repository.interface";
import type { IAuthenticationService } from "../services/authentication.service.interface";
// ── Input ────────────────────────────────────────────────────────────────
export const signInInputSchema = z
.object({
username: z.string().min(3).max(31),
password: z.string().min(6).max(255),
})
.strict();
export type SignInInput = z.infer<typeof signInInputSchema>;
// ── Output ───────────────────────────────────────────────────────────────
export const signInOutputSchema = z.object({
session: sessionSchema,
cookie: cookieSchema,
});
export type SignInOutput = z.infer<typeof signInOutputSchema>;
// ── Use case ─────────────────────────────────────────────────────────────
export type ISignInUseCase = ReturnType<typeof signInUseCase>;
export const signInUseCase =
(usersRepository: IUsersRepository, authenticationService: IAuthenticationService) =>
async (input: SignInInput): Promise<SignInOutput> => {
const existingUser = await usersRepository.getUserByUsername(input.username);
if (!existingUser) {
throw new AuthenticationError("User does not exist");
}
const validPassword = await authenticationService.verifyPassword(
existingUser.passwordHash,
input.password,
);
if (!validPassword) {
throw new AuthenticationError("Incorrect username or password");
}
const result = await authenticationService.createSession(existingUser);
return signInOutputSchema.parse(result);
};
```
If `cookieSchema` and `sessionSchema` aren't already exported from their entity files, this is the moment to add the exports (they ARE typically exported per entity-models convention; verify):
```bash
grep -n "export const sessionSchema\|export const cookieSchema" packages/auth/src/entities/models/*.ts
```
If a schema export is missing, add it (the entity file already has the Zod schema definition; just `export` it).
- [ ] **Step 5: Run GREEN**
```bash
pnpm test --filter @repo/auth -- sign-in.use-case
```
Expected: all sign-in use-case tests pass (preexisting + new R25 ones).
### 3.B — sign-up use case (same pattern)
- [ ] **Step 6: Update `sign-up.use-case.ts` analogously**
Read the current file, then add:
```typescript
export const signUpInputSchema = z
.object({
username: z.string().min(3).max(31),
password: z.string().min(6).max(255),
confirmPassword: z.string().min(6).max(255),
})
.strict()
.refine((d) => d.password === d.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
export type SignUpInput = z.infer<typeof signUpInputSchema>;
export const signUpOutputSchema = z.object({
session: sessionSchema,
cookie: cookieSchema,
});
export type SignUpOutput = z.infer<typeof signUpOutputSchema>;
```
Update the factory signature: `(input: SignUpInput): Promise<SignUpOutput>`. End body with `return signUpOutputSchema.parse(result)`.
- [ ] **Step 7: Add R25 test for sign-up + run**
Mirror the sign-in R25 test in `sign-up.use-case.test.ts`, then:
```bash
pnpm test --filter @repo/auth -- sign-up.use-case
```
Expected: all green.
### 3.C — sign-out use case (void output)
- [ ] **Step 8: Update `sign-out.use-case.ts`**
Sign-out has input `{ sessionId: string }` and returns `void`. Per R5 (uniform input) + R12 (no presenter for void):
```typescript
import { z } from "zod";
import type { IAuthenticationService } from "../services/authentication.service.interface";
export const signOutInputSchema = z.object({ sessionId: z.string() }).strict();
export type SignOutInput = z.infer<typeof signOutInputSchema>;
// No xOutputSchema — use case returns void.
export type ISignOutUseCase = ReturnType<typeof signOutUseCase>;
export const signOutUseCase =
(authenticationService: IAuthenticationService) =>
async (input: SignOutInput): Promise<void> => {
await authenticationService.invalidateSession(input.sessionId);
};
```
- [ ] **Step 9: Run sign-out tests**
```bash
pnpm test --filter @repo/auth -- sign-out.use-case
```
Expected: all green. (Sign-out has no R25 test — void output has nothing to validate.)
### 3.D — sign-in controller (presenter + unknown input)
- [ ] **Step 10: Update sign-in controller test FIRST**
Edit `packages/auth/src/interface-adapters/controllers/sign-in.controller.test.ts`. The new contract: controller takes `unknown`, returns the presenter's view (here: a Cookie). Change the existing tests to construct invalid input as `unknown` and verify the schema error path; verify the success path returns the cookie shape.
```typescript
import { describe, it, expect } from "vitest";
import { signInController } from "@/interface-adapters/controllers/sign-in.controller";
import { signInUseCase } from "@/application/use-cases/sign-in.use-case";
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
import { InputParseError } from "@/entities/errors/common";
import { userFactory } from "@/__factories__/user.factory";
describe("signInController", () => {
it("returns a cookie on successful sign-in", async () => {
const users = new MockUsersRepository();
const auth = new MockAuthenticationService(users);
const seedUser = userFactory.build({ username: "alice" });
await users.createUser(seedUser);
const useCase = signInUseCase(users, auth);
const controller = signInController(useCase);
const result = await controller({
username: "alice",
password: seedUser.passwordHash.replace("hashed_", ""),
});
expect(result).toBeDefined();
expect(result.name).toBeTruthy();
expect(result.value).toBeTruthy();
});
it("throws InputParseError on invalid input", async () => {
const users = new MockUsersRepository();
const auth = new MockAuthenticationService(users);
const useCase = signInUseCase(users, auth);
const controller = signInController(useCase);
await expect(controller({ username: "ab" } as unknown)).rejects.toBeInstanceOf(InputParseError);
});
it("throws InputParseError when input is not an object", async () => {
const users = new MockUsersRepository();
const auth = new MockAuthenticationService(users);
const useCase = signInUseCase(users, auth);
const controller = signInController(useCase);
await expect(controller("garbage" as unknown)).rejects.toBeInstanceOf(InputParseError);
});
});
```
- [ ] **Step 11: Run RED**
```bash
pnpm test --filter @repo/auth -- sign-in.controller
```
Expected: FAIL — controller still has old shape, third test (`"garbage"`) might pass by coincidence; the type signature check on input may be the visible failure.
- [ ] **Step 12: Update sign-in controller**
Replace `packages/auth/src/interface-adapters/controllers/sign-in.controller.ts` with:
```typescript
import { InputParseError } from "../../entities/errors/common";
import {
signInInputSchema,
type ISignInUseCase,
type SignInOutput,
} from "../../application/use-cases/sign-in.use-case";
function presenter(value: SignInOutput) {
return value.cookie;
}
export type ISignInController = ReturnType<typeof signInController>;
export const signInController =
(signInUseCase: ISignInUseCase) =>
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = signInInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid sign-in input", { cause: parsed.error });
}
const result = await signInUseCase(parsed.data);
return presenter(result);
};
```
- [ ] **Step 13: Run GREEN**
```bash
pnpm test --filter @repo/auth -- sign-in.controller
```
Expected: 3 tests pass.
### 3.E — sign-up controller (same pattern)
- [ ] **Step 14: Update `sign-up.controller.ts` and its test**
Test pattern mirrors sign-in. Controller pattern:
```typescript
import { InputParseError } from "../../entities/errors/common";
import {
signUpInputSchema,
type ISignUpUseCase,
type SignUpOutput,
} from "../../application/use-cases/sign-up.use-case";
function presenter(value: SignUpOutput) {
return value.cookie;
}
export type ISignUpController = ReturnType<typeof signUpController>;
export const signUpController =
(signUpUseCase: ISignUpUseCase) =>
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = signUpInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid sign-up input", { cause: parsed.error });
}
const result = await signUpUseCase(parsed.data);
return presenter(result);
};
```
Run `pnpm test --filter @repo/auth -- sign-up.controller`. Expected: green.
### 3.F — sign-out controller (no presenter — void)
- [ ] **Step 15: Update `sign-out.controller.ts` and its test**
Controller (R12 — no presenter, returns `Promise<void>`):
```typescript
import { InputParseError } from "../../entities/errors/common";
import {
signOutInputSchema,
type ISignOutUseCase,
} from "../../application/use-cases/sign-out.use-case";
export type ISignOutController = ReturnType<typeof signOutController>;
export const signOutController =
(signOutUseCase: ISignOutUseCase) =>
async (input: unknown): Promise<void> => {
const parsed = signOutInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid sign-out input", { cause: parsed.error });
}
await signOutUseCase(parsed.data);
};
```
Test pattern (the existing test likely passes a string sessionId — change to pass an object):
```typescript
import { describe, it, expect } from "vitest";
import { signOutController } from "@/interface-adapters/controllers/sign-out.controller";
import { signOutUseCase } from "@/application/use-cases/sign-out.use-case";
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
import { InputParseError } from "@/entities/errors/common";
describe("signOutController", () => {
it("returns void on successful sign-out", async () => {
const users = new MockUsersRepository();
const auth = new MockAuthenticationService(users);
const useCase = signOutUseCase(auth);
const controller = signOutController(useCase);
const result = await controller({ sessionId: "any" });
expect(result).toBeUndefined();
});
it("throws InputParseError on missing sessionId", async () => {
const users = new MockUsersRepository();
const auth = new MockAuthenticationService(users);
const useCase = signOutUseCase(auth);
const controller = signOutController(useCase);
await expect(controller({} as unknown)).rejects.toBeInstanceOf(InputParseError);
});
});
```
Run: `pnpm test --filter @repo/auth -- sign-out.controller`. Expected: green.
### 3.G — `procedures.ts` (auth error map)
- [ ] **Step 16: Create `packages/auth/src/integrations/api/procedures.ts`**
```typescript
import { t } from "@repo/core-shared/trpc/init";
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
import {
AuthenticationError,
UnauthenticatedError,
UnauthorizedError,
} from "../../entities/errors/auth";
import { InputParseError } from "../../entities/errors/common";
export const authProcedure = t.procedure.use(
defineErrorMiddleware([
[InputParseError, "BAD_REQUEST"],
[AuthenticationError, "UNAUTHORIZED"],
[UnauthenticatedError, "UNAUTHORIZED"],
[UnauthorizedError, "FORBIDDEN"],
]),
);
```
### 3.H — Router migration
- [ ] **Step 17: Replace router with feature procedure + schema imports**
Replace `packages/auth/src/integrations/api/router.ts`:
```typescript
import { router } from "@repo/core-shared/trpc/init";
import { authContainer } from "../../di/container";
import { AUTH_SYMBOLS } from "../../di/symbols";
import { signInInputSchema } from "../../application/use-cases/sign-in.use-case";
import { signUpInputSchema } from "../../application/use-cases/sign-up.use-case";
import { signOutInputSchema } from "../../application/use-cases/sign-out.use-case";
import type { ISignInController } from "../../interface-adapters/controllers/sign-in.controller";
import type { ISignUpController } from "../../interface-adapters/controllers/sign-up.controller";
import type { ISignOutController } from "../../interface-adapters/controllers/sign-out.controller";
import { authProcedure } from "./procedures";
export const authRouter = router({
signIn: authProcedure.input(signInInputSchema).mutation(({ input }) => {
const ctrl = authContainer.get<ISignInController>(AUTH_SYMBOLS.ISignInController);
return ctrl(input);
}),
signUp: authProcedure.input(signUpInputSchema).mutation(({ input }) => {
const ctrl = authContainer.get<ISignUpController>(AUTH_SYMBOLS.ISignUpController);
return ctrl(input);
}),
signOut: authProcedure.input(signOutInputSchema).mutation(({ input }) => {
const ctrl = authContainer.get<ISignOutController>(AUTH_SYMBOLS.ISignOutController);
return ctrl(input);
}),
});
export type AuthRouter = typeof authRouter;
```
### 3.I — Router test (R26 error mapping)
- [ ] **Step 18: Read existing `router.test.ts` (if any)**
```bash
ls packages/auth/src/integrations/api/
cat packages/auth/src/integrations/api/router.test.ts 2>/dev/null
```
If it doesn't exist, create it. If it exists, add R26 tests to it.
- [ ] **Step 19: Write/extend router test for R26**
Create or modify `packages/auth/src/integrations/api/router.test.ts`:
```typescript
import { describe, it, expect, beforeEach } from "vitest";
import { TRPCError } from "@trpc/server";
import { authRouter } from "@/integrations/api/router";
import { authContainer } from "@/di/container";
import { AUTH_SYMBOLS } from "@/di/symbols";
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
import type { IUsersRepository } from "@/application/repositories/users.repository.interface";
import type { IAuthenticationService } from "@/application/services/authentication.service.interface";
describe("authRouter (R26 error mapping)", () => {
beforeEach(() => {
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
}
if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) {
authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService);
}
const users = new MockUsersRepository();
const auth = new MockAuthenticationService(users);
authContainer.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository).toConstantValue(users);
authContainer
.bind<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService)
.toConstantValue(auth);
});
it("translates AuthenticationError → UNAUTHORIZED on missing user", async () => {
const caller = authRouter.createCaller({});
try {
await caller.signIn({ username: "ghost", password: "long-enough" });
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(TRPCError);
expect((e as TRPCError).code).toBe("UNAUTHORIZED");
}
});
it("translates BAD_REQUEST when zod parse fails at the procedure boundary", async () => {
const caller = authRouter.createCaller({});
try {
await caller.signIn({ username: "ab", password: "x" } as unknown as {
username: string;
password: string;
});
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(TRPCError);
expect((e as TRPCError).code).toBe("BAD_REQUEST");
}
});
});
```
- [ ] **Step 20: Run RED then GREEN**
```bash
pnpm test --filter @repo/auth -- router
```
Expected: tests pass against the new router.
### 3.J — Public-API surface cleanup
- [ ] **Step 21: Read current `src/index.ts`**
```bash
cat packages/auth/src/index.ts
```
(Currently exports `User`/`Session`/`Cookie`/`AuthRouter`/errors/`SESSION_COOKIE`. Auth has no query builders to move — its `ui/query.ts` is `export {}`.)
- [ ] **Step 22: Create `src/ui/index.ts`**
Auth has no UI artifacts yet. Create a placeholder so the `./ui` subpath resolves:
```typescript
// packages/auth/src/ui/index.ts
// Auth has no React Query option builders today (all auth procedures are
// mutations). This file is the public UI surface for future components
// and queries — extend rather than re-add to root index.ts.
export {};
```
If `packages/auth/src/ui/query.ts` exists with `export {};`, leave it (or delete; whichever is cleaner). Re-export from `index.ts`:
```bash
# verify
cat packages/auth/src/ui/query.ts
```
If it's just `export {}`, leave the file as-is — `ui/index.ts` is the new public surface.
- [ ] **Step 23: Update `src/index.ts` to export schemas + IUseCase aliases**
Replace `packages/auth/src/index.ts`:
```typescript
export type { User } from "./entities/models/user";
export type { Session } from "./entities/models/session";
export type { Cookie } from "./entities/models/cookie";
export type { AuthRouter } from "./integrations/api/router";
export {
AuthenticationError,
UnauthenticatedError,
UnauthorizedError,
} from "./entities/errors/auth";
export { InputParseError } from "./entities/errors/common";
export { SESSION_COOKIE } from "./config";
// Use case schemas + types (Plan 9 R18)
export {
signInInputSchema,
signInOutputSchema,
type SignInInput,
type SignInOutput,
type ISignInUseCase,
} from "./application/use-cases/sign-in.use-case";
export {
signUpInputSchema,
signUpOutputSchema,
type SignUpInput,
type SignUpOutput,
type ISignUpUseCase,
} from "./application/use-cases/sign-up.use-case";
export {
signOutInputSchema,
type SignOutInput,
type ISignOutUseCase,
} from "./application/use-cases/sign-out.use-case";
// Controller type aliases
export type { ISignInController } from "./interface-adapters/controllers/sign-in.controller";
export type { ISignUpController } from "./interface-adapters/controllers/sign-up.controller";
export type { ISignOutController } from "./interface-adapters/controllers/sign-out.controller";
```
- [ ] **Step 24: Add `./ui` subpath to `package.json`**
Edit `packages/auth/package.json`. Add to `exports`:
```jsonc
{
"exports": {
".": "./src/index.ts",
"./ui": "./src/ui/index.ts",
"./cms": "./src/integrations/cms/index.ts",
"./api": "./src/integrations/api/router.ts",
"./di/bind-production": "./src/di/bind-production.ts"
}
}
```
### 3.K — Verify
- [ ] **Step 25: Full validation**
```bash
pnpm install
pnpm typecheck
pnpm lint
pnpm test
pnpm turbo boundaries
```
All green. Tests should number the same as before plus 4 new ones (1× R25 sign-in, 1× R25 sign-up, 2× R26 router).
### 3.L — Update changelog
- [ ] **Step 26: Append auth entries to the refactor changelog**
Under §1 Files added:
```
- packages/auth/src/integrations/api/procedures.ts — authProcedure with feature error map (InputParse → BAD_REQUEST, Auth/Unauthenticated → UNAUTHORIZED, Unauthorized → FORBIDDEN)
- packages/auth/src/ui/index.ts — placeholder UI surface (no queries today; mutations only)
- packages/auth/src/integrations/api/router.test.ts — R26 error-mapping tests for signIn (UNAUTHORIZED on missing user, BAD_REQUEST on bad input)
```
Under §2 Files modified:
```
- packages/auth/src/application/use-cases/sign-in.use-case.ts — input + output schemas; output.parse before return; SignInInput/SignInOutput types exported
- packages/auth/src/application/use-cases/sign-up.use-case.ts — input + output schemas (with confirmPassword refine); output.parse; types exported
- packages/auth/src/application/use-cases/sign-out.use-case.ts — input schema only (void output, no presenter); SignOutInput exported
- packages/auth/src/interface-adapters/controllers/sign-in.controller.ts — presenter returning cookie; unknown input; ReturnType<typeof presenter> return type
- packages/auth/src/interface-adapters/controllers/sign-up.controller.ts — presenter returning cookie; unknown input
- packages/auth/src/interface-adapters/controllers/sign-out.controller.ts — no presenter (void); unknown input; Promise<void> return
- packages/auth/src/integrations/api/router.ts — uses authProcedure, .input(xInputSchema)
- packages/auth/src/index.ts — schemas + types now exported from feature root
- packages/auth/package.json — added ./ui subpath
- All affected use-case + controller tests
```
Under §3.1 / §3.2 / §3.3 / §5.1 / §5.2 / §6.1 / §6.2 — append the auth-specific summary one-liner (e.g., "auth migrated, all 3 use cases").
- [ ] **Step 27: Commit**
```bash
git add packages/auth packages/core-shared docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md
git commit -m "$(cat <<'EOF'
refactor(auth): unify use-case I/O schemas + presenter + feature error map
Per Plan 9 (spec R1-R28):
- Use cases: input + output schemas (signIn, signUp); input-only for
signOut (void output). Use case body validates output via
outputSchema.parse before returning.
- Controllers: receive `unknown`; safeParse with the use-case schema;
presenter (returning cookie) for signIn/signUp; void return for
signOut.
- New integrations/api/procedures.ts with authProcedure built via
defineErrorMiddleware([[InputParseError,"BAD_REQUEST"],
[AuthenticationError,"UNAUTHORIZED"], [UnauthenticatedError,
"UNAUTHORIZED"], [UnauthorizedError,"FORBIDDEN"]]).
- Router uses authProcedure + .input(xInputSchema) for every procedure.
- src/index.ts exports schemas + types + IUseCase/IController aliases.
- package.json gains ./ui subpath; src/ui/index.ts placeholder
(auth has no query builders today).
- New tests: R25 output-validation per use case (signIn, signUp);
R26 router error-mapping (UNAUTHORIZED on missing user,
BAD_REQUEST on schema fail).
Refactor log: §1, §2, §3.1, §3.2, §3.3, §5.1, §5.2, §6.1, §6.2
Spec: R1R6, R8R15, R18, R19, R22R26
EOF
)"
```
---
## Task 4: Migrate `blog` feature
**Files:**
- Modify: every blog use-case + test (`get-articles`, `create-article`, `get-article-by-slug`)
- Modify: every blog controller + test (same three)
- Create: `packages/blog/src/integrations/api/procedures.ts`
- Modify: `packages/blog/src/integrations/api/router.ts`
- Modify or Create: `packages/blog/src/integrations/api/router.test.ts`
- Modify: `packages/blog/src/index.ts`
- Move: `articleBySlugQuery`, `listArticlesQuery` from current `src/index.ts``src/ui/index.ts`
- Modify: `packages/blog/package.json` (add `./ui`)
### 4.A — `get-articles` use case
- [ ] **Step 1: Read current files for context**
```bash
cat packages/blog/src/application/use-cases/get-articles.use-case.ts
cat packages/blog/src/application/use-cases/get-articles.use-case.test.ts
```
Note: input is currently `options?: { status?, authorId?, limit?, offset? }`; output is `Promise<Article[]>`.
- [ ] **Step 2: Write failing R25 test for `get-articles`**
Add to `packages/blog/src/application/use-cases/get-articles.use-case.test.ts`:
```typescript
import { getArticlesOutputSchema } from "@/application/use-cases/get-articles.use-case";
import { z } from "zod";
describe("getArticlesUseCase output validation (R25)", () => {
it("throws when the repository returns a malformed article", async () => {
const repo = new MockArticlesRepository();
// bypass the mock's createArticle (which is typed) by reaching into _articles directly
(repo as unknown as { _articles: unknown[] })._articles.push({ id: 123 });
const useCase = getArticlesUseCase(repo);
await expect(useCase({})).rejects.toBeInstanceOf(z.ZodError);
});
it("exports an output schema that mirrors Article[]", () => {
expect(getArticlesOutputSchema).toBeDefined();
expect(getArticlesOutputSchema.safeParse([]).success).toBe(true);
});
});
```
- [ ] **Step 3: Run RED**
```bash
pnpm test --filter @repo/blog -- get-articles.use-case
```
Expected: FAIL — `getArticlesOutputSchema` does not exist; current `getArticlesUseCase` accepts `options?` not `XInput`.
- [ ] **Step 4: Update `get-articles.use-case.ts`**
Replace with:
```typescript
import { z } from "zod";
import { articleSchema, articleStatusSchema } from "../../entities/models/article";
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
export const getArticlesInputSchema = z
.object({
status: articleStatusSchema.optional(),
authorId: z.string().optional(),
limit: z.number().int().positive().optional(),
offset: z.number().int().nonnegative().optional(),
})
.strict();
export type GetArticlesInput = z.infer<typeof getArticlesInputSchema>;
export const getArticlesOutputSchema = z.array(articleSchema);
export type GetArticlesOutput = z.infer<typeof getArticlesOutputSchema>;
export type IGetArticlesUseCase = ReturnType<typeof getArticlesUseCase>;
export const getArticlesUseCase =
(articlesRepository: IArticlesRepository) =>
async (input: GetArticlesInput): Promise<GetArticlesOutput> => {
const result = await articlesRepository.getArticles(input);
return getArticlesOutputSchema.parse(result);
};
```
The existing `getArticlesUseCase()` (no-arg) call in tests breaks — update those calls to pass `{}`.
- [ ] **Step 5: Update existing `get-articles.use-case.test.ts` calls**
Find every `await useCase()` (no args) in this file and replace with `await useCase({})`. Also find any `await useCase({ status: "..." })` — those still work but verify they conform to the new schema.
- [ ] **Step 6: Run GREEN**
```bash
pnpm test --filter @repo/blog -- get-articles.use-case
```
Expected: all green (existing tests + 2 new R25 tests).
### 4.B — `create-article` use case
- [ ] **Step 7: Update `create-article.use-case.ts`**
```typescript
import { z } from "zod";
import { articleSchema } from "../../entities/models/article";
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
export const createArticleInputSchema = z
.object({
title: z.string().min(1).max(255),
content: z.unknown().optional(),
authorId: z.string(),
slug: z.string().optional(),
})
.strict();
export type CreateArticleInput = z.infer<typeof createArticleInputSchema>;
export const createArticleOutputSchema = articleSchema;
export type CreateArticleOutput = z.infer<typeof createArticleOutputSchema>;
function generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export type ICreateArticleUseCase = ReturnType<typeof createArticleUseCase>;
export const createArticleUseCase =
(articlesRepository: IArticlesRepository) =>
async (input: CreateArticleInput): Promise<CreateArticleOutput> => {
const now = new Date();
const article = {
id: crypto.randomUUID(),
title: input.title,
slug: input.slug ?? generateSlug(input.title),
content: input.content ?? null,
status: "draft" as const,
authorId: input.authorId,
createdAt: now,
updatedAt: now,
};
const result = await articlesRepository.createArticle(article);
return createArticleOutputSchema.parse(result);
};
```
- [ ] **Step 8: Add R25 test for create-article**
In `create-article.use-case.test.ts`, add:
```typescript
import { createArticleOutputSchema } from "@/application/use-cases/create-article.use-case";
import { z } from "zod";
describe("createArticleUseCase output validation (R25)", () => {
it("throws when repository returns a malformed article", async () => {
const repo = {
createArticle: async () => ({ id: 1 }) as unknown as never,
} as unknown as IArticlesRepository;
const useCase = createArticleUseCase(repo);
await expect(
useCase({ title: "X", authorId: "u1" }),
).rejects.toBeInstanceOf(z.ZodError);
});
});
```
(Add `IArticlesRepository` to imports if needed.)
Run: `pnpm test --filter @repo/blog -- create-article.use-case`. Expected: green.
### 4.C — `get-article-by-slug` use case
- [ ] **Step 9: Update `get-article-by-slug.use-case.ts`**
```typescript
import { z } from "zod";
import { ArticleNotFoundError } from "../../entities/errors/article";
import { articleSchema } from "../../entities/models/article";
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
export const getArticleBySlugInputSchema = z
.object({ slug: z.string().min(1) })
.strict();
export type GetArticleBySlugInput = z.infer<typeof getArticleBySlugInputSchema>;
export const getArticleBySlugOutputSchema = articleSchema;
export type GetArticleBySlugOutput = z.infer<typeof getArticleBySlugOutputSchema>;
export type IGetArticleBySlugUseCase = ReturnType<typeof getArticleBySlugUseCase>;
export const getArticleBySlugUseCase =
(articlesRepository: IArticlesRepository) =>
async (input: GetArticleBySlugInput): Promise<GetArticleBySlugOutput> => {
const article = await articlesRepository.getArticleBySlug(input.slug);
if (!article) {
throw new ArticleNotFoundError(`Article with slug "${input.slug}" not found`);
}
return getArticleBySlugOutputSchema.parse(article);
};
```
- [ ] **Step 10: Add R25 test for get-article-by-slug**
In `get-article-by-slug.use-case.test.ts`, add a test that the repository returning a malformed article triggers a parse error. Run test. Green.
### 4.D — Controllers (3) — presenter (identity for now) + unknown input
- [ ] **Step 11: Update `get-articles.controller.ts`**
```typescript
import { InputParseError } from "../../entities/errors/common";
import {
getArticlesInputSchema,
type GetArticlesOutput,
type IGetArticlesUseCase,
} from "../../application/use-cases/get-articles.use-case";
function presenter(value: GetArticlesOutput) {
// identity for now (R11 — every non-void controller has a presenter)
return value;
}
export type IGetArticlesController = ReturnType<typeof getArticlesController>;
export const getArticlesController =
(getArticlesUseCase: IGetArticlesUseCase) =>
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = getArticlesInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid get-articles input", { cause: parsed.error });
}
const result = await getArticlesUseCase(parsed.data);
return presenter(result);
};
```
- [ ] **Step 12: Update `get-articles.controller.test.ts` calls**
The current test calls `controller({})` (still works) and `controller({ status: "published" })` (still works). The third test uses `{ limit: "not a number" }` cast — still works because schema rejects. No changes needed *unless* the call signature of `controller` changed (input is now `unknown` instead of `Partial<...>`). Check test compiles:
```bash
pnpm typecheck --filter @repo/blog
```
If TS complains about `controller({ ... } as ...)`, simplify by passing the value directly (the param is `unknown`, anything fits):
```typescript
await expect(controller({ limit: "not a number" })).rejects.toBeInstanceOf(InputParseError);
```
Run tests. Green.
- [ ] **Step 13: Update `create-article.controller.ts`**
```typescript
import { InputParseError } from "../../entities/errors/common";
import {
createArticleInputSchema,
type CreateArticleOutput,
type ICreateArticleUseCase,
} from "../../application/use-cases/create-article.use-case";
function presenter(value: CreateArticleOutput) {
return value;
}
export type ICreateArticleController = ReturnType<typeof createArticleController>;
export const createArticleController =
(createArticleUseCase: ICreateArticleUseCase) =>
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = createArticleInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid create-article input", { cause: parsed.error });
}
const result = await createArticleUseCase(parsed.data);
return presenter(result);
};
```
- [ ] **Step 14: Update `get-article-by-slug.controller.ts`**
```typescript
import { InputParseError } from "../../entities/errors/common";
import {
getArticleBySlugInputSchema,
type GetArticleBySlugOutput,
type IGetArticleBySlugUseCase,
} from "../../application/use-cases/get-article-by-slug.use-case";
function presenter(value: GetArticleBySlugOutput) {
return value;
}
export type IGetArticleBySlugController = ReturnType<typeof getArticleBySlugController>;
export const getArticleBySlugController =
(getArticleBySlugUseCase: IGetArticleBySlugUseCase) =>
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = getArticleBySlugInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid get-article-by-slug input", { cause: parsed.error });
}
const result = await getArticleBySlugUseCase(parsed.data);
return presenter(result);
};
```
- [ ] **Step 15: Run all blog controller tests**
```bash
pnpm test --filter @repo/blog -- controllers
```
Expected: green. Update any test failures by removing `as Partial<...>` casts (input is now `unknown`).
### 4.E — `procedures.ts` (blog error map)
- [ ] **Step 16: Create `packages/blog/src/integrations/api/procedures.ts`**
```typescript
import { t } from "@repo/core-shared/trpc/init";
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
import { ArticleNotFoundError } from "../../entities/errors/article";
import { InputParseError } from "../../entities/errors/common";
export const blogProcedure = t.procedure.use(
defineErrorMiddleware([
[InputParseError, "BAD_REQUEST"],
[ArticleNotFoundError, "NOT_FOUND"],
]),
);
```
### 4.F — Router migration
- [ ] **Step 17: Replace `packages/blog/src/integrations/api/router.ts`**
```typescript
import { router } from "@repo/core-shared/trpc/init";
import { blogContainer } from "../../di/container";
import { BLOG_SYMBOLS } from "../../di/symbols";
import { getArticlesInputSchema } from "../../application/use-cases/get-articles.use-case";
import { createArticleInputSchema } from "../../application/use-cases/create-article.use-case";
import { getArticleBySlugInputSchema } from "../../application/use-cases/get-article-by-slug.use-case";
import type { IGetArticlesController } from "../../interface-adapters/controllers/get-articles.controller";
import type { ICreateArticleController } from "../../interface-adapters/controllers/create-article.controller";
import type { IGetArticleBySlugController } from "../../interface-adapters/controllers/get-article-by-slug.controller";
import { blogProcedure } from "./procedures";
export const blogRouter = router({
articleBySlug: blogProcedure
.input(getArticleBySlugInputSchema)
.query(({ input }) => {
const ctrl = blogContainer.get<IGetArticleBySlugController>(
BLOG_SYMBOLS.IGetArticleBySlugController,
);
return ctrl(input);
}),
listArticles: blogProcedure
.input(getArticlesInputSchema)
.query(({ input }) => {
const ctrl = blogContainer.get<IGetArticlesController>(
BLOG_SYMBOLS.IGetArticlesController,
);
return ctrl(input);
}),
createArticle: blogProcedure
.input(createArticleInputSchema)
.mutation(({ input }) => {
const ctrl = blogContainer.get<ICreateArticleController>(
BLOG_SYMBOLS.ICreateArticleController,
);
return ctrl(input);
}),
});
export type BlogRouter = typeof blogRouter;
```
### 4.G — Router test (R26)
- [ ] **Step 18: Create or extend `packages/blog/src/integrations/api/router.test.ts`**
```typescript
import { describe, it, expect, beforeEach } from "vitest";
import { TRPCError } from "@trpc/server";
import { blogRouter } from "@/integrations/api/router";
import { blogContainer } from "@/di/container";
import { BLOG_SYMBOLS } from "@/di/symbols";
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface";
describe("blogRouter (R26 error mapping)", () => {
beforeEach(() => {
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
}
blogContainer
.bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
.toConstantValue(new MockArticlesRepository());
});
it("translates ArticleNotFoundError → NOT_FOUND", async () => {
const caller = blogRouter.createCaller({});
try {
await caller.articleBySlug({ slug: "missing" });
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(TRPCError);
expect((e as TRPCError).code).toBe("NOT_FOUND");
}
});
it("translates zod parse failure → BAD_REQUEST", async () => {
const caller = blogRouter.createCaller({});
try {
await caller.articleBySlug({} as unknown as { slug: string });
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(TRPCError);
expect((e as TRPCError).code).toBe("BAD_REQUEST");
}
});
});
```
### 4.H — Public-API surface
- [ ] **Step 19: Create `packages/blog/src/ui/index.ts`**
The current `src/index.ts` re-exports `articleBySlugQuery` and `listArticlesQuery` from `./ui/query`. Move those re-exports to `src/ui/index.ts`:
```typescript
// packages/blog/src/ui/index.ts
export { articleBySlugQuery, listArticlesQuery } from "./query";
```
- [ ] **Step 20: Update `packages/blog/src/index.ts` (remove queries; add schemas)**
```typescript
export type { Article, ArticleStatus } from "./entities/models/article";
export type { BlogRouter } from "./integrations/api/router";
export { ArticleNotFoundError } from "./entities/errors/article";
export { InputParseError } from "./entities/errors/common";
// Use case schemas + types (Plan 9 R18)
export {
getArticlesInputSchema,
getArticlesOutputSchema,
type GetArticlesInput,
type GetArticlesOutput,
type IGetArticlesUseCase,
} from "./application/use-cases/get-articles.use-case";
export {
createArticleInputSchema,
createArticleOutputSchema,
type CreateArticleInput,
type CreateArticleOutput,
type ICreateArticleUseCase,
} from "./application/use-cases/create-article.use-case";
export {
getArticleBySlugInputSchema,
getArticleBySlugOutputSchema,
type GetArticleBySlugInput,
type GetArticleBySlugOutput,
type IGetArticleBySlugUseCase,
} from "./application/use-cases/get-article-by-slug.use-case";
// Controller type aliases
export type { IGetArticlesController } from "./interface-adapters/controllers/get-articles.controller";
export type { ICreateArticleController } from "./interface-adapters/controllers/create-article.controller";
export type { IGetArticleBySlugController } from "./interface-adapters/controllers/get-article-by-slug.controller";
```
(Note: `articleBySlugQuery` and `listArticlesQuery` are no longer here.)
- [ ] **Step 21: Update `packages/blog/package.json`**
```jsonc
{
"exports": {
".": "./src/index.ts",
"./ui": "./src/ui/index.ts",
"./cms": "./src/integrations/cms/index.ts",
"./api": "./src/integrations/api/router.ts",
"./di/bind-production": "./src/di/bind-production.ts"
}
}
```
- [ ] **Step 22: Confirm no app/package imports the moved queries from `@repo/blog`**
```bash
grep -rln 'from "@repo/blog"' apps/ packages/ | xargs -I{} grep -l 'articleBySlugQuery\|listArticlesQuery' {} 2>/dev/null
```
Expected: empty (no consumers today). If any are found, change them to `from "@repo/blog/ui"`.
### 4.I — Verify + changelog + commit
- [ ] **Step 23: Full validation**
```bash
pnpm install
pnpm typecheck
pnpm lint
pnpm test
pnpm turbo boundaries
```
All green.
- [ ] **Step 24: Update changelog with blog entries** (mirror auth section: §1 added — procedures.ts, ui/index.ts, router.test.ts; §2 modified — three use cases, three controllers, router, src/index.ts, package.json + tests; §3.13.3, §5.15.2, §6.16.2 entries).
- [ ] **Step 25: Commit**
```bash
git add packages/blog docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md
git commit -m "$(cat <<'EOF'
refactor(blog): unify use-case I/O schemas + presenter + feature error map
Per Plan 9 (spec R1-R28):
- Use cases: input + output schemas (getArticles, createArticle,
getArticleBySlug). Output validated via outputSchema.parse before
return. status field uses articleStatusSchema (was loose `string`).
- Controllers: receive `unknown`; safeParse with use-case schema;
identity presenter (R11) on every controller.
- New integrations/api/procedures.ts with blogProcedure
([InputParseError → BAD_REQUEST], [ArticleNotFoundError → NOT_FOUND]).
- Router uses blogProcedure + .input(xInputSchema) for all 3 procedures.
- src/index.ts: remove articleBySlugQuery/listArticlesQuery re-exports;
export schemas + types + IUseCase/IController aliases.
- src/ui/index.ts (NEW): query builders moved here; package.json adds
./ui subpath.
- New tests: R25 output-validation per use case; R26 router error-
mapping (NOT_FOUND on missing slug, BAD_REQUEST on schema fail).
Refactor log: §1, §2, §3.1, §3.2, §3.3, §5.1, §5.2, §6.1, §6.2
Spec: R1R6, R8R15, R18R20, R22R26
EOF
)"
```
---
## Task 5: Migrate `marketing-pages` feature
Mirror Task 4 structure for two use cases (`get-page-by-slug`, `get-site-settings`) and two controllers. Site-settings has VOID input (`getSiteSettings()`) per current code — the schema becomes `z.object({}).strict()` (R5).
**Files:** all `packages/marketing-pages/src/application/use-cases/*.{ts,test.ts}`, all `packages/marketing-pages/src/interface-adapters/controllers/*.{ts,test.ts}`, `integrations/api/procedures.ts` (new), `integrations/api/router.ts`, `integrations/api/router.test.ts`, `src/index.ts`, `src/ui/index.ts` (new), `package.json`.
### 5.A — `get-page-by-slug` use case
- [ ] **Step 1: Read current files**
```bash
cat packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.ts
cat packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.test.ts
```
- [ ] **Step 2: Rewrite use case**
```typescript
// packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.ts
import { z } from "zod";
import { PageNotFoundError } from "../../entities/errors/page";
import { pageSchema } from "../../entities/models/page";
import type { IPagesRepository } from "../repositories/pages.repository.interface";
export const getPageBySlugInputSchema = z.object({ slug: z.string().min(1) }).strict();
export type GetPageBySlugInput = z.infer<typeof getPageBySlugInputSchema>;
export const getPageBySlugOutputSchema = pageSchema;
export type GetPageBySlugOutput = z.infer<typeof getPageBySlugOutputSchema>;
export type IGetPageBySlugUseCase = ReturnType<typeof getPageBySlugUseCase>;
export const getPageBySlugUseCase =
(pagesRepository: IPagesRepository) =>
async (input: GetPageBySlugInput): Promise<GetPageBySlugOutput> => {
const page = await pagesRepository.getPageBySlug(input.slug);
if (!page) {
throw new PageNotFoundError(`Page with slug "${input.slug}" not found`);
}
return getPageBySlugOutputSchema.parse(page);
};
```
(Verify the current behavior — if `getPageBySlugUseCase` currently returns `undefined` for missing pages instead of throwing, this changes semantics. Read the existing file first; if it returns undefined, KEEP the undefined-return behavior and adjust the schema:
```typescript
export const getPageBySlugOutputSchema = pageSchema;
// ...
async (input: GetPageBySlugInput): Promise<GetPageBySlugOutput | undefined> => {
const page = await pagesRepository.getPageBySlug(input.slug);
if (!page) return undefined;
return getPageBySlugOutputSchema.parse(page);
};
```
Either direction is fine — choose to match the existing test expectations.)
- [ ] **Step 3: Add R25 test, run RED → GREEN.**
### 5.B — `get-site-settings` use case (void input)
- [ ] **Step 4: Rewrite use case**
```typescript
// packages/marketing-pages/src/application/use-cases/get-site-settings.use-case.ts
import { z } from "zod";
import { siteSettingsSchema } from "../../entities/models/site-settings";
import type { ISiteSettingsRepository } from "../repositories/site-settings.repository.interface";
export const getSiteSettingsInputSchema = z.object({}).strict();
export type GetSiteSettingsInput = z.infer<typeof getSiteSettingsInputSchema>;
export const getSiteSettingsOutputSchema = siteSettingsSchema;
export type GetSiteSettingsOutput = z.infer<typeof getSiteSettingsOutputSchema>;
export type IGetSiteSettingsUseCase = ReturnType<typeof getSiteSettingsUseCase>;
export const getSiteSettingsUseCase =
(siteSettingsRepository: ISiteSettingsRepository) =>
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async (_input: GetSiteSettingsInput): Promise<GetSiteSettingsOutput> => {
const result = await siteSettingsRepository.getSiteSettings();
return getSiteSettingsOutputSchema.parse(result);
};
```
The `_input` is required by R5 (uniform input). The `_` prefix tells lint it's intentional.
- [ ] **Step 5: Update existing tests** to pass `{}` as the input (was no-arg). Add R25 test. Run.
### 5.C — Controllers
- [ ] **Step 6: Update `get-page-by-slug.controller.ts`** with presenter (identity), `unknown` input, schema import. Pattern identical to blog.
- [ ] **Step 7: Update `get-site-settings.controller.ts`** similarly. Note: must call `controller({})` in tests.
- [ ] **Step 8: Update both controller tests; run all green.**
### 5.D — `procedures.ts`
- [ ] **Step 9: Create `packages/marketing-pages/src/integrations/api/procedures.ts`**
```typescript
import { t } from "@repo/core-shared/trpc/init";
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
import { PageNotFoundError } from "../../entities/errors/page";
import { InputParseError } from "../../entities/errors/common";
export const marketingPagesProcedure = t.procedure.use(
defineErrorMiddleware([
[InputParseError, "BAD_REQUEST"],
[PageNotFoundError, "NOT_FOUND"],
]),
);
```
### 5.E — Router
- [ ] **Step 10: Update router**
```typescript
import { router } from "@repo/core-shared/trpc/init";
import { marketingPagesContainer } from "../../di/container";
import { MARKETING_PAGES_SYMBOLS } from "../../di/symbols";
import { getPageBySlugInputSchema } from "../../application/use-cases/get-page-by-slug.use-case";
import { getSiteSettingsInputSchema } from "../../application/use-cases/get-site-settings.use-case";
import type { IGetPageBySlugController } from "../../interface-adapters/controllers/get-page-by-slug.controller";
import type { IGetSiteSettingsController } from "../../interface-adapters/controllers/get-site-settings.controller";
import { marketingPagesProcedure } from "./procedures";
export const marketingPagesRouter = router({
pageBySlug: marketingPagesProcedure
.input(getPageBySlugInputSchema)
.query(({ input }) => {
const ctrl = marketingPagesContainer.get<IGetPageBySlugController>(
MARKETING_PAGES_SYMBOLS.IGetPageBySlugController,
);
return ctrl(input);
}),
siteSettings: marketingPagesProcedure
.input(getSiteSettingsInputSchema)
.query(({ input }) => {
const ctrl = marketingPagesContainer.get<IGetSiteSettingsController>(
MARKETING_PAGES_SYMBOLS.IGetSiteSettingsController,
);
return ctrl(input);
}),
});
export type MarketingPagesRouter = typeof marketingPagesRouter;
```
### 5.F — Router test (R26)
- [ ] **Step 11: Add R26 test** — assert `PageNotFoundError → NOT_FOUND` and zod-parse → `BAD_REQUEST` (mirrors blog router test).
### 5.G — Public-API surface
- [ ] **Step 12: Create `packages/marketing-pages/src/ui/index.ts`**
```typescript
export { pageBySlugQuery, siteSettingsQuery } from "./query";
```
- [ ] **Step 13: Update `packages/marketing-pages/src/index.ts`**
Remove `pageBySlugQuery, siteSettingsQuery` exports; add schemas + types + IUseCase / IController aliases (mirroring blog's pattern).
- [ ] **Step 14: Add `./ui` to `package.json`.**
### 5.H — Verify + changelog + commit
- [ ] **Step 15: Full validation pass.**
```bash
pnpm typecheck && pnpm lint && pnpm test && pnpm turbo boundaries
```
- [ ] **Step 16: Update changelog with marketing-pages entries.**
- [ ] **Step 17: Commit**
```bash
git add packages/marketing-pages docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md
git commit -m "$(cat <<'EOF'
refactor(marketing-pages): unify use-case I/O schemas + presenter + feature error map
Per Plan 9 (spec R1-R28):
- Use cases: input + output schemas (getPageBySlug, getSiteSettings).
Site-settings input is z.object({}).strict() per R5 (uniform input).
- Controllers: unknown input + identity presenter; void output not
applicable (both use cases return data).
- New integrations/api/procedures.ts with marketingPagesProcedure
([InputParseError → BAD_REQUEST], [PageNotFoundError → NOT_FOUND]).
- Router uses marketingPagesProcedure + .input(xInputSchema).
- src/index.ts: remove pageBySlugQuery/siteSettingsQuery; export
schemas + types + IUseCase/IController aliases.
- src/ui/index.ts (NEW); package.json adds ./ui subpath.
- R25 output-validation tests + R26 router error-mapping test.
Refactor log: §1, §2, §3.1, §3.2, §3.3, §5.1, §5.2, §6.1, §6.2
Spec: R1R6, R8R15, R18R20, R22R26
EOF
)"
```
---
## Task 6: Migrate `navigation` feature
Single use case (`get-header`) — void input, non-void output.
**Files:** `application/use-cases/get-header.use-case.{ts,test.ts}`, `interface-adapters/controllers/get-header.controller.{ts,test.ts}`, `integrations/api/procedures.ts` (new), `integrations/api/router.ts`, `integrations/api/router.test.ts`, `src/index.ts`, `src/ui/index.ts` (new), `package.json`.
- [ ] **Step 1: Update `get-header.use-case.ts`**
```typescript
import { z } from "zod";
import { HeaderNotFoundError } from "../../entities/errors/header";
import { headerSchema } from "../../entities/models/header";
import type { IHeaderRepository } from "../repositories/header.repository.interface";
export const getHeaderInputSchema = z.object({}).strict();
export type GetHeaderInput = z.infer<typeof getHeaderInputSchema>;
export const getHeaderOutputSchema = headerSchema;
export type GetHeaderOutput = z.infer<typeof getHeaderOutputSchema>;
export type IGetHeaderUseCase = ReturnType<typeof getHeaderUseCase>;
export const getHeaderUseCase =
(headerRepository: IHeaderRepository) =>
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async (_input: GetHeaderInput): Promise<GetHeaderOutput> => {
const header = await headerRepository.getHeader();
if (!header) {
throw new HeaderNotFoundError("Header global not found");
}
return getHeaderOutputSchema.parse(header);
};
```
(Adjust the `if (!header) throw` only if the existing use case throws on missing; if it returns undefined, mirror that. Read first.)
- [ ] **Step 2: Update tests to pass `{}` to the use case + add R25 test.**
- [ ] **Step 3: Update `get-header.controller.ts`**
```typescript
import { InputParseError } from "../../entities/errors/common";
import {
getHeaderInputSchema,
type GetHeaderOutput,
type IGetHeaderUseCase,
} from "../../application/use-cases/get-header.use-case";
function presenter(value: GetHeaderOutput) {
return value;
}
export type IGetHeaderController = ReturnType<typeof getHeaderController>;
export const getHeaderController =
(getHeaderUseCase: IGetHeaderUseCase) =>
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = getHeaderInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid get-header input", { cause: parsed.error });
}
const result = await getHeaderUseCase(parsed.data);
return presenter(result);
};
```
- [ ] **Step 4: Update controller test (call with `{}`).**
- [ ] **Step 5: Create `packages/navigation/src/integrations/api/procedures.ts`**
```typescript
import { t } from "@repo/core-shared/trpc/init";
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
import { HeaderNotFoundError } from "../../entities/errors/header";
import { InputParseError } from "../../entities/errors/common";
export const navigationProcedure = t.procedure.use(
defineErrorMiddleware([
[InputParseError, "BAD_REQUEST"],
[HeaderNotFoundError, "NOT_FOUND"],
]),
);
```
- [ ] **Step 6: Update router**
```typescript
import { router } from "@repo/core-shared/trpc/init";
import { navigationContainer } from "../../di/container";
import { NAVIGATION_SYMBOLS } from "../../di/symbols";
import { getHeaderInputSchema } from "../../application/use-cases/get-header.use-case";
import type { IGetHeaderController } from "../../interface-adapters/controllers/get-header.controller";
import { navigationProcedure } from "./procedures";
export const navigationRouter = router({
header: navigationProcedure
.input(getHeaderInputSchema)
.query(({ input }) => {
const ctrl = navigationContainer.get<IGetHeaderController>(
NAVIGATION_SYMBOLS.IGetHeaderController,
);
return ctrl(input);
}),
});
export type NavigationRouter = typeof navigationRouter;
```
- [ ] **Step 7: Add R26 test in `router.test.ts`**`HeaderNotFoundError → NOT_FOUND` and bad input → `BAD_REQUEST`.
- [ ] **Step 8: Create `packages/navigation/src/ui/index.ts`**
```typescript
export { headerQuery } from "./query";
```
- [ ] **Step 9: Update `packages/navigation/src/index.ts`** — remove `headerQuery`; add schemas + types + IUseCase / IController aliases.
- [ ] **Step 10: Add `./ui` to `package.json`.**
- [ ] **Step 11: Validate all green; update changelog; commit:**
```bash
git add packages/navigation docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md
git commit -m "$(cat <<'EOF'
refactor(navigation): unify use-case I/O schemas + presenter + feature error map
Per Plan 9 (spec R1-R28):
- getHeader use case: input z.object({}).strict() (R5); output =
headerSchema parsed at runtime.
- getHeader controller: unknown input + identity presenter.
- New integrations/api/procedures.ts with navigationProcedure
([InputParseError → BAD_REQUEST], [HeaderNotFoundError → NOT_FOUND]).
- Router uses navigationProcedure + .input(getHeaderInputSchema).
- src/index.ts: remove headerQuery; export schemas + IUseCase/Controller
aliases.
- src/ui/index.ts (NEW); package.json adds ./ui subpath.
- R25 + R26 tests added.
Refactor log: §1, §2, §3.1, §3.2, §3.3, §5.1, §5.2, §6.1, §6.2
Spec: R1R6, R8R15, R18R20, R22R26
EOF
)"
```
---
## Task 7: Migrate `media` feature
Three use cases — `get-media`, `list-media`, `delete-media`. Delete is void output.
**Files:** all media use cases + tests, all media controllers + tests, `procedures.ts` (new), `router.ts`, `router.test.ts`, `src/index.ts`, `src/ui/index.ts` (new), `package.json`.
### 7.A — `get-media` use case
- [ ] **Step 1: Update**
```typescript
import { z } from "zod";
import { MediaNotFoundError } from "../../entities/errors/media";
import { mediaSchema } from "../../entities/models/media";
import type { IMediaRepository } from "../repositories/media.repository.interface";
export const getMediaInputSchema = z.object({ id: z.string().min(1) }).strict();
export type GetMediaInput = z.infer<typeof getMediaInputSchema>;
export const getMediaOutputSchema = mediaSchema;
export type GetMediaOutput = z.infer<typeof getMediaOutputSchema>;
export type IGetMediaUseCase = ReturnType<typeof getMediaUseCase>;
export const getMediaUseCase =
(mediaRepository: IMediaRepository) =>
async (input: GetMediaInput): Promise<GetMediaOutput> => {
const media = await mediaRepository.getMedia(input.id);
if (!media) {
throw new MediaNotFoundError(`Media with id "${input.id}" not found`);
}
return getMediaOutputSchema.parse(media);
};
```
- [ ] **Step 2: Add R25 test for malformed media. Run.**
### 7.B — `list-media` use case
- [ ] **Step 3: Update**
```typescript
import { z } from "zod";
import { mediaSchema } from "../../entities/models/media";
import type { IMediaRepository } from "../repositories/media.repository.interface";
export const listMediaInputSchema = z
.object({
limit: z.number().int().positive().optional(),
offset: z.number().int().nonnegative().optional(),
})
.strict();
export type ListMediaInput = z.infer<typeof listMediaInputSchema>;
export const listMediaOutputSchema = z.array(mediaSchema);
export type ListMediaOutput = z.infer<typeof listMediaOutputSchema>;
export type IListMediaUseCase = ReturnType<typeof listMediaUseCase>;
export const listMediaUseCase =
(mediaRepository: IMediaRepository) =>
async (input: ListMediaInput): Promise<ListMediaOutput> => {
const result = await mediaRepository.listMedia(input);
return listMediaOutputSchema.parse(result);
};
```
- [ ] **Step 4: Add R25 test, run.**
### 7.C — `delete-media` use case (void output)
- [ ] **Step 5: Update**
```typescript
import { z } from "zod";
import { MediaNotFoundError } from "../../entities/errors/media";
import type { IMediaRepository } from "../repositories/media.repository.interface";
export const deleteMediaInputSchema = z.object({ id: z.string().min(1) }).strict();
export type DeleteMediaInput = z.infer<typeof deleteMediaInputSchema>;
// No output schema — use case returns void.
export type IDeleteMediaUseCase = ReturnType<typeof deleteMediaUseCase>;
export const deleteMediaUseCase =
(mediaRepository: IMediaRepository) =>
async (input: DeleteMediaInput): Promise<void> => {
const existing = await mediaRepository.getMedia(input.id);
if (!existing) {
throw new MediaNotFoundError(`Media with id "${input.id}" not found`);
}
await mediaRepository.deleteMedia(input.id);
};
```
(Verify against existing logic — adapt to existing throw vs return-undefined behavior.)
- [ ] **Step 6: Update tests, run.**
### 7.D — Controllers
- [ ] **Step 7-9: `get-media.controller.ts`, `list-media.controller.ts` — both with identity presenter; `delete-media.controller.ts` — no presenter, void return.**
`delete-media` controller (R12):
```typescript
import { InputParseError } from "../../entities/errors/common";
import {
deleteMediaInputSchema,
type IDeleteMediaUseCase,
} from "../../application/use-cases/delete-media.use-case";
export type IDeleteMediaController = ReturnType<typeof deleteMediaController>;
export const deleteMediaController =
(deleteMediaUseCase: IDeleteMediaUseCase) =>
async (input: unknown): Promise<void> => {
const parsed = deleteMediaInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid delete-media input", { cause: parsed.error });
}
await deleteMediaUseCase(parsed.data);
};
```
`get-media` and `list-media` controllers follow blog's identity-presenter pattern.
- [ ] **Step 10: Update all three controller tests; run.**
### 7.E — `procedures.ts` (media)
- [ ] **Step 11: Create**
```typescript
import { t } from "@repo/core-shared/trpc/init";
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
import { MediaNotFoundError } from "../../entities/errors/media";
import { InputParseError } from "../../entities/errors/common";
export const mediaProcedure = t.procedure.use(
defineErrorMiddleware([
[InputParseError, "BAD_REQUEST"],
[MediaNotFoundError, "NOT_FOUND"],
]),
);
```
### 7.F — Router
- [ ] **Step 12: Update**
```typescript
import { router } from "@repo/core-shared/trpc/init";
import { mediaContainer } from "../../di/container";
import { MEDIA_SYMBOLS } from "../../di/symbols";
import { getMediaInputSchema } from "../../application/use-cases/get-media.use-case";
import { listMediaInputSchema } from "../../application/use-cases/list-media.use-case";
import { deleteMediaInputSchema } from "../../application/use-cases/delete-media.use-case";
import type { IGetMediaController } from "../../interface-adapters/controllers/get-media.controller";
import type { IListMediaController } from "../../interface-adapters/controllers/list-media.controller";
import type { IDeleteMediaController } from "../../interface-adapters/controllers/delete-media.controller";
import { mediaProcedure } from "./procedures";
export const mediaRouter = router({
getMedia: mediaProcedure.input(getMediaInputSchema).query(({ input }) => {
const ctrl = mediaContainer.get<IGetMediaController>(MEDIA_SYMBOLS.IGetMediaController);
return ctrl(input);
}),
listMedia: mediaProcedure.input(listMediaInputSchema).query(({ input }) => {
const ctrl = mediaContainer.get<IListMediaController>(MEDIA_SYMBOLS.IListMediaController);
return ctrl(input);
}),
deleteMedia: mediaProcedure.input(deleteMediaInputSchema).mutation(({ input }) => {
const ctrl = mediaContainer.get<IDeleteMediaController>(MEDIA_SYMBOLS.IDeleteMediaController);
return ctrl(input);
}),
});
export type MediaRouter = typeof mediaRouter;
```
### 7.G — Router test (R26)
- [ ] **Step 13: Add R26 test**`MediaNotFoundError → NOT_FOUND`, `BAD_REQUEST` on bad input.
### 7.H — Public-API surface
- [ ] **Step 14: Create `packages/media/src/ui/index.ts`**
Media has no UI today. Placeholder:
```typescript
// packages/media/src/ui/index.ts
// Media has no React Query option builders today. This file is the
// public UI surface for future components and queries — extend rather
// than re-add to root index.ts.
export {};
```
- [ ] **Step 15: Update `packages/media/src/index.ts`** — add schemas + types + IUseCase / IController aliases (mirroring blog).
- [ ] **Step 16: Add `./ui` to `packages/media/package.json`.**
### 7.I — Verify + changelog + commit
- [ ] **Step 17: Full validation.**
```bash
pnpm typecheck && pnpm lint && pnpm test && pnpm turbo boundaries
```
- [ ] **Step 18: Update changelog.**
- [ ] **Step 19: Commit**
```bash
git add packages/media docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md
git commit -m "$(cat <<'EOF'
refactor(media): unify use-case I/O schemas + presenter + feature error map
Per Plan 9 (spec R1-R28):
- Use cases: input + output schemas (getMedia, listMedia); deleteMedia
has input schema only (void output, R12 — no presenter).
- Controllers: unknown input + identity presenter on getMedia/listMedia;
Promise<void> on deleteMedia.
- New integrations/api/procedures.ts with mediaProcedure
([InputParseError → BAD_REQUEST], [MediaNotFoundError → NOT_FOUND]).
- Router uses mediaProcedure + .input(xInputSchema).
- src/index.ts exports schemas + types; src/ui/index.ts placeholder
(media has no queries today); package.json adds ./ui subpath.
- R25 + R26 tests added.
Refactor log: §1, §2, §3.1, §3.2, §3.3, §5.1, §5.2, §6.1, §6.2
Spec: R1R6, R8R15, R18R20, R22R26
EOF
)"
```
---
## Task 8: Final verification + boundary sweep
**Files:** none modified directly. Verification only. If any straggler imports surface (e.g., a test still casting input as `Partial<XInput>`), fix in this task with a separate small commit.
- [ ] **Step 1: Run full validation**
```bash
pnpm install
pnpm typecheck
pnpm lint
pnpm test
pnpm turbo boundaries
pnpm build
```
All green.
- [ ] **Step 2: Verify Plan 9 acceptance criteria from spec §8**
Check each:
```bash
# Every use case exports xInputSchema:
grep -L "export const .*InputSchema" packages/*/src/application/use-cases/*.ts | grep -v test
# Expected: empty (every non-test file has a schema)
# Every non-void use case exports xOutputSchema:
# (manual review; void use cases are sign-out and delete-media)
grep -L "export const .*OutputSchema" packages/*/src/application/use-cases/*.ts | grep -v test
# Expected: only sign-out.use-case.ts and delete-media.use-case.ts
# Every controller has presenter or returns void:
grep -L "function presenter\|Promise<void>" packages/*/src/interface-adapters/controllers/*.ts | grep -v test
# Expected: empty
# Every feature has procedures.ts:
ls packages/{auth,blog,marketing-pages,navigation,media}/src/integrations/api/procedures.ts
# Expected: 5 files listed
# Every feature package.json has ./ui:
for f in auth blog marketing-pages navigation media; do
grep -q '"./ui"' packages/$f/package.json && echo "$f: OK" || echo "$f: MISSING"
done
# Expected: 5 × OK
```
- [ ] **Step 3: Spot-check one tRPC error response per feature**
Pick one router test per feature; ensure each emits the expected `TRPCError.code`. Already covered by R26 tests in each feature's `router.test.ts` — verify output:
```bash
pnpm test -- router.test
```
- [ ] **Step 4: If anything is non-conformant, fix it inline and commit**
```bash
git add <files>
git commit -m "chore(plan-9): straggler fixes from final verification
Plan 9 acceptance sweep caught <issue summary>. Refactor log §7."
```
If everything's green, no commit needed.
- [ ] **Step 5: Update changelog with verification summary** under §7 (Open issues / deferred decisions or a new "Verification" subsection).
```markdown
## 7. Open issues / deferred decisions
(Plan 9 verification, 2026-05-06):
- All R1R28 conformance checks passed against the spec.
- Tests: <N> total (was <M> pre-Plan-9). Net delta: <delta> (R25/R26 additions).
- typecheck / lint / boundaries / build / test all green.
- Doc-update pass remains deferred — combined with paused Plan 8 items, executes in a single follow-up pass.
```
(Fill in actual numbers from `pnpm test` output.)
---
## Task 9: ADR-013 + final changelog summary
**Files:**
- Create: `docs/decisions/adr-013-input-output-unification.md`
- Modify: `docs/decisions/adr-012-lazar-conformance.md` (one-line cross-reference)
- Modify: `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md` (top "Summary" section)
- [ ] **Step 1: Create ADR-013**
Write `docs/decisions/adr-013-input-output-unification.md`:
```markdown
# ADR-013: Use-Case Input/Output Unification + Presenter Pattern + Feature-Scoped Error Mapping
**Status:** Accepted
**Date:** 2026-05-06
**Supersedes:** none — extends ADR-008 (per-feature DI), ADR-011 (TDD foundation), ADR-012 (Lazar conformance)
**Spec:** docs/superpowers/specs/2026-05-06-input-output-unification-design.md
**Plan:** docs/superpowers/plans/2026-05-06-plan-9-io-unification.md
**Refactor log:** docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md
## Context
Plan 8 (ADR-012) established factory-function use cases and one-controller-
per-use-case. But the input contract was still defined three times — once
in the tRPC procedure's `.input(z.object({...}))`, once in the controller's
local `const inputSchema`, and once implicitly in the use case's TypeScript
parameter type. The three definitions drifted: the controller's
`z.string().min(3).max(31)` was stricter than the tRPC version's
`z.string()`. Output validation was TypeScript-only — repositories could
return malformed values and use cases happily passed them through.
There was also no consistent error-translation between domain errors
(`ArticleNotFoundError`, `AuthenticationError`, …) and `TRPCError`,
meaning the wire response code was unpredictable per feature.
Per-feature public-API surfaces conflated UI artifacts (query builders
imported React Query) with pure contracts (entity types) on the same
top-level export, making "what does this package expose to whom" muddy.
## Decision
Adopt four interlocking patterns, codified as 30 RFC-2119 rules in the
spec:
1. **Use-case file is the single source of truth for input AND output
contracts.** Every use case exports `xInputSchema` (always a
`z.ZodObject`, even for void inputs via `z.object({}).strict()`) and
— for non-void use cases — `xOutputSchema`. The use-case body ends
with `xOutputSchema.parse(result)` before returning. Type aliases
`XInput`/`XOutput`/`IXUseCase` are exported alongside.
2. **Controllers consume the use-case schema; output passes through a
co-located `function presenter`.** Controllers receive `unknown`,
`safeParse` against `xInputSchema`, throw `InputParseError` on
failure, then call the use case and pass the result through a
top-level `function presenter(value: XOutput)` defined in the same
file. The controller's return type is `Promise<ReturnType<typeof
presenter>>`. Identity presenters are permitted and expected for
pass-through cases — the function form must always exist (R11) so
adding a transform is a one-line edit. Void-output controllers
(e.g., `signOutController`, `deleteMediaController`) skip the
presenter and return `Promise<void>` (R12).
3. **Feature-scoped error→TRPCError middleware.** Each feature's
`integrations/api/procedures.ts` exports an `xProcedure` built from
`t.procedure.use(defineErrorMiddleware([[ErrorCtor, "TRPC_CODE"],
...]))`. The factory `defineErrorMiddleware` lives in
`core-shared/trpc/`; it discriminates by `instanceof` and preserves
the original error as `TRPCError.cause`. **`core-shared` never
enumerates feature-specific error classes** — each feature passes its
own constructors in via its own `procedures.ts`. Routers use the
feature's `xProcedure` instead of bare `publicProcedure` and
`.input(xInputSchema)` instead of redefining input shapes.
4. **Per-feature public surface split.** Feature root `.` exports only
contracts: domain types, domain errors, schemas, `IXUseCase`/`IXController`
aliases, router type, constants. UI artifacts (query builders,
future React components) move to a new `./ui` subpath
(`src/ui/index.ts`). Apps that need queries import from
`@repo/<feature>/ui`; apps that need the type-only contract import
from `@repo/<feature>`.
## Consequences
### Positive
- **Single source of truth for I/O contracts.** Schema drift is no
longer possible — there's one definition, imported by everyone.
- **Runtime-validated outputs.** `xOutputSchema.parse(...)` catches
"repo returned malformed data" bugs at the layer that owns the
contract, instead of silently flowing wrong shapes downstream.
- **Predictable error responses.** Every domain error maps to a known
`TRPCError.code` via the per-feature middleware; clients can rely on
status codes.
- **Discoverable transforms via presenter.** When a view needs to drop
fields, rename them, or serialize dates, the presenter function is
already there — change one function body. No structural refactor.
- **Clean public surface.** Feature root packages no longer pretend to
be UI packages; apps make explicit choices about what they need.
- **Frontend gets schemas for free.** Forms can `import { signInInputSchema
} from "@repo/auth"` and feed it into `react-hook-form` + `zodResolver`
with the same constraints the backend enforces.
### Negative
- **More code.** Every use case grows by ~10 lines (input + output
schema + parse). Every controller grows by ~5 lines (presenter,
even if identity). Acceptable cost for the consistency.
- **Per-feature `procedures.ts` boilerplate.** Five new files (~10
lines each) — one per feature. Maintaining the error map is one of
the few feature-level chores; new error classes need adding to the
map.
- **Schemas run twice on the tRPC path** (tRPC's `.input()` parse +
controller's `safeParse`). Negligible cost; zero behavioral risk
because both use the same schema. Defense-in-depth value when the
controller is invoked from non-tRPC entry points.
- **Apps with existing imports may need updating** — `articleBySlugQuery`,
`pageBySlugQuery`, etc. now live behind `@repo/<feature>/ui`.
(At Plan 9 land time, no apps consume these yet, so the cost is
forward-only.)
## Alternatives considered
- **Keep schemas in controllers (Lazar's reference pattern).** Lazar
has only one validation layer (server actions skip `.input()`), so
one schema is sufficient. Our entry point is tRPC, which insists on
a schema for type inference — putting the canonical schema in the
controller and exporting it for the router was considered. Rejected
because the use case is the contract owner; schemas describe the
*operation*, not the *transport*.
- **Centralized error-name → code map in `core-shared`.** Considered
using `error.name` discrimination with a small global registry.
Rejected because it violates feature ownership — `core-shared` would
need to know about every feature's error classes. The
`defineErrorMiddleware` factory cleanly inverts the dependency:
`core-shared` provides the plumbing, features pass their own
constructors.
- **Validate outputs only in tests.** Considered using TypeScript
types alone for output, deferring runtime validation to
contract-suite tests. Rejected because the cost of `.parse()` on
return is trivial and the bug-catching value at runtime is real
(Payload integrations have surprised us before).
- **Presenters only when reshaping.** Considered Lazar's actual rule
(presenter only when there's a transform). Rejected (R11) because
the discoverable hook for future shaping is worth the trivial
identity-function boilerplate.
- **Presenters in a separate `presenters/` folder.** Considered as a
concession to "controllers = thin orchestration". Rejected because
Lazar's reference co-locates the presenter with its consumer — the
controller — keeping the contract visible in one file.
- **Shared `./schemas` subpath.** Considered exposing schemas only via
a dedicated subpath instead of the feature root. Rejected because
schemas ARE feature contracts — they belong with the other contracts
(types, errors). Adding a fourth subpath felt like ceremony.
## Acceptance verification (Task 8, 2026-05-06)
- All Plan 9 acceptance criteria from spec §8 met.
- Tests: <N> total. Spec coverage: every R1R28 represented.
- `pnpm typecheck && pnpm lint && pnpm test && pnpm turbo boundaries
&& pnpm build` green.
- Five feature-level R26 router-error-mapping tests demonstrate domain
error → `TRPCError.code` translation works end-to-end.
(Fill in N from actual test count after Task 8 verification.)
## References
- Spec: `docs/superpowers/specs/2026-05-06-input-output-unification-design.md`
- Plan: `docs/superpowers/plans/2026-05-06-plan-9-io-unification.md`
- Refactor log: `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md`
- Reference (Lazar's blog post + repo): https://github.com/nikolovlazar/nextjs-clean-architecture
- Prior ADRs: ADR-008 (per-feature DI), ADR-011 (TDD foundation), ADR-012 (Lazar conformance)
```
- [ ] **Step 2: Append cross-reference to ADR-012**
Edit `docs/decisions/adr-012-lazar-conformance.md`. At the very end (after the existing References section), add a new section:
```markdown
## Update — 2026-05-06
Plan 9 (ADR-013) further unifies the input/output schema story:
schemas now live in the use-case file (a refinement of §What we
adopted #1's "factory-function use cases"); controllers gain a
co-located `function presenter` (extending §What we adopted #4's
"one-controller-per-use-case"); domain error → `TRPCError`
translation runs through a per-feature middleware factory (a new
concern not in this ADR). See `docs/decisions/adr-013-input-output-unification.md`.
```
- [ ] **Step 3: Add Summary section to refactor changelog**
Prepend a Summary section to `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md` (right after the front-matter):
```markdown
## Summary
**Completed:** 2026-05-06
**Branch:** main (or feature/io-unification per execution choice)
**Total commits:** 9 (Tasks 19)
**Net test count change:** <pre><post> (+<delta>)
| Category | Count |
|---|---|
| Files added | 5 × procedures.ts + 5 × ui/index.ts + 1 × define-error-middleware{.ts,.test.ts} + 1 × ADR + 1 × changelog = 14 (approx; some ui/index.ts replace existing query.ts) |
| Files modified | 12 use cases + 12 controllers + 5 routers + 5 src/index.ts + 5 package.json + 5 router tests + ~25 test files = ~70 |
| New tRPC error codes mapped | 5 (BAD_REQUEST, NOT_FOUND, UNAUTHORIZED, FORBIDDEN, plus tRPC built-in BAD_REQUEST from zod) |
### Tasks completed
| Task | Commit | Description |
|---|---|---|
| 1 | <sha> | Refactor changelog scaffold |
| 2 | <sha> | core-shared defineErrorMiddleware + t export |
| 3 | <sha> | auth migration |
| 4 | <sha> | blog migration |
| 5 | <sha> | marketing-pages migration |
| 6 | <sha> | navigation migration |
| 7 | <sha> | media migration |
| 8 | <sha> | Final verification (no commit if clean) |
| 9 | <sha> | ADR-013 + changelog summary |
### Conformance verification (Task 8)
Spec acceptance criteria §8: all met.
- Every use case exports xInputSchema; non-void use cases also export xOutputSchema.
- Every non-void controller has a top-level presenter and uses ReturnType<typeof presenter>.
- Every feature has procedures.ts with feature-scoped error map.
- core-shared/trpc/define-error-middleware.ts is the only plumbing in core-shared; no central name→code registry.
- Per-feature package.json has ./ui subpath; root index.ts no longer exports query builders.
- R25 (output-validation) test exists per non-void use case.
- R26 (router error-mapping) test exists per feature.
- pnpm typecheck && pnpm lint && pnpm test && pnpm turbo boundaries && pnpm build all green.
```
(Fill in `<sha>` and `<pre>/<post>/<delta>` from actual git log + test counts.)
- [ ] **Step 4: Final commit**
```bash
git add docs/decisions/adr-013-input-output-unification.md docs/decisions/adr-012-lazar-conformance.md docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md
git commit -m "$(cat <<'EOF'
docs(adr): ADR-013 input/output unification + Plan 9 changelog summary
Records the Plan 9 architectural decision (schemas in use-case file,
runtime output validation, presenter pattern, feature-scoped error
middleware, ./ui subpath split). ADR-012 gets a one-line cross-
reference to the new ADR. Refactor log gets a Summary section with
commit table and conformance checklist.
Plan 9 complete. The deferred doc-update pass (CLAUDE.md / AGENTS.md /
guides) — combined with the still-pending Plan 8 items — is the next
follow-up.
Refactor log: Summary, doc-update checklist
Spec: R29, R30
EOF
)"
```
---
## Self-review (after writing the plan)
- [x] Spec coverage: every rule R1R30 is represented in at least one task.
- R1, R2, R3, R4, R5: Tasks 3.A7.B (every use case migration step shows the schema + parse).
- R6 (error class names): existing classes already set `this.name` per Plan 8 — verified by R26 tests in each feature router test.
- R7, R8, R9, R10: every controller migration step (3.D, 3.E, 3.F, 4.D, 5.C, 6.3, 7.D).
- R11, R12: presenter / void distinction shown explicitly per controller.
- R13, R14, R15: each feature creates `procedures.ts` and updates router (3.G3.H, 4.E4.F, 5.D5.E, 6.56.6, 7.E7.F).
- R16, R17: Task 2 implements `defineErrorMiddleware` with `instanceof` matching.
- R18, R19, R20, R21: per-feature `src/index.ts` cleanup + `./ui` subpath + apps unaffected (no current consumers).
- R22, R23: existing Plan 8 DI pattern retained — no changes needed.
- R24, R25, R26, R27, R28: tests added per feature; identity presenters mean R27/R28 are vacuously satisfied (no shape change to assert).
- R29: Task 9 creates ADR-013.
- R30: Task 1 scaffolds the refactor log.
- [x] Placeholder scan: no "TBD"/"TODO"; all code blocks are concrete; commit messages are full text.
- [x] Type consistency: every `xInputSchema`/`XInput` pair lines up; controllers reference `XOutput` types from their use case; every router imports schemas from the same path used in tests.
- [x] No cross-task references — each per-feature task is self-contained (the auth task does not depend on the blog task being done).
## Execution
Per `superpowers:subagent-driven-development`: dispatch one implementer subagent per task with full task text + context, then spec compliance review, then code quality review, then proceed.