# 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: R13–R17
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: R1–R6, R8–R15, R18, R19, R22–R26
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.1–3.3, §5.1–5.2, §6.1–6.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: R1–R6, R8–R15, R18–R20, R22–R26
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: R1–R6, R8–R15, R18–R20, R22–R26
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: R1–R6, R8–R15, R18–R20, R22–R26
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: R1–R6, R8–R15, R18–R20, R22–R26
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 R1–R28 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 R1–R28 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 1–9)
**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 R1–R30 is represented in at least one task.
  - R1, R2, R3, R4, R5: Tasks 3.A–7.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.G–3.H, 4.E–4.F, 5.D–5.E, 6.5–6.6, 7.E–7.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.
