# Permissions System Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Extend `@wrnexus/authz` so permissions, roles, policies and attributes are declared in code and discoverable, while role assignments live in a pluggable store.

**Architecture:** A **registry** (`defineAuthz`) declares what exists; a **catalog** merges declarations and freezes at boot; a **store** (`PermissionStore`) holds who-has-what; an **engine** resolves a subject to effective permissions and returns an `AuthorizationDecision`. The decision primitives already in `advanced.ts` are the evaluation layer and are not replaced.

**Tech Stack:** TypeScript, Bun (`bun:test`), `@wrnexus/core` (Context/Middleware types only), `@wrnexus/db` (Db interface, migrations).

## Global Constraints

- Every `@wrnexus/*` package is version `0.8.4`. Do not change versions.
- Zero runtime npm dependencies. Use only Bun/WebCrypto/node: builtins.
- `@wrnexus/core` MUST NOT import `@wrnexus/authz`. `can()` stays off `Context`; the resolver lives in `ctx.locals._authz`.
- `@wrnexus/authz` may import **types only** from `@wrnexus/core` (`import type { Context, Middleware }`).
- Existing exports of `@wrnexus/authz` keep working unchanged, with ONE approved exception: Task 8 changes the default 403 body of `authorizeDecision` to stop disclosing policy internals. That break is intentional and ruled on; everything else is additive.
- Framework-owned tables use the `_wrn_` prefix (matching `_wrn_tenant`, `_wrn_cursor`). The spec wrote `wrn_authz_assignment`; use `_wrn_authz_assignment` and `_wrn_authz_grant`.
- `requirePermission` is already exported with signature `(rbac: Rbac, permission: string)`. Do not change it. The new resource-aware guard is named `guardPermission`.
- Every failure path denies. Never fail open.
- After any change to `packages/authz/src/index.ts` exports, regenerate the API baseline with `bun run generate:public-api`.
- Full gate before declaring done: `bun run check:production`.
- Test files live in `packages/<pkg>/test/*.test.ts` and use `import { describe, expect, test } from "bun:test"`.

---

## File Structure

**Created:**

| File                                       | Responsibility                                                                                    |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------- |
| `packages/authz/src/types.ts`              | Shared types: `AuthzScope`, `PermissionMeta`, `AuthzModule`, `AuthzCatalog`, `SubjectAssignments` |
| `packages/authz/src/registry.ts`           | `defineAuthz()` — validate and freeze one declaration module                                      |
| `packages/authz/src/catalog.ts`            | `mergeCatalogs()` — merge modules, detect conflicts, freeze                                       |
| `packages/authz/src/store.ts`              | `PermissionStore` interface, `memoryPermissionStore()`, `cachedPermissionStore()`                 |
| `packages/authz/src/audit.ts`              | `AuthzAuditSink`, `memoryAuditSink()`, `consoleAuditSink()`                                       |
| `packages/authz/src/engine.ts`             | `createAuthzResolver()` — effective permissions, precedence, fail-closed                          |
| `packages/authz/src/middleware.ts`         | `authzMiddleware()`, `can()`, `decideFor()`, `guardPermission()`                                  |
| `packages/authz/src/db.ts`                 | `dbPermissionStore(db)` — subpath export `@wrnexus/authz/db`                                      |
| `packages/authz/src/migrations.ts`         | `authzMigrationSql(dialect)` — DDL for the two tables                                             |
| `packages/authz/src/codegen.ts`            | `generatePermissionTypes(catalog)` — emits the `Permission`/`Role` unions                         |
| `packages/authz/test/store-conformance.ts` | Shared suite both store adapters must pass (not a `.test.ts`)                                     |
| `packages/cli/src/authz.ts`                | `runAuthzCommand(root, sub, args)` for `list` / `init` / `generate`                               |

**Modified:**

| File                             | Change                                                          |
| -------------------------------- | --------------------------------------------------------------- |
| `packages/authz/src/index.ts`    | Re-export the new surface                                       |
| `packages/authz/src/advanced.ts` | `authorizeDecision` gains `{ exposeReason }`, defaulting to off |
| `packages/authz/package.json`    | Add `./db` subpath export                                       |
| `packages/router/src/index.ts`   | Discover `app/authz/*.{ts,js}` into `router.authz`              |
| `packages/cli/src/index.ts`      | Dispatch `case "authz"`                                         |
| `docs/public-api-0.8.json`       | Regenerated baseline                                            |

---

## Task 1: Types and registry

**Files:**

- Create: `packages/authz/src/types.ts`
- Create: `packages/authz/src/registry.ts`
- Test: `packages/authz/test/registry.test.ts`

**Interfaces:**

- Consumes: `DecisionPolicy` from `./advanced.ts`
- Produces: `AuthzScope`, `PermissionMeta`, `AuthzModule`, `AuthzCatalog`, `SubjectAssignments`, `defineAuthz(module: AuthzModule): AuthzModule`

- [ ] **Step 1: Write the failing test**

Create `packages/authz/test/registry.test.ts`:

```ts
import { describe, expect, test } from "bun:test";
import { defineAuthz } from "../src/registry.ts";

describe("defineAuthz", () => {
  test("returns a frozen module", () => {
    const mod = defineAuthz({
      permissions: { "post:read": { title: "View posts" } },
      roles: { editor: ["post:*"] },
    });
    expect(Object.isFrozen(mod)).toBe(true);
    expect(mod.permissions!["post:read"]!.title).toBe("View posts");
    expect(mod.roles!.editor).toEqual(["post:*"]);
  });

  test("defaults missing sections to empty objects", () => {
    const mod = defineAuthz({});
    expect(mod.permissions).toEqual({});
    expect(mod.roles).toEqual({});
    expect(mod.policies).toEqual({});
    expect(mod.attributes).toEqual({});
    expect(mod.bindings).toEqual({});
  });

  test("rejects a permission id that is not colon-namespaced lowercase", () => {
    expect(() => defineAuthz({ permissions: { "Post Read": {} } })).toThrow(/permission id/i);
    expect(() => defineAuthz({ permissions: { "post:*": {} } })).toThrow(/wildcard/i);
  });

  test("rejects a role granting an unknown-shaped entry", () => {
    expect(() => defineAuthz({ roles: { editor: [""] } })).toThrow(/role 'editor'/i);
  });

  test("rejects a binding naming a policy that is not declared", () => {
    expect(() =>
      defineAuthz({
        permissions: { "post:write": {} },
        bindings: { "post:write": ["missingPolicy"] },
      }),
    ).toThrow(/missingPolicy/);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `bun test packages/authz/test/registry.test.ts`
Expected: FAIL — cannot resolve `../src/registry.ts`

- [ ] **Step 3: Write the types**

Create `packages/authz/src/types.ts`:

```ts
import type { DecisionPolicy } from "./advanced.ts";

/** Narrows an assignment to a tenant. Absent means a global assignment. */
export interface AuthzScope {
  tenantId?: string;
}

export interface PermissionMeta {
  title?: string;
  description?: string;
  risk?: "low" | "medium" | "high";
  /** Granted to anonymous subjects. Every other permission denies without a user. */
  public?: boolean;
}

export interface AttributeMeta {
  description?: string;
}

/** One `app/authz/<name>.ts` declaration. */
export interface AuthzModule {
  permissions?: Record<string, PermissionMeta>;
  roles?: Record<string, string[]>;
  policies?: Record<string, DecisionPolicy<never, never>>;
  attributes?: Record<string, AttributeMeta>;
  /** permission id -> policy names that must pass for it. */
  bindings?: Record<string, string[]>;
}

/** The merged, frozen view of every declaration in the app. */
export interface AuthzCatalog {
  permissions: ReadonlyMap<string, PermissionMeta>;
  roles: ReadonlyMap<string, readonly string[]>;
  policies: ReadonlyMap<string, DecisionPolicy<never, never>>;
  attributes: ReadonlyMap<string, AttributeMeta>;
  bindings: ReadonlyMap<string, readonly string[]>;
}

export interface SubjectAssignments {
  roles: string[];
  /** Explicit allows, bypassing roles. */
  grants: string[];
  /** Explicit denies. Win over everything, including "*". */
  denies: string[];
}
```

- [ ] **Step 4: Write the registry**

Create `packages/authz/src/registry.ts`:

```ts
import type { AuthzModule } from "./types.ts";

const PERMISSION_ID = /^[a-z0-9]+(?::[a-z0-9-]+)+$/;

/**
 * Validate and freeze one authorization declaration. Called from
 * `app/authz/<name>.ts` as the module's default export.
 */
export function defineAuthz(module: AuthzModule): AuthzModule {
  const permissions = module.permissions ?? {};
  const roles = module.roles ?? {};
  const policies = module.policies ?? {};
  const attributes = module.attributes ?? {};
  const bindings = module.bindings ?? {};

  for (const id of Object.keys(permissions)) {
    if (id.includes("*")) {
      throw new Error(
        `WRN-AUTHZ-DECL: permission id '${id}' must not contain a wildcard; wildcards belong in roles.`,
      );
    }
    if (!PERMISSION_ID.test(id)) {
      throw new Error(
        `WRN-AUTHZ-DECL: permission id '${id}' must be lowercase colon-namespaced, e.g. 'post:read'.`,
      );
    }
  }

  for (const [role, grants] of Object.entries(roles)) {
    for (const grant of grants) {
      if (typeof grant !== "string" || !grant.trim()) {
        throw new Error(
          `WRN-AUTHZ-DECL: role '${role}' grants an empty entry; expected a permission, 'ns:*', or 'role:<name>'.`,
        );
      }
    }
  }

  for (const [permission, names] of Object.entries(bindings)) {
    for (const name of names) {
      if (!(name in policies)) {
        throw new Error(
          `WRN-AUTHZ-DECL: binding for '${permission}' names policy '${name}', which is not declared in the same module.`,
        );
      }
    }
  }

  return Object.freeze({ permissions, roles, policies, attributes, bindings });
}
```

- [ ] **Step 5: Run test to verify it passes**

Run: `bun test packages/authz/test/registry.test.ts`
Expected: PASS, 5 tests

- [ ] **Step 6: Commit**

```bash
git add packages/authz/src/types.ts packages/authz/src/registry.ts packages/authz/test/registry.test.ts
git commit -m "feat(authz): add defineAuthz declaration registry"
```

---

## Task 2: Catalog merge and conflict detection

**Files:**

- Create: `packages/authz/src/catalog.ts`
- Test: `packages/authz/test/catalog.test.ts`

**Interfaces:**

- Consumes: `AuthzModule`, `AuthzCatalog` from `./types.ts`; `defineAuthz` from `./registry.ts`
- Produces: `mergeCatalogs(sources: CatalogSource[]): AuthzCatalog`, `interface CatalogSource { source: string; module: AuthzModule }`, `emptyCatalog(): AuthzCatalog`

- [ ] **Step 1: Write the failing test**

Create `packages/authz/test/catalog.test.ts`:

```ts
import { describe, expect, test } from "bun:test";
import { defineAuthz } from "../src/registry.ts";
import { emptyCatalog, mergeCatalogs } from "../src/catalog.ts";

describe("mergeCatalogs", () => {
  test("merges disjoint modules", () => {
    const catalog = mergeCatalogs([
      { source: "a.ts", module: defineAuthz({ permissions: { "post:read": {} } }) },
      { source: "b.ts", module: defineAuthz({ permissions: { "user:read": {} } }) },
    ]);
    expect([...catalog.permissions.keys()].sort()).toEqual(["post:read", "user:read"]);
  });

  test("re-declaring a permission with deep-equal metadata is a no-op", () => {
    const meta = { title: "View posts", risk: "low" as const };
    const catalog = mergeCatalogs([
      { source: "a.ts", module: defineAuthz({ permissions: { "post:read": meta } }) },
      { source: "b.ts", module: defineAuthz({ permissions: { "post:read": { ...meta } } }) },
    ]);
    expect(catalog.permissions.size).toBe(1);
  });

  test("conflicting metadata is a boot error naming both files", () => {
    expect(() =>
      mergeCatalogs([
        { source: "a.ts", module: defineAuthz({ permissions: { "post:read": { risk: "low" } } }) },
        { source: "b.ts", module: defineAuthz({ permissions: { "post:read": { risk: "high" } } }) },
      ]),
    ).toThrow(/a\.ts.*b\.ts|b\.ts.*a\.ts/s);
  });

  test("conflicting role definitions are a boot error", () => {
    expect(() =>
      mergeCatalogs([
        { source: "a.ts", module: defineAuthz({ roles: { editor: ["post:read"] } }) },
        { source: "b.ts", module: defineAuthz({ roles: { editor: ["post:write"] } }) },
      ]),
    ).toThrow(/editor/);
  });

  test("bindings for the same permission union across modules", () => {
    const p1 = defineAuthz({
      permissions: { "post:write": {} },
      policies: { ownsPost: async () => ({ allowed: true }) },
      bindings: { "post:write": ["ownsPost"] },
    });
    const p2 = defineAuthz({
      policies: { notLocked: async () => ({ allowed: true }) },
      bindings: { "post:write": ["notLocked"] },
    });
    const catalog = mergeCatalogs([
      { source: "a.ts", module: p1 },
      { source: "b.ts", module: p2 },
    ]);
    expect([...catalog.bindings.get("post:write")!].sort()).toEqual(["notLocked", "ownsPost"]);
  });

  test("a binding referencing a policy no module declares is a boot error", () => {
    expect(() =>
      mergeCatalogs([
        {
          source: "a.ts",
          module: { permissions: { "post:write": {} }, bindings: { "post:write": ["ghost"] } },
        },
      ]),
    ).toThrow(/ghost/);
  });

  test("the merged catalog is frozen", () => {
    const catalog = mergeCatalogs([]);
    expect(() => (catalog.permissions as Map<string, never>).set("x:y", {} as never)).toThrow();
  });

  test("emptyCatalog has no entries", () => {
    expect(emptyCatalog().permissions.size).toBe(0);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `bun test packages/authz/test/catalog.test.ts`
Expected: FAIL — cannot resolve `../src/catalog.ts`

- [ ] **Step 3: Write the implementation**

Create `packages/authz/src/catalog.ts`:

```ts
import type { AttributeMeta, AuthzCatalog, AuthzModule, PermissionMeta } from "./types.ts";
import type { DecisionPolicy } from "./advanced.ts";

export interface CatalogSource {
  /** File or package that declared this module, used in conflict messages. */
  source: string;
  module: AuthzModule;
}

/** Structural equality for declaration metadata. Key order is irrelevant. */
function deepEqual(a: unknown, b: unknown): boolean {
  if (Object.is(a, b)) return true;
  if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
  if (Array.isArray(a) !== Array.isArray(b)) return false;
  const left = a as Record<string, unknown>;
  const right = b as Record<string, unknown>;
  const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
  for (const key of keys) if (!deepEqual(left[key], right[key])) return false;
  return true;
}

/** A frozen Map that throws on mutation, so the catalog cannot drift after boot. */
function frozenMap<V>(entries: Iterable<[string, V]>): ReadonlyMap<string, V> {
  const map = new Map(entries);
  const reject = () => {
    throw new Error("WRN-AUTHZ-FROZEN: the authorization catalog is frozen after boot.");
  };
  map.set = reject as never;
  map.delete = reject as never;
  map.clear = reject as never;
  return map;
}

export function emptyCatalog(): AuthzCatalog {
  return {
    permissions: frozenMap<PermissionMeta>([]),
    roles: frozenMap<readonly string[]>([]),
    policies: frozenMap<DecisionPolicy<never, never>>([]),
    attributes: frozenMap<AttributeMeta>([]),
    bindings: frozenMap<readonly string[]>([]),
  };
}

export function mergeCatalogs(sources: CatalogSource[]): AuthzCatalog {
  const permissions = new Map<string, PermissionMeta>();
  const roles = new Map<string, readonly string[]>();
  const policies = new Map<string, DecisionPolicy<never, never>>();
  const attributes = new Map<string, AttributeMeta>();
  const bindings = new Map<string, Set<string>>();
  const origin = new Map<string, string>();

  const claim = (
    kind: string,
    key: string,
    source: string,
    existingValue: unknown,
    value: unknown,
  ) => {
    const previous = origin.get(`${kind}:${key}`);
    if (previous === undefined) {
      origin.set(`${kind}:${key}`, source);
      return;
    }
    if (!deepEqual(existingValue, value)) {
      throw new Error(
        `WRN-AUTHZ-CONFLICT: ${kind} '${key}' is declared differently in ${previous} and ${source}.`,
      );
    }
  };

  for (const { source, module } of sources) {
    for (const [id, meta] of Object.entries(module.permissions ?? {})) {
      claim("permission", id, source, permissions.get(id), meta);
      permissions.set(id, meta);
    }
    for (const [name, grants] of Object.entries(module.roles ?? {})) {
      claim("role", name, source, roles.get(name), grants);
      roles.set(name, grants);
    }
    for (const [name, policy] of Object.entries(module.policies ?? {})) {
      // Two closures are never deep-equal, so identity is the only sane test.
      const existing = policies.get(name);
      if (existing && existing !== policy) {
        throw new Error(
          `WRN-AUTHZ-CONFLICT: policy '${name}' is declared differently in ${origin.get(`policy:${name}`)} and ${source}.`,
        );
      }
      origin.set(`policy:${name}`, source);
      policies.set(name, policy);
    }
    for (const [name, meta] of Object.entries(module.attributes ?? {})) {
      claim("attribute", name, source, attributes.get(name), meta);
      attributes.set(name, meta);
    }
    for (const [permission, names] of Object.entries(module.bindings ?? {})) {
      const set = bindings.get(permission) ?? new Set<string>();
      for (const name of names) set.add(name);
      bindings.set(permission, set);
    }
  }

  for (const [permission, names] of bindings) {
    for (const name of names) {
      if (!policies.has(name)) {
        throw new Error(
          `WRN-AUTHZ-CONFLICT: binding for '${permission}' names policy '${name}', which no module declares.`,
        );
      }
    }
  }

  return {
    permissions: frozenMap(permissions),
    roles: frozenMap(roles),
    policies: frozenMap(policies),
    attributes: frozenMap(attributes),
    bindings: frozenMap([...bindings].map(([k, v]) => [k, [...v]] as [string, readonly string[]])),
  };
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `bun test packages/authz/test/catalog.test.ts`
Expected: PASS, 8 tests

- [ ] **Step 5: Commit**

```bash
git add packages/authz/src/catalog.ts packages/authz/test/catalog.test.ts
git commit -m "feat(authz): merge declaration modules into a frozen catalog"
```

---

## Task 3: PermissionStore interface, memory adapter, conformance suite

**Files:**

- Create: `packages/authz/src/store.ts`
- Create: `packages/authz/test/store-conformance.ts`
- Test: `packages/authz/test/store-memory.test.ts`

**Interfaces:**

- Consumes: `AuthzScope`, `SubjectAssignments` from `./types.ts`
- Produces: `PermissionStore`, `memoryPermissionStore(): PermissionStore`, `runStoreConformance(name: string, makeStore: () => Promise<PermissionStore>)`

- [ ] **Step 1: Write the conformance suite**

Create `packages/authz/test/store-conformance.ts`. This is imported by adapter tests; it has no `.test.ts` suffix so Bun does not run it directly.

```ts
import { beforeEach, describe, expect, test } from "bun:test";
import type { PermissionStore } from "../src/store.ts";

/**
 * Every PermissionStore adapter must pass this suite, so the memory and db
 * implementations cannot drift apart.
 */
export function runStoreConformance(name: string, makeStore: () => Promise<PermissionStore>): void {
  describe(`PermissionStore conformance: ${name}`, () => {
    let store: PermissionStore;
    beforeEach(async () => {
      store = await makeStore();
    });

    test("an unknown subject has empty assignments", async () => {
      expect(await store.assignmentsFor("nobody")).toEqual({
        roles: [],
        grants: [],
        denies: [],
      });
    });

    test("assignRole then assignmentsFor round-trips", async () => {
      await store.assignRole("u1", "editor");
      expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]);
    });

    test("assignRole is idempotent", async () => {
      await store.assignRole("u1", "editor");
      await store.assignRole("u1", "editor");
      expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]);
    });

    test("revokeRole removes only that role", async () => {
      await store.assignRole("u1", "editor");
      await store.assignRole("u1", "admin");
      await store.revokeRole("u1", "editor");
      expect((await store.assignmentsFor("u1")).roles).toEqual(["admin"]);
    });

    test("revoking a role that was never assigned is a no-op", async () => {
      await store.revokeRole("u1", "ghost");
      expect((await store.assignmentsFor("u1")).roles).toEqual([]);
    });

    test("scoped assignments do not leak across tenants", async () => {
      await store.assignRole("u1", "editor", { tenantId: "t1" });
      expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["editor"]);
      expect((await store.assignmentsFor("u1", { tenantId: "t2" })).roles).toEqual([]);
    });

    test("a global assignment is visible inside every tenant", async () => {
      await store.assignRole("u1", "superadmin");
      expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["superadmin"]);
    });

    test("global and scoped roles union within a tenant", async () => {
      await store.assignRole("u1", "viewer");
      await store.assignRole("u1", "editor", { tenantId: "t1" });
      expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles.sort()).toEqual([
        "editor",
        "viewer",
      ]);
    });

    test("grant with allow and deny land in the right buckets", async () => {
      await store.grant("u1", "post:write", "allow");
      await store.grant("u1", "post:delete", "deny");
      const assignments = await store.assignmentsFor("u1");
      expect(assignments.grants).toEqual(["post:write"]);
      expect(assignments.denies).toEqual(["post:delete"]);
    });

    test("re-granting the same permission replaces its effect", async () => {
      await store.grant("u1", "post:write", "allow");
      await store.grant("u1", "post:write", "deny");
      const assignments = await store.assignmentsFor("u1");
      expect(assignments.grants).toEqual([]);
      expect(assignments.denies).toEqual(["post:write"]);
    });

    test("revokeGrant removes the permission entirely", async () => {
      await store.grant("u1", "post:write", "allow");
      await store.revokeGrant("u1", "post:write");
      expect((await store.assignmentsFor("u1")).grants).toEqual([]);
    });

    test("listSubjects returns everyone with an assignment in scope", async () => {
      await store.assignRole("u1", "editor", { tenantId: "t1" });
      await store.assignRole("u2", "editor", { tenantId: "t1" });
      await store.assignRole("u3", "editor", { tenantId: "t2" });
      expect((await store.listSubjects({ tenantId: "t1" })).sort()).toEqual(["u1", "u2"]);
    });

    test("an explicitly empty tenantId is refused, not treated as global", async () => {
      await store.assignRole("g1", "viewer");
      // Otherwise a caller who controls the tenant id reaches global scope.
      await expect(store.assignmentsFor("g1", { tenantId: "" })).rejects.toThrow(/tenantId/);
      await expect(store.assignRole("g1", "admin", { tenantId: "" })).rejects.toThrow(/tenantId/);
    });

    test("a non-string tenantId is refused", async () => {
      // Same class as the empty-string case: the caller controls this value.
      for (const bad of [null, 0, false, {}]) {
        await expect(store.assignmentsFor("u1", { tenantId: bad as never })).rejects.toThrow(
          /tenantId/,
        );
      }
    });

    test("concurrent identical assignRole calls all resolve", async () => {
      // Check-then-act loses this race; the UNIQUE constraint then rejects
      // every loser even though the desired end state was already reached.
      await Promise.all(Array.from({ length: 20 }, () => store.assignRole("u1", "editor")));
      expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]);
    });

    test("concurrent grants on distinct keys all resolve", async () => {
      await Promise.all([
        store.grant("u1", "post:read", "allow"),
        store.grant("u1", "post:write", "allow"),
        store.grant("u1", "post:delete", "deny"),
      ]);
      const assignments = await store.assignmentsFor("u1");
      expect(assignments.grants.sort()).toEqual(["post:read", "post:write"]);
      expect(assignments.denies).toEqual(["post:delete"]);
    });

    test("a rejected write leaves unrelated state intact", async () => {
      await store.assignRole("victim", "admin");
      await store.grant("victim", "post:read", "allow");
      // An invalid effect must be refused without disturbing anything else.
      await expect(store.grant("victim", "post:write", "bogus" as never)).rejects.toThrow();
      const assignments = await store.assignmentsFor("victim");
      expect(assignments.roles).toEqual(["admin"]);
      expect(assignments.grants).toEqual(["post:read"]);
    });

    // NOTE: the shared-connection rollback hazard - where one method's open
    // transaction sweeps in a concurrent bare write from another method and
    // discards it, so a revoke resolves successfully while the role survives -
    // is prevented STRUCTURALLY, by the store using no transactions at all.
    // It is deliberately not covered here: reproducing it needs the bare write
    // to land inside the open transaction, which a single-process Promise.all
    // does not reliably arrange, so any such test would pass against the
    // defective implementation and give false assurance.

    test("listSubjects with no scope returns global assignees only", async () => {
      await store.assignRole("g1", "viewer");
      await store.assignRole("s1", "editor", { tenantId: "t1" });
      expect(await store.listSubjects()).toEqual(["g1"]);
    });
  });
}
```

- [ ] **Step 2: Write the memory adapter test**

Create `packages/authz/test/store-memory.test.ts`:

```ts
import { memoryPermissionStore } from "../src/store.ts";
import { runStoreConformance } from "./store-conformance.ts";

runStoreConformance("memory", async () => memoryPermissionStore());
```

- [ ] **Step 3: Run test to verify it fails**

Run: `bun test packages/authz/test/store-memory.test.ts`
Expected: FAIL — cannot resolve `../src/store.ts`

- [ ] **Step 4: Write the implementation**

Create `packages/authz/src/store.ts`:

```ts
import type { AuthzScope, SubjectAssignments } from "./types.ts";

export type GrantEffect = "allow" | "deny";

export interface PermissionStore {
  assignmentsFor(subjectId: string, scope?: AuthzScope): Promise<SubjectAssignments>;
  assignRole(subjectId: string, role: string, scope?: AuthzScope): Promise<void>;
  revokeRole(subjectId: string, role: string, scope?: AuthzScope): Promise<void>;
  grant(
    subjectId: string,
    permission: string,
    effect: GrantEffect,
    scope?: AuthzScope,
  ): Promise<void>;
  revokeGrant(subjectId: string, permission: string, scope?: AuthzScope): Promise<void>;
  listSubjects(scope?: AuthzScope): Promise<string[]>;
}

/**
 * Global assignments are stored under the empty-string scope key. An OMITTED
 * scope means global; an explicitly EMPTY tenantId is refused, because it is
 * indistinguishable from global and would let a caller who controls the tenant
 * id read and write global assignments.
 */
export function scopeKey(scope?: AuthzScope): string {
  const tenantId = scope?.tenantId;
  if (tenantId === undefined) return "";
  // Guard the TYPE as well as the value: a null from a JSON body or a nullable
  // column would otherwise flow through un-normalised and the adapters would
  // disagree about what happened - the db rejects on NOT NULL, memory accepts
  // an unreachable row.
  if (typeof tenantId !== "string" || tenantId === "") {
    throw new Error(
      "WRN-AUTHZ-SCOPE: tenantId must be a non-empty string; omit the scope for a global assignment.",
    );
  }
  return tenantId;
}

interface Row {
  subjectId: string;
  scope: string;
}
interface RoleRow extends Row {
  role: string;
}
interface GrantRow extends Row {
  permission: string;
  effect: GrantEffect;
}

export function memoryPermissionStore(): PermissionStore {
  const roles: RoleRow[] = [];
  const grants: GrantRow[] = [];

  // A request inside tenant t sees global assignments plus t's own.
  const visible = (row: Row, key: string) => row.scope === "" || row.scope === key;

  return {
    async assignmentsFor(subjectId, scope) {
      const key = scopeKey(scope);
      const mine = (row: Row) => row.subjectId === subjectId && visible(row, key);
      const matched = grants.filter(mine);
      return {
        roles: roles.filter(mine).map((row) => row.role),
        grants: matched.filter((row) => row.effect === "allow").map((row) => row.permission),
        denies: matched.filter((row) => row.effect === "deny").map((row) => row.permission),
      };
    },
    async assignRole(subjectId, role, scope) {
      const key = scopeKey(scope);
      if (roles.some((r) => r.subjectId === subjectId && r.scope === key && r.role === role))
        return;
      roles.push({ subjectId, scope: key, role });
    },
    async revokeRole(subjectId, role, scope) {
      const key = scopeKey(scope);
      const at = roles.findIndex(
        (r) => r.subjectId === subjectId && r.scope === key && r.role === role,
      );
      if (at !== -1) roles.splice(at, 1);
    },
    async grant(subjectId, permission, effect, scope) {
      const key = scopeKey(scope);
      const at = grants.findIndex(
        (g) => g.subjectId === subjectId && g.scope === key && g.permission === permission,
      );
      if (at !== -1) grants.splice(at, 1);
      grants.push({ subjectId, scope: key, permission, effect });
    },
    async revokeGrant(subjectId, permission, scope) {
      const key = scopeKey(scope);
      const at = grants.findIndex(
        (g) => g.subjectId === subjectId && g.scope === key && g.permission === permission,
      );
      if (at !== -1) grants.splice(at, 1);
    },
    async listSubjects(scope) {
      const key = scopeKey(scope);
      const ids = new Set<string>();
      for (const row of roles) if (row.scope === key) ids.add(row.subjectId);
      for (const row of grants) if (row.scope === key) ids.add(row.subjectId);
      return [...ids];
    },
  };
}
```

- [ ] **Step 5: Run test to verify it passes**

Run: `bun test packages/authz/test/store-memory.test.ts`
Expected: PASS, 13 tests

- [ ] **Step 6: Commit**

```bash
git add packages/authz/src/store.ts packages/authz/test/store-conformance.ts packages/authz/test/store-memory.test.ts
git commit -m "feat(authz): add PermissionStore contract with memory adapter and conformance suite"
```

---

## Task 4: Cached store decorator

**Files:**

- Modify: `packages/authz/src/store.ts` (append)
- Test: `packages/authz/test/store-cached.test.ts`

**Interfaces:**

- Consumes: `PermissionStore`, `scopeKey` from `./store.ts`
- Produces: `cachedPermissionStore(inner: PermissionStore, options?: { ttlMs?: number; max?: number }): CachedPermissionStore`, `interface CachedPermissionStore extends PermissionStore { invalidate(subjectId: string, scope?: AuthzScope): void; invalidateAll(): void }`

- [ ] **Step 1: Write the failing test**

Create `packages/authz/test/store-cached.test.ts`:

```ts
import { describe, expect, test } from "bun:test";
import { cachedPermissionStore, memoryPermissionStore } from "../src/store.ts";
import { runStoreConformance } from "./store-conformance.ts";

// A cache must not change observable behaviour: writes invalidate internally.
runStoreConformance("cached(memory)", async () => cachedPermissionStore(memoryPermissionStore()));

describe("cachedPermissionStore", () => {
  test("serves a repeat read from cache", async () => {
    const inner = memoryPermissionStore();
    let reads = 0;
    const counting = {
      ...inner,
      assignmentsFor: (id: string, scope?: { tenantId?: string }) => {
        reads++;
        return inner.assignmentsFor(id, scope);
      },
    };
    const store = cachedPermissionStore(counting, { ttlMs: 60_000 });
    await store.assignmentsFor("u1");
    await store.assignmentsFor("u1");
    expect(reads).toBe(1);
  });

  test("a write invalidates that subject", async () => {
    const store = cachedPermissionStore(memoryPermissionStore(), { ttlMs: 60_000 });
    await store.assignmentsFor("u1");
    await store.assignRole("u1", "editor");
    expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]);
  });

  test("invalidate() drops a cached subject", async () => {
    const inner = memoryPermissionStore();
    const store = cachedPermissionStore(inner, { ttlMs: 60_000 });
    await store.assignmentsFor("u1");
    await inner.assignRole("u1", "editor"); // behind the cache's back
    expect((await store.assignmentsFor("u1")).roles).toEqual([]);
    store.invalidate("u1");
    expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]);
  });

  test("entries expire after ttlMs", async () => {
    const inner = memoryPermissionStore();
    const store = cachedPermissionStore(inner, { ttlMs: 1 });
    await store.assignmentsFor("u1");
    await inner.assignRole("u1", "editor");
    await Bun.sleep(5);
    expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]);
  });

  test("cache is bounded by max", async () => {
    const store = cachedPermissionStore(memoryPermissionStore(), { ttlMs: 60_000, max: 2 });
    await store.assignmentsFor("a");
    await store.assignmentsFor("b");
    await store.assignmentsFor("c");
    expect(store.size()).toBeLessThanOrEqual(2);
  });

  test("a global write invalidates the subject in every tenant", async () => {
    const inner = memoryPermissionStore();
    const store = cachedPermissionStore(inner, { ttlMs: 60_000 });
    await store.assignmentsFor("u1", { tenantId: "t1" }); // warm the tenant entry
    await store.assignRole("u1", "editor"); // global write
    // Global roles are visible inside every tenant, so the cached t1 entry
    // must not survive this write.
    expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["editor"]);
  });

  test("cache keys cannot collide across subject/tenant boundaries", async () => {
    const inner = memoryPermissionStore();
    const store = cachedPermissionStore(inner, { ttlMs: 60_000 });
    // Naive "scope + separator + subject" concatenation makes these two pairs
    // produce the same key, serving one subject the other's permissions.
    await inner.assignRole("b�c", "editor", { tenantId: "a" });
    expect((await store.assignmentsFor("b�c", { tenantId: "a" })).roles).toEqual(["editor"]);
    expect((await store.assignmentsFor("c", { tenantId: "a�b" })).roles).toEqual([]);
  });

  test("scoped and global reads cache separately", async () => {
    const inner = memoryPermissionStore();
    const store = cachedPermissionStore(inner, { ttlMs: 60_000 });
    await inner.assignRole("u1", "editor", { tenantId: "t1" });
    expect((await store.assignmentsFor("u1")).roles).toEqual([]);
    expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["editor"]);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `bun test packages/authz/test/store-cached.test.ts`
Expected: FAIL — `cachedPermissionStore` is not exported

- [ ] **Step 3: Append the implementation to `packages/authz/src/store.ts`**

```ts
export interface CachedPermissionStore extends PermissionStore {
  /** Drop one subject. Call after changing roles out of band. */
  invalidate(subjectId: string, scope?: AuthzScope): void;
  invalidateAll(): void;
  /** Cached entry count, for tests and diagnostics. */
  size(): number;
}

export interface CacheOptions {
  ttlMs?: number;
  max?: number;
}

/**
 * Caches assignment reads. Writes through this decorator invalidate the
 * affected subject immediately; changes made directly against the inner store
 * need an explicit `invalidate()` call rather than waiting out the TTL.
 */
export function cachedPermissionStore(
  inner: PermissionStore,
  options: CacheOptions = {},
): CachedPermissionStore {
  const ttlMs = options.ttlMs ?? 5_000;
  const max = options.max ?? 1_000;
  const entries = new Map<string, { at: number; value: SubjectAssignments }>();

  // Subject and tenant ids are unconstrained strings, so the key must be
  // unambiguous: concatenating around a separator lets ("a", "b<sep>c") and
  // ("a<sep>b", "c") collide, which would serve one subject another's
  // permissions. JSON encoding escapes the components.
  const cacheKey = (subjectId: string, scope?: AuthzScope) =>
    JSON.stringify([scopeKey(scope), subjectId]);
  // Track subjects separately rather than pattern-matching key strings, so a
  // global write can find every tenant entry without substring guesswork.
  const bySubject = new Map<string, Set<string>>();
  const drop = (subjectId: string, scope?: AuthzScope) => {
    // A global write changes what every tenant sees for that subject.
    if (scopeKey(scope) === "") {
      for (const key of bySubject.get(subjectId) ?? []) entries.delete(key);
      bySubject.delete(subjectId);
      return;
    }
    const key = cacheKey(subjectId, scope);
    entries.delete(key);
    bySubject.get(subjectId)?.delete(key);
  };

  return {
    async assignmentsFor(subjectId, scope) {
      const key = cacheKey(subjectId, scope);
      const hit = entries.get(key);
      if (hit && Date.now() - hit.at < ttlMs) return hit.value;
      const value = await inner.assignmentsFor(subjectId, scope);
      if (entries.size >= max) {
        const oldest = entries.keys().next().value!;
        entries.delete(oldest);
        for (const keys of bySubject.values()) keys.delete(oldest);
      }
      entries.set(key, { at: Date.now(), value });
      let keys = bySubject.get(subjectId);
      if (!keys) bySubject.set(subjectId, (keys = new Set()));
      keys.add(key);
      return value;
    },
    async assignRole(subjectId, role, scope) {
      await inner.assignRole(subjectId, role, scope);
      drop(subjectId, scope);
    },
    async revokeRole(subjectId, role, scope) {
      await inner.revokeRole(subjectId, role, scope);
      drop(subjectId, scope);
    },
    async grant(subjectId, permission, effect, scope) {
      await inner.grant(subjectId, permission, effect, scope);
      drop(subjectId, scope);
    },
    async revokeGrant(subjectId, permission, scope) {
      await inner.revokeGrant(subjectId, permission, scope);
      drop(subjectId, scope);
    },
    listSubjects: (scope) => inner.listSubjects(scope),
    invalidate: drop,
    invalidateAll: () => {
      entries.clear();
      bySubject.clear();
    },
    size: () => entries.size,
  };
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `bun test packages/authz/test/store-cached.test.ts`
Expected: PASS — 13 conformance tests plus 6 cache tests

- [ ] **Step 5: Commit**

```bash
git add packages/authz/src/store.ts packages/authz/test/store-cached.test.ts
git commit -m "feat(authz): add caching decorator for PermissionStore"
```

---

## Task 5: Audit sink

**Files:**

- Create: `packages/authz/src/audit.ts`
- Test: `packages/authz/test/audit.test.ts`

**Interfaces:**

- Consumes: `AuthzScope` from `./types.ts`
- Produces: `AuthzAuditEvent`, `AuthzAuditSink`, `memoryAuditSink(): MemoryAuditSink`, `consoleAuditSink(): AuthzAuditSink`, `safeRecord(sink, event): void`

- [ ] **Step 1: Write the failing test**

Create `packages/authz/test/audit.test.ts`:

```ts
import { describe, expect, test } from "bun:test";
import { memoryAuditSink, safeRecord } from "../src/audit.ts";

describe("audit sink", () => {
  test("memoryAuditSink collects events", () => {
    const sink = memoryAuditSink();
    sink.record({ permission: "post:read", allowed: true, at: 1 });
    expect(sink.events).toHaveLength(1);
    expect(sink.events[0]!.permission).toBe("post:read");
  });

  test("safeRecord swallows sink failures", () => {
    const exploding = {
      record() {
        throw new Error("sink is down");
      },
    };
    // Auditing must never break a request.
    expect(() => safeRecord(exploding, { permission: "p:x", allowed: false, at: 1 })).not.toThrow();
  });

  test("safeRecord swallows async sink rejections", async () => {
    const rejecting = { record: async () => Promise.reject(new Error("later")) };
    expect(() => safeRecord(rejecting, { permission: "p:x", allowed: false, at: 1 })).not.toThrow();
    await Bun.sleep(1);
  });

  test("safeRecord tolerates an undefined sink", () => {
    expect(() => safeRecord(undefined, { permission: "p:x", allowed: true, at: 1 })).not.toThrow();
  });

  test("safeRecord tolerates a malformed sink", () => {
    const notAFunction = { record: "nope" } as unknown as AuthzAuditSink;
    expect(() =>
      safeRecord(notAFunction, { permission: "p:x", allowed: true, at: 1 }),
    ).not.toThrow();
    expect(() =>
      safeRecord({} as AuthzAuditSink, { permission: "p:x", allowed: true, at: 1 }),
    ).not.toThrow();
  });

  test("memoryAuditSink.clear empties the buffer", () => {
    const sink = memoryAuditSink();
    sink.record({ permission: "p:x", allowed: true, at: 1 });
    sink.clear();
    expect(sink.events).toHaveLength(0);
  });

  test("consoleAuditSink cannot be used to forge a second log line", () => {
    const lines: string[] = [];
    const original = console.info;
    console.info = (...args: unknown[]) => void lines.push(args.join(" "));
    try {
      consoleAuditSink().record({
        subjectId: "u1\n[wrnexus:authz] allow admin:everything subject=root",
        permission: "post:read",
        allowed: false,
        reason: "nope\r\ninjected",
        at: 1,
      });
    } finally {
      console.info = original;
    }
    // One event must produce exactly one line, with no embedded newlines.
    expect(lines).toHaveLength(1);
    expect(lines[0]).not.toContain("\n");
    expect(lines[0]).not.toContain("\r");
  });
});
```

The test file's imports must include `consoleAuditSink` and the `AuthzAuditSink` type
alongside `memoryAuditSink` and `safeRecord`.

- [ ] **Step 2: Run test to verify it fails**

Run: `bun test packages/authz/test/audit.test.ts`
Expected: FAIL — cannot resolve `../src/audit.ts`

- [ ] **Step 3: Write the implementation**

Create `packages/authz/src/audit.ts`:

```ts
import type { AuthzScope } from "./types.ts";

export interface AuthzAuditEvent {
  subjectId?: string;
  scope?: AuthzScope;
  permission: string;
  allowed: boolean;
  reason?: string;
  policy?: string;
  /** Epoch milliseconds. */
  at: number;
}

export interface AuthzAuditSink {
  record(event: AuthzAuditEvent): void | Promise<void>;
}

export interface MemoryAuditSink extends AuthzAuditSink {
  events: AuthzAuditEvent[];
  clear(): void;
}

export function memoryAuditSink(): MemoryAuditSink {
  const events: AuthzAuditEvent[] = [];
  return {
    events,
    record: (event) => void events.push(event),
    clear: () => void events.splice(0, events.length),
  };
}

/**
 * Subject ids, tenant ids, and denial reasons trace back to request input, so
 * a newline in one would forge a second audit line indistinguishable from a
 * real entry. Strip CR/LF and other control characters before interpolating.
 */
function logSafe(value: string): string {
  let out = "";
  for (const character of value) {
    const code = character.codePointAt(0)!;
    // C0 + DEL, plus NEL and the Unicode line/paragraph separators, which some
    // log shippers and JSON consumers also treat as line terminators.
    const isLineBreaking =
      code < 0x20 || code === 0x7f || code === 0x85 || code === 0x2028 || code === 0x2029;
    out += isLineBreaking ? " " : character;
  }
  return out;
}

export function consoleAuditSink(): AuthzAuditSink {
  return {
    record(event) {
      const verdict = event.allowed ? "allow" : "deny";
      console.info(
        `[wrnexus:authz] ${verdict} ${logSafe(event.permission)} ` +
          `subject=${logSafe(event.subjectId ?? "anonymous")}` +
          `${event.scope?.tenantId ? ` tenant=${logSafe(event.scope.tenantId)}` : ""}` +
          `${event.reason ? ` reason=${logSafe(event.reason)}` : ""}` +
          `${event.policy ? ` policy=${logSafe(event.policy)}` : ""}`,
      );
    },
  };
}

/** Record without ever letting a sink failure escape into the request path. */
export function safeRecord(sink: AuthzAuditSink | undefined, event: AuthzAuditEvent): void {
  if (!sink) return;
  try {
    const result = sink.record(event);
    if (result instanceof Promise) {
      result.catch((error) => console.warn("[wrnexus:authz] audit sink failed", error));
    }
  } catch (error) {
    console.warn("[wrnexus:authz] audit sink failed", error);
  }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `bun test packages/authz/test/audit.test.ts`
Expected: PASS, 4 tests

- [ ] **Step 5: Commit**

```bash
git add packages/authz/src/audit.ts packages/authz/test/audit.test.ts
git commit -m "feat(authz): add pluggable authorization audit sink"
```

---

## Task 6: Resolution engine

**Files:**

- Create: `packages/authz/src/engine.ts`
- Test: `packages/authz/test/engine.test.ts`

**Interfaces:**

- Consumes: `AuthzCatalog`, `AuthzScope`, `SubjectAssignments` from `./types.ts`; `PermissionStore` from `./store.ts`; `AuthzAuditSink`, `safeRecord` from `./audit.ts`; `AuthorizationDecision` from `./advanced.ts`
- Produces: `createAuthzResolver(options: AuthzResolverOptions): AuthzResolver` with `AuthzResolver { permissionsFor(subjectId, scope?): Promise<Set<string>>; decide(input: DecideInput): Promise<AuthorizationDecision> }`, `expandRoles(catalog, roles): Set<string>`, `permissionMatches(granted: Set<string>, permission: string): boolean`

- [ ] **Step 1: Write the failing test**

Create `packages/authz/test/engine.test.ts`:

```ts
import { describe, expect, test } from "bun:test";
import { defineAuthz } from "../src/registry.ts";
import { mergeCatalogs } from "../src/catalog.ts";
import { memoryPermissionStore } from "../src/store.ts";
import { memoryAuditSink } from "../src/audit.ts";
import { createAuthzResolver, expandRoles, permissionMatches } from "../src/engine.ts";

const catalog = mergeCatalogs([
  {
    source: "test.ts",
    module: defineAuthz({
      permissions: {
        "post:read": { public: true },
        "post:write": {},
        "post:delete": { risk: "high" },
        "post:comment:delete": {},
      },
      roles: {
        editor: ["post:*"],
        moderator: ["post:comment:*"],
        admin: ["role:editor", "post:delete"],
        cyclic: ["role:cyclic", "post:read"],
      },
      policies: {
        ownsPost: async (subject: { id?: string }, resource?: { authorId?: string }) =>
          resource?.authorId === subject?.id
            ? { allowed: true }
            : { allowed: false, reason: "not the author", policy: "ownsPost" },
        explodes: async () => {
          throw new Error("policy blew up");
        },
      },
      bindings: { "post:write": ["ownsPost"] },
    }),
  },
]);

const make = (store = memoryPermissionStore(), audit = memoryAuditSink()) => ({
  store,
  audit,
  resolver: createAuthzResolver({ catalog, store, audit, strict: false }),
});

describe("expandRoles", () => {
  test("expands wildcards and role inheritance", () => {
    expect([...expandRoles(catalog, ["admin"])].sort()).toEqual(["post:*", "post:delete"]);
  });
  test("terminates on cyclic inheritance", () => {
    expect([...expandRoles(catalog, ["cyclic"])]).toEqual(["post:read"]);
  });
});

describe("permissionMatches", () => {
  test("matches exact, root wildcard, and every namespace depth", () => {
    expect(permissionMatches(new Set(["post:read"]), "post:read")).toBe(true);
    expect(permissionMatches(new Set(["*"]), "anything:at:all")).toBe(true);
    expect(permissionMatches(new Set(["post:*"]), "post:comment:delete")).toBe(true);
    expect(permissionMatches(new Set(["post:comment:*"]), "post:comment:delete")).toBe(true);
    expect(permissionMatches(new Set(["post:comment:*"]), "post:write")).toBe(false);
  });
});

describe("createAuthzResolver.decide", () => {
  test("allows a public permission for an anonymous subject", async () => {
    const { resolver } = make();
    const result = await resolver.decide({ subject: null, permission: "post:read" });
    expect(result.allowed).toBe(true);
  });

  test("denies a non-public permission for an anonymous subject", async () => {
    const { resolver } = make();
    const result = await resolver.decide({ subject: null, permission: "post:delete" });
    expect(result.allowed).toBe(false);
  });

  test("allows via a role-derived wildcard", async () => {
    const { store, resolver } = make();
    await store.assignRole("u1", "moderator");
    const result = await resolver.decide({
      subject: { id: "u1" },
      permission: "post:comment:delete",
    });
    expect(result.allowed).toBe(true);
  });

  test("an explicit deny beats a role and beats '*'", async () => {
    const { store, resolver } = make();
    await store.assignRole("u1", "admin");
    await store.grant("u1", "post:delete", "deny");
    const result = await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" });
    expect(result.allowed).toBe(false);
    expect(result.reason).toMatch(/explicit deny/i);
  });

  test("a bound policy can deny a permission the role grants", async () => {
    const { store, resolver } = make();
    await store.assignRole("u1", "editor");
    const denied = await resolver.decide({
      subject: { id: "u1" },
      permission: "post:write",
      resource: { authorId: "someone-else" },
    });
    expect(denied.allowed).toBe(false);
    expect(denied.policy).toBe("ownsPost");

    const allowed = await resolver.decide({
      subject: { id: "u1" },
      permission: "post:write",
      resource: { authorId: "u1" },
    });
    expect(allowed.allowed).toBe(true);
  });

  test("a throwing policy denies rather than escaping", async () => {
    const throwing = mergeCatalogs([
      {
        source: "t.ts",
        module: defineAuthz({
          permissions: { "x:go": {} },
          policies: {
            explodes: async () => {
              throw new Error("boom");
            },
          },
          bindings: { "x:go": ["explodes"] },
        }),
      },
    ]);
    const store = memoryPermissionStore();
    await store.grant("u1", "x:go", "allow");
    const resolver = createAuthzResolver({ catalog: throwing, store, strict: false });
    const result = await resolver.decide({ subject: { id: "u1" }, permission: "x:go" });
    expect(result.allowed).toBe(false);
  });

  test("a store failure denies and does not throw", async () => {
    const broken = {
      ...memoryPermissionStore(),
      assignmentsFor: async () => {
        throw new Error("db down");
      },
    };
    const resolver = createAuthzResolver({ catalog, store: broken, strict: false });
    const result = await resolver.decide({ subject: { id: "u1" }, permission: "post:read" });
    expect(result.allowed).toBe(false);
  });

  test("an unregistered permission denies when strict is off", async () => {
    const { resolver } = make();
    const result = await resolver.decide({ subject: { id: "u1" }, permission: "ghost:perm" });
    expect(result.allowed).toBe(false);
    expect(result.reason).toMatch(/not registered/i);
  });

  test("an unregistered permission throws when strict is on", async () => {
    const resolver = createAuthzResolver({
      catalog,
      store: memoryPermissionStore(),
      strict: true,
    });
    await expect(
      resolver.decide({ subject: { id: "u1" }, permission: "ghost:perm" }),
    ).rejects.toThrow(/ghost:perm/);
  });

  test("denials are audited and allows are not, by default", async () => {
    const { store, audit, resolver } = make();
    // moderator, NOT editor: editor holds "post:*", which legitimately grants
    // post:delete, so that call would be an allow and nothing would be audited.
    await store.assignRole("u1", "moderator");
    await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" });
    await resolver.decide({ subject: { id: "u1" }, permission: "post:read" });
    expect(audit.events).toHaveLength(1);
    expect(audit.events[0]!.allowed).toBe(false);
  });

  test("auditAllows records both verdicts", async () => {
    const store = memoryPermissionStore();
    const audit = memoryAuditSink();
    const resolver = createAuthzResolver({
      catalog,
      store,
      audit,
      strict: false,
      auditAllows: true,
    });
    await resolver.decide({ subject: null, permission: "post:read" });
    expect(audit.events).toHaveLength(1);
    expect(audit.events[0]!.allowed).toBe(true);
  });

  test("tenant scope selects the right assignments", async () => {
    const { store, resolver } = make();
    await store.assignRole("u1", "editor", { tenantId: "t1" });
    const inside = await resolver.decide({
      subject: { id: "u1" },
      permission: "post:write",
      resource: { authorId: "u1" },
      scope: { tenantId: "t1" },
    });
    const outside = await resolver.decide({
      subject: { id: "u1" },
      permission: "post:write",
      resource: { authorId: "u1" },
      scope: { tenantId: "t2" },
    });
    expect(inside.allowed).toBe(true);
    expect(outside.allowed).toBe(false);
  });
});

describe("createAuthzResolver fail-closed regressions", () => {
  const guarded = mergeCatalogs([
    {
      source: "guarded.ts",
      module: defineAuthz({
        permissions: { "feed:view": { public: true }, "x:go": {} },
        policies: {
          never: async () => ({ allowed: false, reason: "always no", policy: "never" }),
          truthy: async () => ({ allowed: "yes" }) as never,
        },
        bindings: { "feed:view": ["never"] },
      }),
    },
  ]);

  test("a public permission still runs its bound policies for anonymous callers", async () => {
    // The least-trusted caller must not receive the weakest evaluation:
    // `public` relaxes the identity requirement, never the policy requirement.
    const resolver = createAuthzResolver({
      catalog: guarded,
      store: memoryPermissionStore(),
      strict: false,
    });
    const anonymous = await resolver.decide({ subject: null, permission: "feed:view" });
    expect(anonymous.allowed).toBe(false);
    expect(anonymous.policy).toBe("never");
  });

  test("a policy returning a truthy non-boolean denies", async () => {
    const catalog = mergeCatalogs([
      {
        source: "t.ts",
        module: defineAuthz({
          permissions: { "x:go": {} },
          policies: { truthy: async () => ({ allowed: "yes" }) as never },
          bindings: { "x:go": ["truthy"] },
        }),
      },
    ]);
    const store = memoryPermissionStore();
    await store.grant("u1", "x:go", "allow");
    const resolver = createAuthzResolver({ catalog, store, strict: false });
    expect((await resolver.decide({ subject: { id: "u1" }, permission: "x:go" })).allowed).toBe(
      false,
    );
  });

  test("a binding naming a policy the catalog lacks denies rather than skipping", async () => {
    // Hand-built catalog: mergeCatalogs would reject this, but the resolver
    // accepts any AuthzCatalog and must not grant what the policy guarded.
    const broken = {
      permissions: new Map([["x:go", {}]]),
      roles: new Map(),
      policies: new Map(),
      attributes: new Map(),
      bindings: new Map([["x:go", ["ghost"]]]),
    } as unknown as Parameters<typeof createAuthzResolver>[0]["catalog"];
    const store = memoryPermissionStore();
    await store.grant("u1", "x:go", "allow");
    const resolver = createAuthzResolver({ catalog: broken, store, strict: false });
    expect((await resolver.decide({ subject: { id: "u1" }, permission: "x:go" })).allowed).toBe(
      false,
    );
  });

  test("a wildcard deny blocks the whole namespace", async () => {
    const { store, resolver } = make();
    await store.assignRole("u1", "admin");
    await store.grant("u1", "post:*", "deny");
    expect(
      (await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" })).allowed,
    ).toBe(false);
  });

  test("permissionsFor omits denied permissions", async () => {
    const { store, resolver } = make();
    await store.assignRole("u1", "editor");
    await store.grant("u1", "post:*", "deny");
    const effective = await resolver.permissionsFor("u1");
    // The obvious composition must agree with decide().
    expect(permissionMatches(effective, "post:write")).toBe(false);
  });

  test("a non-string or empty subject id denies instead of falling back to anonymous", async () => {
    const { resolver } = make();
    for (const id of [0, "", null, 123, {}]) {
      const result = await resolver.decide({
        subject: { id } as never,
        permission: "post:read", // public — must still not be reached this way
      });
      if (id === null) continue; // null is genuinely anonymous
      expect(result.allowed).toBe(false);
    }
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `bun test packages/authz/test/engine.test.ts`
Expected: FAIL — cannot resolve `../src/engine.ts`

- [ ] **Step 3: Write the implementation**

Create `packages/authz/src/engine.ts`:

```ts
import type { AuthorizationDecision } from "./advanced.ts";
import { safeRecord, type AuthzAuditSink } from "./audit.ts";
import type { PermissionStore } from "./store.ts";
import type { AuthzCatalog, AuthzScope } from "./types.ts";

export interface AuthzResolverOptions {
  catalog: AuthzCatalog;
  store: PermissionStore;
  audit?: AuthzAuditSink;
  /**
   * Throw on an unregistered permission instead of denying. Defaults to true
   * outside production, so typos surface during development.
   */
  strict?: boolean;
  /** Record allows as well as denies. Off by default to bound write volume. */
  auditAllows?: boolean;
}

export interface DecideInput {
  subject: { id?: string; [key: string]: unknown } | null | undefined;
  permission: string;
  resource?: unknown;
  scope?: AuthzScope;
}

export interface AuthzResolver {
  /**
   * Effective permissions with denied entries removed — for coarse gating such
   * as hiding a menu section.
   *
   * NOT authoritative. A set of strings cannot express "everything under
   * `post:*` except `post:delete`", so a narrow deny beneath a broad grant is
   * not representable here: the set still contains `post:*` while `decide()`
   * correctly refuses `post:delete`. Gate individual actions with `decide()`
   * (or `can()` / `filterCan()`), never by matching against this set.
   */
  permissionsFor(subjectId: string, scope?: AuthzScope): Promise<Set<string>>;
  decide(input: DecideInput): Promise<AuthorizationDecision>;
}

/** Expand roles into their granted entries, following `role:` and stopping on cycles. */
export function expandRoles(catalog: AuthzCatalog, roles: readonly string[]): Set<string> {
  const out = new Set<string>();
  const seen = new Set<string>();
  const walk = (role: string) => {
    if (seen.has(role)) return;
    seen.add(role);
    for (const entry of catalog.roles.get(role) ?? []) {
      if (entry.startsWith("role:")) walk(entry.slice(5));
      else out.add(entry);
    }
  };
  for (const role of roles) walk(role);
  return out;
}

/**
 * Exact match, root wildcard, or a namespace wildcard at any depth.
 *
 * Do NOT gate access by matching against `permissionsFor()`'s result — that set
 * cannot represent a narrow deny beneath a broad grant, so the composition
 * returns true where `decide()` refuses. Use `decide()` / `can()` instead.
 */
export function permissionMatches(granted: Set<string>, permission: string): boolean {
  if (granted.has("*") || granted.has(permission)) return true;
  for (let at = permission.indexOf(":"); at !== -1; at = permission.indexOf(":", at + 1)) {
    if (granted.has(`${permission.slice(0, at)}:*`)) return true;
  }
  return false;
}

/**
 * True if any entry in the deny list covers `permission`. Denies honour the
 * same depth-aware wildcards as grants, so denying "post:*" blocks
 * post:comment:delete rather than being accepted and silently doing nothing.
 */
export function deniedBy(denies: readonly string[], permission: string): boolean {
  return denies.length ? permissionMatches(new Set(denies), permission) : false;
}

function isProduction(): boolean {
  return (process.env.NODE_ENV ?? "development") === "production";
}

export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolver {
  const { catalog, store, audit } = options;
  const strict = options.strict ?? !isProduction();

  /**
   * Single source of truth for "what does this subject hold?". Returns the raw
   * assignments alongside the effective set, because `decide` reports on the
   * deny that blocked it. Do NOT duplicate this logic in either caller.
   */
  const loadEffective = async (subjectId: string, scope?: AuthzScope) => {
    const assignments = await store.assignmentsFor(subjectId, scope);
    const granted = expandRoles(catalog, assignments.roles);
    for (const grant of assignments.grants) granted.add(grant);
    return { assignments, granted };
  };

  /**
   * Effective permissions, denies already removed. Callers compose this with
   * `permissionMatches` to gate menus and admin UI, so it must not report a
   * permission that `decide` would refuse.
   */
  const permissionsFor = async (subjectId: string, scope?: AuthzScope): Promise<Set<string>> => {
    const { assignments, granted } = await loadEffective(subjectId, scope);
    if (!assignments.denies.length) return granted;
    // Hoist the deny set: rebuilding it per entry makes this O(grants x denies)
    // allocations on a per-request path whose input size an operator controls.
    const denySet = new Set(assignments.denies);
    const effective = new Set<string>();
    for (const entry of granted) {
      // A wildcard grant survives only if nothing denies it outright.
      if (!permissionMatches(denySet, entry)) effective.add(entry);
    }
    return effective;
  };

  const finish = (input: DecideInput, result: AuthorizationDecision): AuthorizationDecision => {
    if (!result.allowed || options.auditAllows) {
      safeRecord(audit, {
        subjectId: typeof input.subject?.id === "string" ? input.subject.id : undefined,
        scope: input.scope,
        permission: input.permission,
        allowed: result.allowed,
        reason: result.reason,
        policy: result.policy,
        at: Date.now(),
      });
    }
    return result;
  };

  /**
   * Run every policy bound to a permission. Returns a denial, or null to allow.
   * Anonymous callers run this too: `public` relaxes the identity requirement,
   * never the policy requirement.
   */
  const runPolicies = async (
    input: DecideInput,
    permission: string,
  ): Promise<AuthorizationDecision | null> => {
    for (const name of catalog.bindings.get(permission) ?? []) {
      const policy = catalog.policies.get(name);
      if (!policy) {
        // A binding naming a policy the catalog lacks must deny, not skip:
        // silently ignoring it would grant whatever the policy guarded.
        console.error(
          `[wrnexus:authz] binding for '${permission}' names unknown policy '${name}'; denying`,
        );
        return { allowed: false, reason: "Policy unavailable", policy: name };
      }
      try {
        const verdict = await (
          policy as unknown as (
            s: unknown,
            r: unknown,
          ) => AuthorizationDecision | Promise<AuthorizationDecision>
        )(input.subject, input.resource);
        // Identity check, not truthiness: {allowed: "yes"} must not grant.
        if (verdict?.allowed !== true) {
          return {
            allowed: false,
            reason: verdict?.reason ?? "Policy denied access",
            policy: verdict?.policy ?? name,
          };
        }
      } catch (error) {
        console.error(`[wrnexus:authz] policy '${name}' threw; denying`, error);
        return { allowed: false, reason: "Policy error", policy: name };
      }
    }
    return null;
  };

  return {
    permissionsFor,

    async decide(input) {
      const { subject, permission, scope } = input;
      const meta = catalog.permissions.get(permission);

      if (!meta) {
        if (strict) {
          throw new Error(
            `WRN-AUTHZ-UNKNOWN: permission '${permission}' is not registered. ` +
              `Declare it with defineAuthz() in app/authz/.`,
          );
        }
        return finish(input, {
          allowed: false,
          reason: `Permission '${permission}' is not registered`,
        });
      }

      // Only a non-empty string identifies a subject. A numeric id of 0 or a
      // non-string id must not fall through to the anonymous path, and must
      // never reach the store as a lookup key.
      const rawId: unknown = subject?.id;
      const subjectId = typeof rawId === "string" && rawId !== "" ? rawId : undefined;
      if (rawId !== undefined && rawId !== null && subjectId === undefined) {
        console.error("[wrnexus:authz] subject.id must be a non-empty string; denying");
        return finish(input, { allowed: false, reason: "Invalid subject" });
      }

      if (!subjectId) {
        if (!meta.public) {
          return finish(input, { allowed: false, reason: "Authentication required" });
        }
        const denied = await runPolicies(input, permission);
        return finish(input, denied ?? { allowed: true, reason: "public permission" });
      }

      let assignments;
      let granted: Set<string>;
      try {
        ({ assignments, granted } = await loadEffective(subjectId, scope));
      } catch (error) {
        console.error("[wrnexus:authz] permission store failed; denying", error);
        return finish(input, { allowed: false, reason: "Authorization store unavailable" });
      }

      // 1. Explicit deny wins over everything, including "*". Wildcards are
      //    honoured here exactly as they are for grants, so denying "post:*"
      //    blocks post:delete rather than silently doing nothing.
      if (deniedBy(assignments.denies, permission)) {
        return finish(input, { allowed: false, reason: "explicit deny" });
      }

      // 2. Must hold the permission at all.
      if (!meta.public && !permissionMatches(granted, permission)) {
        return finish(input, { allowed: false, reason: "Missing permission" });
      }

      // 3. Every bound policy must pass.
      const denied = await runPolicies(input, permission);
      return finish(input, denied ?? { allowed: true });
    },
  };
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `bun test packages/authz/test/engine.test.ts`
Expected: PASS, 15 tests

- [ ] **Step 5: Commit**

```bash
git add packages/authz/src/engine.ts packages/authz/test/engine.test.ts
git commit -m "feat(authz): add resolution engine with deny-wins precedence and fail-closed errors"
```

---

## Task 7: Middleware, can(), and guards

**Files:**

- Create: `packages/authz/src/middleware.ts`
- Test: `packages/authz/test/middleware.test.ts`

**Interfaces:**

- Consumes: `createAuthzResolver`, `AuthzResolverOptions`, `AuthzResolver` from `./engine.ts`; `Context`, `Middleware` types from `@wrnexus/core`
- Produces: `AUTHZ_LOCALS_KEY`, `authzMiddleware(options: AuthzResolverOptions): Middleware`, `decideFor(ctx, permission, resource?): Promise<AuthorizationDecision>`, `can(ctx, permission, resource?): Promise<boolean>`, `guardPermission(permission, getResource?): Middleware`, `filterCan<T>(ctx, permission, items): Promise<T[]>`

- [ ] **Step 1: Write the failing test**

Create `packages/authz/test/middleware.test.ts`:

```ts
import { describe, expect, test } from "bun:test";
import type { Context } from "@wrnexus/core";
import { defineAuthz } from "../src/registry.ts";
import { mergeCatalogs } from "../src/catalog.ts";
import { memoryPermissionStore } from "../src/store.ts";
import { authzMiddleware, can, filterCan, guardPermission } from "../src/middleware.ts";

const catalog = mergeCatalogs([
  {
    source: "t.ts",
    module: defineAuthz({
      permissions: { "post:read": { public: true }, "post:write": {}, "post:delete": {} },
      roles: { editor: ["post:write"] },
      policies: {
        ownsPost: async (s: { id?: string }, r?: { authorId?: string }) =>
          r?.authorId === s?.id ? { allowed: true } : { allowed: false, reason: "not owner" },
      },
      bindings: { "post:delete": ["ownsPost"] },
    }),
  },
]);

/** Minimal Context stand-in; the middleware only touches user, tenant, locals. */
function makeCtx(user: unknown, tenantId?: string): Context {
  return {
    user,
    tenant: tenantId ? { id: tenantId } : undefined,
    locals: {},
    url: new URL("http://localhost/x"),
    req: new Request("http://localhost/x"),
  } as unknown as Context;
}

const withMiddleware = async (ctx: Context, store = memoryPermissionStore()) => {
  await authzMiddleware({ catalog, store, strict: false })(ctx, async () => new Response("ok"));
  return store;
};

describe("authzMiddleware + can", () => {
  test("can() resolves through the middleware-installed resolver", async () => {
    const ctx = makeCtx({ id: "u1" });
    const store = memoryPermissionStore();
    await store.assignRole("u1", "editor");
    await withMiddleware(ctx, store);
    expect(await can(ctx, "post:write")).toBe(true);
    expect(await can(ctx, "post:delete", { authorId: "u1" })).toBe(false);
  });

  test("can() throws a clear setup error without the middleware", async () => {
    const ctx = makeCtx({ id: "u1" });
    await expect(can(ctx, "post:read")).rejects.toThrow(/authzMiddleware/);
  });

  test("results are memoised per request", async () => {
    const inner = memoryPermissionStore();
    let reads = 0;
    const counting = {
      ...inner,
      assignmentsFor: (id: string, scope?: { tenantId?: string }) => {
        reads++;
        return inner.assignmentsFor(id, scope);
      },
    };
    const ctx = makeCtx({ id: "u1" });
    await authzMiddleware({ catalog, store: counting, strict: false })(
      ctx,
      async () => new Response("ok"),
    );
    await can(ctx, "post:write");
    await can(ctx, "post:write");
    expect(reads).toBe(1);
  });

  test("memoisation keys on the resource, not just the permission", async () => {
    const ctx = makeCtx({ id: "u1" });
    const store = memoryPermissionStore();
    await store.grant("u1", "post:delete", "allow");
    await withMiddleware(ctx, store);
    expect(await can(ctx, "post:delete", { authorId: "u1" })).toBe(true);
    expect(await can(ctx, "post:delete", { authorId: "other" })).toBe(false);
  });

  test("the tenant on the context becomes the scope", async () => {
    const ctx = makeCtx({ id: "u1" }, "t1");
    const store = memoryPermissionStore();
    await store.assignRole("u1", "editor", { tenantId: "t1" });
    await withMiddleware(ctx, store);
    expect(await can(ctx, "post:write")).toBe(true);
  });
});

describe("guardPermission", () => {
  test("calls next when allowed", async () => {
    const ctx = makeCtx({ id: "u1" });
    const store = memoryPermissionStore();
    await store.assignRole("u1", "editor");
    await withMiddleware(ctx, store);
    const res = await guardPermission("post:write")(ctx, async () => new Response("passed"));
    expect(await res.text()).toBe("passed");
  });

  test("returns 403 without leaking the reason by default", async () => {
    const ctx = makeCtx({ id: "u1" });
    await withMiddleware(ctx);
    const res = await guardPermission("post:write")(ctx, async () => new Response("passed"));
    expect(res.status).toBe(403);
    const body = (await res.json()) as Record<string, unknown>;
    expect(body).toEqual({ ok: false, error: "Forbidden" });
  });

  test("exposeReason opts into diagnostics", async () => {
    const ctx = makeCtx({ id: "u1" });
    await withMiddleware(ctx);
    const res = await guardPermission("post:write", { exposeReason: true })(
      ctx,
      async () => new Response("passed"),
    );
    const body = (await res.json()) as Record<string, unknown>;
    expect(body.reason).toBe("Missing permission");
  });

  test("getResource feeds the bound policy", async () => {
    const ctx = makeCtx({ id: "u1" });
    const store = memoryPermissionStore();
    await store.grant("u1", "post:delete", "allow");
    await withMiddleware(ctx, store);
    const guard = guardPermission("post:delete", { getResource: () => ({ authorId: "u1" }) });
    const res = await guard(ctx, async () => new Response("passed"));
    expect(await res.text()).toBe("passed");
  });
});

describe("filterCan", () => {
  test("keeps only the items the subject may act on", async () => {
    const ctx = makeCtx({ id: "u1" });
    const store = memoryPermissionStore();
    await store.grant("u1", "post:delete", "allow");
    await withMiddleware(ctx, store);
    const posts = [{ authorId: "u1" }, { authorId: "other" }, { authorId: "u1" }];
    expect(await filterCan(ctx, "post:delete", posts)).toHaveLength(2);
  });

  test("does not leak rows the memo cannot serialise", async () => {
    const ctx = makeCtx({ id: "u1" });
    const store = memoryPermissionStore();
    await store.grant("u1", "post:delete", "allow");
    await withMiddleware(ctx, store);
    // BigInt columns and circular references are ordinary in ORM rows. A memo
    // that serialises resources funnels all of these into one shared key and
    // returns the first verdict for every later row.
    const circular: Record<string, unknown> = { authorId: "other" };
    circular.self = circular;
    const rows = [{ authorId: "u1", views: 10n }, { authorId: "other", views: 11n }, circular];
    expect(await filterCan(ctx, "post:delete", rows)).toEqual([rows[0]]);
  });

  test("returns an empty array for no items", async () => {
    const ctx = makeCtx({ id: "u1" });
    await withMiddleware(ctx);
    expect(await filterCan(ctx, "post:delete", [])).toEqual([]);
  });
});

describe("per-request memo isolation", () => {
  test("distinct resources are never cross-authorized", async () => {
    const ctx = makeCtx({ id: "u1" });
    const store = memoryPermissionStore();
    await store.grant("u1", "post:delete", "allow");
    await withMiddleware(ctx, store);
    // Same id, different owner; object ids; primitives of different type.
    expect(await can(ctx, "post:delete", { id: 7, authorId: "u1" })).toBe(true);
    expect(await can(ctx, "post:delete", { id: "7", authorId: "other" })).toBe(false);
    expect(await can(ctx, "post:delete", { id: { t: "A" }, authorId: "u1" })).toBe(true);
    expect(await can(ctx, "post:delete", { id: { t: "B" }, authorId: "other" })).toBe(false);
  });

  test("a changed row is not authorized against the stale copy", async () => {
    const ctx = makeCtx({ id: "u1" });
    const store = memoryPermissionStore();
    await store.grant("u1", "post:delete", "allow");
    await withMiddleware(ctx, store);
    expect(await can(ctx, "post:delete", { id: "p1", authorId: "u1" })).toBe(true);
    expect(await can(ctx, "post:delete", { id: "p1", authorId: "someone-else" })).toBe(false);
  });

  test("switching tenant mid-request re-evaluates", async () => {
    const ctx = makeCtx({ id: "u1" }, "t1");
    const store = memoryPermissionStore();
    await store.assignRole("u1", "editor", { tenantId: "t1" });
    await withMiddleware(ctx, store);
    expect(await can(ctx, "post:write")).toBe(true);
    (ctx as { tenant?: { id: string } }).tenant = { id: "t2" };
    // Scope is read at decision time, so the t1 grant must not carry over.
    expect(await can(ctx, "post:write")).toBe(false);
  });
});

describe("guardPermission hardening", () => {
  test("throws the setup error rather than calling next", async () => {
    const ctx = makeCtx({ id: "u1" }); // no authzMiddleware
    let reached = false;
    await expect(
      guardPermission("post:write")(ctx, async () => {
        reached = true;
        return new Response("passed");
      }),
    ).rejects.toThrow(/authzMiddleware/);
    expect(reached).toBe(false);
  });

  test("a throwing getResource denies instead of 500ing", async () => {
    const ctx = makeCtx({ id: "u1" });
    await withMiddleware(ctx);
    const guard = guardPermission("post:delete", {
      getResource: () => {
        throw new Error("SELECT * FROM posts WHERE id=$1 failed");
      },
    });
    const res = await guard(ctx, async () => new Response("passed"));
    expect(res.status).toBe(403);
    const body = await res.text();
    expect(body).not.toContain("SELECT");
  });

  test("redirectTo applies to page requests but not API requests", async () => {
    const page = makeCtx({ id: "u1" });
    await withMiddleware(page);
    const redirected = await guardPermission("post:write", { redirectTo: "/login" })(
      page,
      async () => new Response("passed"),
    );
    expect(redirected.status).toBe(303);

    const api = makeCtx({ id: "u1" });
    (api as { url: URL }).url = new URL("http://localhost/api/posts");
    await withMiddleware(api);
    const json = await guardPermission("post:write", { redirectTo: "/login" })(
      api,
      async () => new Response("passed"),
    );
    // An API caller must see the denial, not follow a redirect into a 200.
    expect(json.status).toBe(403);
  });

  test("an off-site redirectTo is refused", async () => {
    const ctx = makeCtx({ id: "u1" });
    await withMiddleware(ctx);
    for (const target of ["https://evil.example.com/harvest", "//evil.example.com"]) {
      const res = await guardPermission("post:write", { redirectTo: target })(
        ctx,
        async () => new Response("passed"),
      );
      expect(res.status).toBe(403);
    }
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `bun test packages/authz/test/middleware.test.ts`
Expected: FAIL — cannot resolve `../src/middleware.ts`

- [ ] **Step 3: Write the implementation**

Create `packages/authz/src/middleware.ts`:

```ts
import type { Context, Middleware } from "@wrnexus/core";
import type { AuthorizationDecision } from "./advanced.ts";
import { createAuthzResolver, type AuthzResolver, type AuthzResolverOptions } from "./engine.ts";
import type { AuthzScope } from "./types.ts";

/**
 * `can` is deliberately not a Context member: @wrnexus/core must not depend on
 * @wrnexus/authz. The per-request resolver lives here instead.
 */
export const AUTHZ_LOCALS_KEY = "_authz";

interface RequestAuthz {
  resolver: AuthzResolver;
  /** Memo for object resources, keyed by identity so two rows never collide. */
  byRef: WeakMap<object, Map<string, Promise<AuthorizationDecision>>>;
  /** Memo for symbol resources, which also carry identity. */
  bySymbol: Map<symbol, Map<string, Promise<AuthorizationDecision>>>;
  /** Memo for primitive and absent resources. */
  byValue: Map<string, Promise<AuthorizationDecision>>;
}

function readAuthz(ctx: Context): RequestAuthz {
  const value = ctx.locals[AUTHZ_LOCALS_KEY] as RequestAuthz | undefined;
  if (!value) {
    throw new Error(
      "WRN-AUTHZ-SETUP: authzMiddleware() is not registered for this request. " +
        "Add it to app/middleware before calling can()/guardPermission().",
    );
  }
  return value;
}

/**
 * Read the tenant from the context at decision time, not at middleware time:
 * a request that switches tenant mid-flight must not keep the old scope.
 */
function currentScope(ctx: Context): AuthzScope | undefined {
  const tenantId = ctx.tenant?.id;
  return typeof tenantId === "string" && tenantId !== "" ? { tenantId } : undefined;
}

/** Install the per-request resolver. Register after sessionAuth and tenantMiddleware. */
export function authzMiddleware(options: AuthzResolverOptions): Middleware {
  const resolver = createAuthzResolver(options);
  return (ctx, next) => {
    ctx.locals[AUTHZ_LOCALS_KEY] = {
      resolver,
      byRef: new WeakMap(),
      bySymbol: new Map(),
      byValue: new Map(),
    } satisfies RequestAuthz;
    return next();
  };
}

export function decideFor(
  ctx: Context,
  permission: string,
  resource?: unknown,
): Promise<AuthorizationDecision> {
  const request = readAuthz(ctx);
  const scope = currentScope(ctx);
  // Scope is part of the key: the same permission decides differently per tenant.
  // Subject and scope are both part of the key. A request that reassigns
  // ctx.user (impersonation, step-up auth, session revocation) or ctx.tenant
  // must not be served the previous principal's verdict from the memo.
  const subjectId = (ctx.user as { id?: unknown } | null | undefined)?.id;
  const key = JSON.stringify([
    scope?.tenantId ?? "",
    permission,
    typeof subjectId,
    String(subjectId),
  ]);

  const run = () =>
    request.resolver.decide({
      subject: ctx.user as { id?: string } | null | undefined,
      permission,
      resource,
      scope,
    });

  // Object resources memo by IDENTITY. Serialising them would let two distinct
  // rows share a key and cross-authorize, and unserialisable ones (circular
  // refs, BigInt fields, throwing getters) would all collapse into one bucket.
  // Symbols carry identity that String() erases, so they memo by identity too.
  // They are held in a plain Map rather than the WeakMap: the memo is discarded
  // with the request, so there is nothing to leak.
  if (typeof resource === "symbol") {
    let perSymbol = request.bySymbol.get(resource);
    if (!perSymbol) request.bySymbol.set(resource, (perSymbol = new Map()));
    const cached = perSymbol.get(key);
    if (cached) return cached;
    const pending = run();
    perSymbol.set(key, pending);
    return pending;
  }

  if (resource !== null && (typeof resource === "object" || typeof resource === "function")) {
    let perResource = request.byRef.get(resource as object);
    if (!perResource) request.byRef.set(resource as object, (perResource = new Map()));
    const cached = perResource.get(key);
    if (cached) return cached;
    const pending = run();
    perResource.set(key, pending);
    return pending;
  }

  // typeof is part of the key so 7 and "7" are not the same resource, and
  // -0 keeps its sign because String(-0) is "0".
  const rendered = Object.is(resource, -0) ? "-0" : String(resource);
  const valueKey = JSON.stringify([key, typeof resource, rendered]);
  const cached = request.byValue.get(valueKey);
  if (cached) return cached;
  const pending = run();
  request.byValue.set(valueKey, pending);
  return pending;
}

export async function can(ctx: Context, permission: string, resource?: unknown): Promise<boolean> {
  return (await decideFor(ctx, permission, resource)).allowed;
}

export interface GuardOptions {
  /** Load the resource a bound policy needs. */
  getResource?: (ctx: Context) => unknown;
  /** Include reason and policy name in the 403 body. Off by default. */
  exposeReason?: boolean;
  /** Redirect page requests here instead of returning 403. Must be a local path. */
  redirectTo?: string;
}

/** Same rule requireAuth uses, replicated because authz may only import TYPES from core. */
function wantsJson(ctx: Context): boolean {
  if (ctx.url.pathname.startsWith("/api/")) return true;
  const accept = ctx.req.headers.get("accept") ?? "";
  return accept.includes("application/json") && !accept.includes("text/html");
}

/**
 * Header values must be Latin-1, so a localized path would otherwise throw
 * inside `new Response` and 500 on a denial path. Encode ONLY the codepoints
 * that cannot be sent: encodeURI would also escape "%", corrupting a target
 * that already carries a percent-encoded return path.
 */
function headerSafePath(value: string): string {
  let out = "";
  for (const character of value) {
    out += character.codePointAt(0)! <= 0x7f ? character : encodeURIComponent(character);
  }
  return out;
}

/** Reject anything that could navigate off-site or inject a header. */
function isLocalPath(value: string): boolean {
  if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return false;
  for (const character of value) {
    const code = character.codePointAt(0)!;
    if (code < 0x20 || code === 0x7f) return false;
  }
  return true;
}

/**
 * Guard a route on a registered permission. Named `guardPermission` because
 * `requirePermission(rbac, permission)` already exists with a different shape.
 */
export function guardPermission(permission: string, options: GuardOptions = {}): Middleware {
  return async (ctx, next) => {
    let resource: unknown;
    if (options.getResource) {
      try {
        resource = await options.getResource(ctx);
      } catch (error) {
        // Loading the resource failed, so the policy cannot be evaluated. Deny
        // rather than 500 — and never leak the loader's message to the client.
        console.error(`[wrnexus:authz] getResource for '${permission}' threw; denying`, error);
        return Response.json({ ok: false, error: "Forbidden" }, { status: 403 });
      }
    }
    const result = await decideFor(ctx, permission, resource);
    if (result.allowed) return next();

    if (options.redirectTo && !wantsJson(ctx)) {
      if (!isLocalPath(options.redirectTo)) {
        // JSON-encode: this branch exists precisely for values containing
        // CR/LF, which would otherwise forge a second log line.
        console.error(
          `[wrnexus:authz] redirectTo must be a local path, got ${JSON.stringify(options.redirectTo)}; denying`,
        );
      } else {
        return new Response(null, {
          status: 303,
          headers: {
            location: headerSafePath(options.redirectTo),
            "cache-control": "private, no-store",
          },
        });
      }
    }
    return Response.json(
      options.exposeReason
        ? { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy }
        : { ok: false, error: "Forbidden" },
      { status: 403, headers: { "cache-control": "private, no-store" } },
    );
  };
}

/** Keep only the items the current subject may act on. */
export async function filterCan<T>(
  ctx: Context,
  permission: string,
  items: readonly T[],
): Promise<T[]> {
  const verdicts = await Promise.all(
    items.map(async (item) => ({ item, allowed: await can(ctx, permission, item) })),
  );
  return verdicts.filter((entry) => entry.allowed).map((entry) => entry.item);
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `bun test packages/authz/test/middleware.test.ts`
Expected: PASS, 10 tests

- [ ] **Step 5: Commit**

```bash
git add packages/authz/src/middleware.ts packages/authz/test/middleware.test.ts
git commit -m "feat(authz): add request middleware, can(), and guardPermission"
```

---

## Task 8: Stop `authorizeDecision` leaking policy internals

**Files:**

- Modify: `packages/authz/src/advanced.ts:72-83`
- Test: `packages/authz/test/authz.test.ts` (append)

**Interfaces:**

- Consumes: `AuthorizationDecision` from `./advanced.ts`
- Produces: `authorizeDecision(evaluate, options?: { exposeReason?: boolean }): Middleware` — behaviour change, body is now `{ ok: false, error: "Forbidden" }` unless opted in

- [ ] **Step 1: Write the failing test**

Append to `packages/authz/test/authz.test.ts`:

```ts
describe("authorizeDecision disclosure", () => {
  const ctx = { user: { id: "u1" } } as unknown as import("@wrnexus/core").Context;
  const denier = async () => ({ allowed: false, reason: "secret internal rule", policy: "isVip" });

  test("does not leak reason or policy by default", async () => {
    const res = await authorizeDecision(denier)(ctx, async () => new Response("ok"));
    expect(res.status).toBe(403);
    expect(await res.json()).toEqual({ ok: false, error: "Forbidden" });
  });

  test("exposeReason opts back in", async () => {
    const res = await authorizeDecision(denier, { exposeReason: true })(
      ctx,
      async () => new Response("ok"),
    );
    const body = (await res.json()) as Record<string, unknown>;
    expect(body.reason).toBe("secret internal rule");
    expect(body.policy).toBe("isVip");
  });

  test("still calls next when allowed", async () => {
    const res = await authorizeDecision(async () => ({ allowed: true }))(
      ctx,
      async () => new Response("passed"),
    );
    expect(await res.text()).toBe("passed");
  });
});
```

Add `authorizeDecision` to the file's existing import from `../src/index.ts` if it is not already imported.

- [ ] **Step 2: Run test to verify it fails**

Run: `bun test packages/authz/test/authz.test.ts`
Expected: FAIL — the default response still contains `reason`

- [ ] **Step 3: Modify `packages/authz/src/advanced.ts`**

Replace the `authorizeDecision` function with:

```ts
export interface AuthorizeDecisionOptions {
  /**
   * Include `reason` and `policy` in the 403 body. Off by default: policy
   * names describe internal authorization structure and should not reach an
   * unauthenticated caller.
   */
  exposeReason?: boolean;
}

export function authorizeDecision(
  evaluate: (ctx: Context) => AuthorizationDecision | Promise<AuthorizationDecision>,
  options: AuthorizeDecisionOptions = {},
): Middleware {
  return async (ctx, next) => {
    const result = await evaluate(ctx);
    if (result.allowed) return next();
    return Response.json(
      options.exposeReason
        ? { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy }
        : { ok: false, error: "Forbidden" },
      { status: 403 },
    );
  };
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `bun test packages/authz/test/authz.test.ts`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add packages/authz/src/advanced.ts packages/authz/test/authz.test.ts
git commit -m "fix(authz): stop authorizeDecision leaking policy names in 403 bodies"
```

---

## Task 9: Export the new surface

**Files:**

- Modify: `packages/authz/src/index.ts` (append to the existing re-export block)
- Modify: `docs/public-api-0.8.json` (regenerated)
- Test: `packages/authz/test/exports.test.ts`

**Interfaces:**

- Consumes: everything from Tasks 1-8
- Produces: the public `@wrnexus/authz` surface

- [ ] **Step 1: Write the failing test**

Create `packages/authz/test/exports.test.ts`:

```ts
import { describe, expect, test } from "bun:test";
import * as authz from "../src/index.ts";

describe("@wrnexus/authz exports", () => {
  test("keeps the pre-existing surface", () => {
    for (const name of [
      "defineRbac",
      "hasRole",
      "any",
      "all",
      "attr",
      "authorize",
      "requireRole",
      "requirePermission",
      "allow",
      "deny",
      "decision",
      "owner",
      "anyDecision",
      "allDecisions",
      "authorizeDecision",
      "filterAuthorized",
    ]) {
      expect(typeof (authz as Record<string, unknown>)[name]).toBe("function");
    }
  });

  test("adds the registry, store, engine, and middleware surface", () => {
    for (const name of [
      "defineAuthz",
      "mergeCatalogs",
      "emptyCatalog",
      "memoryPermissionStore",
      "cachedPermissionStore",
      "memoryAuditSink",
      "consoleAuditSink",
      "createAuthzResolver",
      "expandRoles",
      "permissionMatches",
      "scopeKey",
      "safeRecord",
      "deniedBy",
      "authzMiddleware",
      "can",
      "decideFor",
      "guardPermission",
      "filterCan",
    ]) {
      expect(typeof (authz as Record<string, unknown>)[name]).toBe("function");
    }
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `bun test packages/authz/test/exports.test.ts`
Expected: FAIL — `defineAuthz` is undefined

- [ ] **Step 3: Append to `packages/authz/src/index.ts`**

```ts
export { defineAuthz } from "./registry.ts";
export { mergeCatalogs, emptyCatalog } from "./catalog.ts";
export type { CatalogSource } from "./catalog.ts";
export { memoryPermissionStore, cachedPermissionStore, scopeKey } from "./store.ts";
export type { PermissionStore, CachedPermissionStore, CacheOptions, GrantEffect } from "./store.ts";
export { memoryAuditSink, consoleAuditSink, safeRecord } from "./audit.ts";
export type { AuthzAuditEvent, AuthzAuditSink, MemoryAuditSink } from "./audit.ts";
export { createAuthzResolver, expandRoles, permissionMatches, deniedBy } from "./engine.ts";
export type { AuthzResolver, AuthzResolverOptions, DecideInput } from "./engine.ts";
export {
  authzMiddleware,
  can,
  decideFor,
  guardPermission,
  filterCan,
  AUTHZ_LOCALS_KEY,
} from "./middleware.ts";
export type { GuardOptions } from "./middleware.ts";
export type {
  AuthzScope,
  AuthzCatalog,
  AuthzModule,
  AttributeMeta,
  PermissionMeta,
  SubjectAssignments,
} from "./types.ts";
export type { AuthorizeDecisionOptions } from "./advanced.ts";
```

- [ ] **Step 4: Run tests and regenerate the API baseline**

Run: `bun test packages/authz && bun run generate:public-api && bun run check:public-api`
Expected: tests PASS; baseline regenerates; check reports a match

- [ ] **Step 5: Commit**

```bash
git add packages/authz/src/index.ts packages/authz/test/exports.test.ts docs/public-api-0.8.json
git commit -m "feat(authz): export registry, store, engine, and middleware surface"
```

---

## Task 10: Router discovery of `app/authz`

**Files:**

- Modify: `packages/router/src/index.ts:273-294` (alongside the existing schema scan)
- Test: `packages/router/test/authz-discovery.test.ts`

**Interfaces:**

- Consumes: `scanDir`, `isSafeIslandName`, `ComponentRef` already in `packages/router/src/index.ts`
- Produces: `Router.authz: ComponentRef[]`

- [ ] **Step 1: Write the failing test**

Create `packages/router/test/authz-discovery.test.ts`:

```ts
import { describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { buildRouter } from "../src/index.ts";

function appWithAuthz(files: Record<string, string>): string {
  const root = mkdtempSync(join(tmpdir(), "wrnexus-authz-"));
  const dir = join(root, "app", "authz");
  mkdirSync(dir, { recursive: true });
  mkdirSync(join(root, "app", "pages"), { recursive: true });
  for (const [name, body] of Object.entries(files)) writeFileSync(join(dir, name), body, "utf8");
  return join(root, "app");
}

describe("app/authz discovery", () => {
  test("collects .ts and .js declarations by filename", () => {
    const appDir = appWithAuthz({
      "blog.ts": "export default {};",
      "billing.js": "export default {};",
    });
    const router = buildRouter(appDir);
    expect(router.authz.map((entry) => entry.name).sort()).toEqual(["billing", "blog"]);
  });

  test("ignores non-module files", () => {
    const appDir = appWithAuthz({ "blog.ts": "export default {};", "notes.md": "# hi" });
    expect(buildRouter(appDir).authz.map((entry) => entry.name)).toEqual(["blog"]);
  });

  test("skips unsafe names", () => {
    const appDir = appWithAuthz({
      "ok.ts": "export default {};",
      "bad name!.ts": "export default {};",
    });
    expect(buildRouter(appDir).authz.map((entry) => entry.name)).toEqual(["ok"]);
  });

  test("an app with no authz directory yields an empty list", () => {
    const root = mkdtempSync(join(tmpdir(), "wrnexus-authz-none-"));
    mkdirSync(join(root, "app", "pages"), { recursive: true });
    expect(buildRouter(join(root, "app")).authz).toEqual([]);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `bun test packages/router/test/authz-discovery.test.ts`
Expected: FAIL — `router.authz` is undefined

- [ ] **Step 3: Modify `packages/router/src/index.ts`**

Add to the `Router` interface, next to `schemas`:

```ts
  /** Authorization declarations (`app/authz/<name>.ts`) merged into the catalog. */
  authz: ComponentRef[];
```

Add the scan immediately after the existing `schemas` loop:

```ts
// Authorization declarations: app/authz/<name>.{ts,js}, each default-exporting
// a defineAuthz() module. Merged into the catalog at boot.
const authz: ComponentRef[] = [];
// scanDir's extension allow-list is route-oriented; passing [".js"] here keeps
// .js out of app/pages scanning, where it would leak into route URLs.
for (const f of scanDir(join(appDir, "authz"), [".js"])) {
  if (!/\.(ts|js)$/.test(f.file)) continue;
  // Generated type files (permissions.gen.ts) live here too. Skip them quietly:
  // they export types only, and isSafeIslandName would otherwise reject the dot
  // and warn on every boot.
  if (/[.]gen[.](ts|js)$/.test(f.file)) continue;
  const name = basename(f.file).replace(/\.(ts|js)$/, "");
  if (!isSafeIslandName(name)) {
    console.warn(`[wrnexus] skipping authz declaration with unsafe name: ${name}`);
    continue;
  }
  authz.push({ name, file: f.file });
}
```

Add `authz,` to the returned object, next to `schemas,`.

- [ ] **Step 4: Run test to verify it passes**

Run: `bun test packages/router`
Expected: PASS — the new file plus existing router tests

- [ ] **Step 5: Commit**

```bash
git add packages/router/src/index.ts packages/router/test/authz-discovery.test.ts
git commit -m "feat(router): discover app/authz declarations"
```

---

## Task 11: Database store adapter

**Files:**

- Create: `packages/authz/src/migrations.ts`
- Create: `packages/authz/src/db.ts`
- Modify: `packages/authz/package.json` (add `./db` export)
- Test: `packages/authz/test/store-db.test.ts`

**Interfaces:**

- Consumes: `PermissionStore`, `scopeKey` from `./store.ts`; `Db` type from `@wrnexus/db`; `Dialect` from `@wrnexus/db`
- Produces: `authzMigrationSql(dialect: Dialect): { up: string; down: string }`, `dbPermissionStore(db: Db): PermissionStore`, `ensureAuthzTables(db: Db, dialect?: Dialect): Promise<void>`

- [ ] **Step 1: Write the failing test**

Create `packages/authz/test/store-db.test.ts`:

```ts
import { createDb } from "@wrnexus/db";
import { sqlite } from "@wrnexus/db/sqlite";
import { dbPermissionStore, ensureAuthzTables } from "../src/db.ts";
import { runStoreConformance } from "./store-conformance.ts";

// The db adapter must satisfy exactly the same contract as the memory one.
runStoreConformance("sqlite", async () => {
  const db = createDb(sqlite(":memory:"));
  await ensureAuthzTables(db, "sqlite");
  return dbPermissionStore(db);
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `bun test packages/authz/test/store-db.test.ts`
Expected: FAIL — cannot resolve `../src/db.ts`

- [ ] **Step 3: Write the migration SQL**

Create `packages/authz/src/migrations.ts`:

```ts
import type { Dialect } from "@wrnexus/db";

/**
 * DDL for the two assignment tables. `scope` holds a tenant id, or the empty
 * string for a global assignment, so the unique constraints work on every
 * dialect (NULL is not comparable in a UNIQUE index).
 */
/**
 * DDL for the two assignment tables, as a list of statements rather than one
 * blob: splitting a blob on a separator makes runtime correctness depend on
 * source formatting, and only the sqlite driver accepts multi-statement exec.
 *
 * `scope` holds a tenant id, or the empty string for a global assignment, so
 * the unique constraints work on every dialect (NULL is not comparable in a
 * UNIQUE index). `effect` is CHECK-constrained: an unrecognised value would
 * otherwise be dropped from both the grant and deny buckets on read, silently
 * turning a deny into a no-op.
 */
export function authzMigrationSql(dialect: Dialect): { up: string[]; down: string[] } {
  const id =
    dialect === "postgres"
      ? "SERIAL PRIMARY KEY"
      : dialect === "mysql"
        ? "INT AUTO_INCREMENT PRIMARY KEY"
        : "INTEGER PRIMARY KEY AUTOINCREMENT";
  const timestamp = dialect === "sqlite" ? "TEXT" : "TIMESTAMP";
  // MySQL's default collation is case- and accent-insensitive, which would let
  // tenant "T1" match "t1" and collapse roles "admin"/"Admin" onto one row.
  const exact = dialect === "mysql" ? " COLLATE utf8mb4_bin" : "";
  const key = `VARCHAR(255)${exact} NOT NULL`;

  return {
    up: [
      `CREATE TABLE IF NOT EXISTS _wrn_authz_assignment (
  id ${id},
  subject_id ${key},
  scope ${key} DEFAULT '',
  role ${key},
  created_at ${timestamp} NOT NULL DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT _wrn_authz_assignment_unique UNIQUE (subject_id, scope, role)
)`,
      `CREATE TABLE IF NOT EXISTS _wrn_authz_grant (
  id ${id},
  subject_id ${key},
  scope ${key} DEFAULT '',
  permission ${key},
  effect VARCHAR(16) NOT NULL CHECK (effect IN ('allow', 'deny')),
  created_at ${timestamp} NOT NULL DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT _wrn_authz_grant_unique UNIQUE (subject_id, scope, permission)
)`,
    ],
    down: ["DROP TABLE IF EXISTS _wrn_authz_grant", "DROP TABLE IF EXISTS _wrn_authz_assignment"],
  };
}
```

- [ ] **Step 4: Write the adapter**

Create `packages/authz/src/db.ts`:

```ts
import type { Db, Dialect } from "@wrnexus/db";
import { authzMigrationSql } from "./migrations.ts";
import { scopeKey, type GrantEffect, type PermissionStore } from "./store.ts";
import type { AuthzScope, SubjectAssignments } from "./types.ts";

// Re-exported so `@wrnexus/authz/db` is the single entry point for everything
// database-related, including the DDL the CLI scaffolds.
export { authzMigrationSql } from "./migrations.ts";

/** Create the tables if absent. Production apps should use a real migration. */
/** Create the tables if absent. Production apps should use a real migration. */
export async function ensureAuthzTables(
  db: Db,
  dialect: Dialect = db.driver.dialect,
): Promise<void> {
  for (const statement of authzMigrationSql(dialect).up) await db.exec(statement);
}

/** Positional placeholder for the dialect: postgres numbers them, others use "?". */
function ph(dialect: Dialect, index: number): string {
  return dialect === "postgres" ? `$${index}` : "?";
}

export function dbPermissionStore(db: Db): PermissionStore {
  const dialect = db.driver.dialect;
  const p = (n: number) => ph(dialect, n);
  // Single-statement upserts. A transaction here would be worse than useless:
  // the drivers run BEGIN on one shared connection, so an open transaction
  // swallows any concurrent write from another method and discards it on
  // rollback - a revoke would resolve successfully while the role survived.
  const onConflict = (columns: string, update: string) =>
    dialect === "mysql"
      ? ` ON DUPLICATE KEY UPDATE ${update}`
      : ` ON CONFLICT (${columns}) DO UPDATE SET ${update}`;
  const onConflictIgnore = (columns: string) =>
    dialect === "mysql"
      ? " ON DUPLICATE KEY UPDATE id = id"
      : ` ON CONFLICT (${columns}) DO NOTHING`;

  return {
    async assignmentsFor(subjectId: string, scope?: AuthzScope): Promise<SubjectAssignments> {
      const key = scopeKey(scope);
      // A request inside a tenant sees global rows plus that tenant's rows.
      const roleRows = await db.all<{ role: string }>(
        `SELECT role FROM _wrn_authz_assignment WHERE subject_id = ${p(1)} AND (scope = '' OR scope = ${p(2)})`,
        [subjectId, key],
      );
      const grantRows = await db.all<{ permission: string; effect: string }>(
        `SELECT permission, effect FROM _wrn_authz_grant WHERE subject_id = ${p(1)} AND (scope = '' OR scope = ${p(2)})`,
        [subjectId, key],
      );
      return {
        roles: roleRows.map((row) => row.role),
        grants: grantRows.filter((r) => r.effect === "allow").map((r) => r.permission),
        // Anything that is not literally "allow" counts as a deny, so a
        // corrupted or mis-cased effect fails closed rather than vanishing.
        denies: grantRows.filter((r) => r.effect !== "allow").map((r) => r.permission),
      };
    },

    async assignRole(subjectId, role, scope) {
      await db.exec(
        `INSERT INTO _wrn_authz_assignment (subject_id, scope, role) VALUES (${p(1)}, ${p(2)}, ${p(3)})` +
          onConflictIgnore("subject_id, scope, role"),
        [subjectId, scopeKey(scope), role],
      );
    },

    async revokeRole(subjectId, role, scope) {
      await db.exec(
        `DELETE FROM _wrn_authz_assignment WHERE subject_id = ${p(1)} AND scope = ${p(2)} AND role = ${p(3)}`,
        [subjectId, scopeKey(scope), role],
      );
    },

    async grant(subjectId, permission, effect, scope) {
      await db.exec(
        `INSERT INTO _wrn_authz_grant (subject_id, scope, permission, effect) VALUES (${p(1)}, ${p(2)}, ${p(3)}, ${p(4)})` +
          onConflict(
            "subject_id, scope, permission",
            "effect = " + (dialect === "mysql" ? "VALUES(effect)" : "excluded.effect"),
          ),
        [subjectId, scopeKey(scope), permission, effect],
      );
    },

    async revokeGrant(subjectId, permission, scope) {
      await db.exec(
        `DELETE FROM _wrn_authz_grant WHERE subject_id = ${p(1)} AND scope = ${p(2)} AND permission = ${p(3)}`,
        [subjectId, scopeKey(scope), permission],
      );
    },

    async listSubjects(scope) {
      const key = scopeKey(scope);
      const rows = await db.all<{ subject_id: string }>(
        `SELECT subject_id FROM _wrn_authz_assignment WHERE scope = ${p(1)} ` +
          `UNION SELECT subject_id FROM _wrn_authz_grant WHERE scope = ${p(2)}`,
        [key, key],
      );
      return [...new Set(rows.map((row) => row.subject_id))];
    },
  };
}
```

- [ ] **Step 5: Add the subpath export**

In `packages/authz/package.json`, replace the `exports` block with:

```json
  "exports": {
    ".": "./src/index.ts",
    "./db": "./src/db.ts"
  },
```

Add `"@wrnexus/authz/db": ["./packages/authz/src/db.ts"]` to `paths` in the root `tsconfig.json`, next to the existing `@wrnexus/authz` entry.

- [ ] **Step 6: Run test to verify it passes**

Run: `bun test packages/authz/test/store-db.test.ts`
Expected: PASS — the same 13 conformance tests as the memory adapter

- [ ] **Step 7: Commit**

```bash
git add packages/authz/src/db.ts packages/authz/src/migrations.ts packages/authz/package.json packages/authz/test/store-db.test.ts tsconfig.json
git commit -m "feat(authz): add database-backed PermissionStore"
```

---

## Task 12: Permission type codegen

**Files:**

- Create: `packages/authz/src/codegen.ts`
- Test: `packages/authz/test/codegen.test.ts`

**Interfaces:**

- Consumes: `AuthzCatalog` from `./types.ts`
- Produces: `generatePermissionTypes(catalog: AuthzCatalog): string`

- [ ] **Step 1: Write the failing test**

Create `packages/authz/test/codegen.test.ts`:

```ts
import { describe, expect, test } from "bun:test";
import { defineAuthz } from "../src/registry.ts";
import { mergeCatalogs, emptyCatalog } from "../src/catalog.ts";
import { generatePermissionTypes } from "../src/codegen.ts";

describe("generatePermissionTypes", () => {
  test("emits sorted Permission and Role unions", () => {
    const catalog = mergeCatalogs([
      {
        source: "t.ts",
        module: defineAuthz({
          permissions: { "post:write": {}, "post:read": {} },
          roles: { editor: ["post:*"], admin: ["*"] },
        }),
      },
    ]);
    const out = generatePermissionTypes(catalog);
    expect(out).toContain('export type Permission = "post:read" | "post:write";');
    expect(out).toContain('export type Role = "admin" | "editor";');
    expect(out).toContain("DO NOT EDIT");
  });

  test("emits never for an empty catalog so the file still typechecks", () => {
    const out = generatePermissionTypes(emptyCatalog());
    expect(out).toContain("export type Permission = never;");
    expect(out).toContain("export type Role = never;");
  });

  test("escapes quotes in identifiers", () => {
    const catalog = mergeCatalogs([{ source: "t.ts", module: { roles: { 'we"ird': [] } } }]);
    expect(generatePermissionTypes(catalog)).toContain('"we\\"ird"');
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `bun test packages/authz/test/codegen.test.ts`
Expected: FAIL — cannot resolve `../src/codegen.ts`

- [ ] **Step 3: Write the implementation**

Create `packages/authz/src/codegen.ts`:

```ts
import type { AuthzCatalog } from "./types.ts";

function union(values: string[]): string {
  if (!values.length) return "never";
  // JSON.stringify, not hand-rolled escaping: role names reach this via the
  // raw mergeCatalogs path without the registry's id validation, so a value
  // may contain a newline, which manual quote/backslash escaping would emit
  // as an unterminated string literal.
  return values
    .slice()
    .sort()
    .map((value) => JSON.stringify(value))
    .join(" | ");
}

/**
 * Emit compile-time unions for the registered permissions and roles, so a
 * typo in can(ctx, "post:wrtie") is a type error rather than a silent false.
 */
export function generatePermissionTypes(catalog: AuthzCatalog): string {
  return `// Generated by \`wrnexus authz generate\`. DO NOT EDIT.

export type Permission = ${union([...catalog.permissions.keys()])};

export type Role = ${union([...catalog.roles.keys()])};
`;
}
```

- [ ] **Step 4: Export it**

Append to `packages/authz/src/index.ts` (Task 13 imports this from the package entry):

```ts
export { generatePermissionTypes } from "./codegen.ts";
```

- [ ] **Step 5: Run test to verify it passes**

Run: `bun test packages/authz/test/codegen.test.ts && bun run generate:public-api`
Expected: PASS, 3 tests; baseline updated

- [ ] **Step 6: Commit**

```bash
git add packages/authz/src/codegen.ts packages/authz/src/index.ts packages/authz/test/codegen.test.ts docs/public-api-0.8.json
git commit -m "feat(authz): generate Permission and Role union types"
```

---

## Task 13: `wrnexus authz` CLI

**Files:**

- Create: `packages/cli/src/authz.ts`
- Modify: `packages/cli/src/index.ts` (add `case "authz"` next to `case "db"`)
- Test: `packages/cli/test/authz-command.test.ts`

**Interfaces:**

- Consumes: `buildRouter` from `@wrnexus/router`; `mergeCatalogs`, `generatePermissionTypes`, `authzMigrationSql` from `@wrnexus/authz`
- Produces: `loadAuthzCatalog(appDir: string): Promise<AuthzCatalog>`, `runAuthzCommand(root: string, sub: string | undefined, args: string[]): Promise<void>`

- [ ] **Step 1: Write the failing test**

Create `packages/cli/test/authz-command.test.ts`:

```ts
import { describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadAuthzCatalog, runAuthzCommand } from "../src/authz.ts";

function scaffold(): string {
  const root = mkdtempSync(join(tmpdir(), "wrnexus-authz-cli-"));
  mkdirSync(join(root, "app", "authz"), { recursive: true });
  mkdirSync(join(root, "app", "pages"), { recursive: true });
  writeFileSync(
    join(root, "app", "authz", "blog.ts"),
    `import { defineAuthz } from "@wrnexus/authz";
export default defineAuthz({
  permissions: { "post:read": { title: "View posts" }, "post:write": {} },
  roles: { editor: ["post:*"] },
});
`,
    "utf8",
  );
  return root;
}

describe("wrnexus authz", () => {
  test("loadAuthzCatalog merges every declaration", async () => {
    const catalog = await loadAuthzCatalog(join(scaffold(), "app"));
    expect([...catalog.permissions.keys()].sort()).toEqual(["post:read", "post:write"]);
    expect([...catalog.roles.keys()]).toEqual(["editor"]);
  });

  test("generate writes the permission types file", async () => {
    const root = scaffold();
    await runAuthzCommand(root, "generate", []);
    const generated = readFileSync(join(root, "app", "authz", "permissions.gen.ts"), "utf8");
    expect(generated).toContain('export type Permission = "post:read" | "post:write";');
  });

  test("init writes a migration containing both tables", async () => {
    const root = scaffold();
    mkdirSync(join(root, "app", "db", "migrations"), { recursive: true });
    await runAuthzCommand(root, "init", []);
    const dir = join(root, "app", "db", "migrations");
    const file = require("node:fs")
      .readdirSync(dir)
      .find((name: string) => name.includes("authz"));
    expect(file).toBeDefined();
    const sql = readFileSync(join(dir, file!), "utf8");
    expect(sql).toContain("_wrn_authz_assignment");
    expect(sql).toContain("_wrn_authz_grant");
    expect(sql).toContain("-- +down");
  });

  test("list prints every permission and role", async () => {
    const root = scaffold();
    const lines: string[] = [];
    const original = console.log;
    console.log = (...args: unknown[]) => void lines.push(args.join(" "));
    try {
      await runAuthzCommand(root, "list", []);
    } finally {
      console.log = original;
    }
    const output = lines.join("\n");
    expect(output).toContain("post:read");
    expect(output).toContain("editor");
  });

  test("an unknown subcommand throws with usage", async () => {
    await expect(runAuthzCommand(scaffold(), "bogus", [])).rejects.toThrow(/usage/i);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `bun test packages/cli/test/authz-command.test.ts`
Expected: FAIL — cannot resolve `../src/authz.ts`

- [ ] **Step 3: Write the implementation**

Create `packages/cli/src/authz.ts`:

```ts
/**
 * `wrnexus authz <cmd>` — authorization catalog tooling.
 *
 *   wrnexus authz list       print every registered permission, role, and policy
 *   wrnexus authz generate   write app/authz/permissions.gen.ts type unions
 *   wrnexus authz init       scaffold the assignment-table migration
 */

import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { buildRouter } from "@wrnexus/router";
import {
  generatePermissionTypes,
  mergeCatalogs,
  type AuthzCatalog,
  type AuthzModule,
  type CatalogSource,
} from "@wrnexus/authz";
import { authzMigrationSql } from "@wrnexus/authz/db";

const USAGE = "usage: wrnexus authz <list|generate|init>";

/** Import every app/authz declaration and merge it into one catalog. */
export async function loadAuthzCatalog(appDir: string): Promise<AuthzCatalog> {
  const router = buildRouter(appDir);
  const sources: CatalogSource[] = [];
  for (const entry of router.authz) {
    const imported = (await import(pathToFileURL(entry.file).href)) as {
      default?: AuthzModule;
    };
    if (!imported.default) {
      console.warn(`[wrnexus] ${entry.file} has no default export; skipping`);
      continue;
    }
    sources.push({ source: entry.file, module: imported.default });
  }
  return mergeCatalogs(sources);
}

function nextMigrationNumber(dir: string): string {
  if (!existsSync(dir)) return "0001";
  const numbers = readdirSync(dir)
    .map((name) => Number.parseInt(name.slice(0, 4), 10))
    .filter((value) => Number.isInteger(value));
  return String((numbers.length ? Math.max(...numbers) : 0) + 1).padStart(4, "0");
}

export async function runAuthzCommand(
  root: string,
  sub: string | undefined,
  args: string[],
): Promise<void> {
  const appDir = join(resolve(root), "app");

  switch (sub) {
    case "list": {
      const catalog = await loadAuthzCatalog(appDir);
      console.log(`Permissions (${catalog.permissions.size}):`);
      for (const [id, meta] of [...catalog.permissions].sort()) {
        const tags = [meta.risk && `risk=${meta.risk}`, meta.public && "public"]
          .filter(Boolean)
          .join(" ");
        console.log(`  ${id}${meta.title ? ` — ${meta.title}` : ""}${tags ? `  [${tags}]` : ""}`);
      }
      console.log(`\nRoles (${catalog.roles.size}):`);
      for (const [name, grants] of [...catalog.roles].sort()) {
        console.log(`  ${name} → ${grants.join(", ") || "(nothing)"}`);
      }
      console.log(`\nPolicies (${catalog.policies.size}):`);
      for (const name of [...catalog.policies.keys()].sort()) {
        const bound = [...catalog.bindings]
          .filter(([, names]) => names.includes(name))
          .map(([permission]) => permission);
        console.log(`  ${name}${bound.length ? ` → ${bound.join(", ")}` : "  (unbound)"}`);
      }
      return;
    }

    case "generate": {
      const catalog = await loadAuthzCatalog(appDir);
      const target = join(appDir, "authz", "permissions.gen.ts");
      mkdirSync(join(appDir, "authz"), { recursive: true });
      writeFileSync(target, generatePermissionTypes(catalog), "utf8");
      console.log(
        `Wrote ${target} (${catalog.permissions.size} permissions, ${catalog.roles.size} roles)`,
      );
      return;
    }

    case "init": {
      const dialect = (args.find((arg) => arg.startsWith("--dialect="))?.split("=")[1] ??
        "sqlite") as "sqlite" | "postgres" | "mysql";
      const dir = join(appDir, "db", "migrations");
      mkdirSync(dir, { recursive: true });
      const { up, down } = authzMigrationSql(dialect);
      const file = join(dir, `${nextMigrationNumber(dir)}_authz_tables.sql`);
      // up/down are statement LISTS; interpolating the arrays directly would
      // comma-join them into one unparseable statement.
      const block = (statements: string[]) => statements.map((s) => `${s};`).join("\n\n");
      writeFileSync(file, `-- +up\n${block(up)}\n\n-- +down\n${block(down)}\n`, "utf8");
      console.log(`Wrote ${file}`);
      console.log("Run `wrnexus db migrate` to apply it.");
      return;
    }

    default:
      throw new Error(USAGE);
  }
}
```

- [ ] **Step 4: Wire it into the CLI**

In `packages/cli/src/index.ts`, add immediately after the `case "db"` block:

```ts
    case "authz": {
      bootstrapProfile(".", "development", rest);
      const { runAuthzCommand } = await import("./authz.ts");
      const [sub, ...authzArgs] = rest.filter((a) => !a.startsWith("--profile="));
      await runAuthzCommand(".", sub, authzArgs);
      break;
    }
```

Also add `authz` to the help text listing available commands.

- [ ] **Step 5: Run test to verify it passes**

Run: `bun test packages/cli/test/authz-command.test.ts`
Expected: PASS, 5 tests

- [ ] **Step 6: Commit**

```bash
git add packages/cli/src/authz.ts packages/cli/src/index.ts packages/cli/test/authz-command.test.ts
git commit -m "feat(cli): add wrnexus authz list/generate/init"
```

---

## Task 14: Wire the catalog into dev and prod boot

**Files:**

- Modify: `packages/dev-server/src/index.ts` (load catalog in `startServer`)
- Modify: `packages/cli/src/build.ts` (bake catalog into the prod manifest)
- Test: `packages/dev-server/test/authz-boot.test.ts`

**Interfaces:**

- Consumes: `loadAuthzCatalog` pattern from Task 13; `authzMiddleware` from `@wrnexus/authz`
- Produces: `RuntimeDeps.authz?: AuthzCatalog` available to the request pipeline

- [ ] **Step 1: Write the failing test**

Create `packages/dev-server/test/authz-boot.test.ts`:

```ts
import { describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadAppAuthzCatalog } from "../src/authz-boot.ts";

function scaffold(body: string): string {
  const root = mkdtempSync(join(tmpdir(), "wrnexus-authz-boot-"));
  mkdirSync(join(root, "app", "authz"), { recursive: true });
  mkdirSync(join(root, "app", "pages"), { recursive: true });
  writeFileSync(join(root, "app", "authz", "main.ts"), body, "utf8");
  return join(root, "app");
}

describe("loadAppAuthzCatalog", () => {
  test("loads declarations from app/authz", async () => {
    const appDir = scaffold(
      `import { defineAuthz } from "@wrnexus/authz";
export default defineAuthz({ permissions: { "post:read": {} } });`,
    );
    const catalog = await loadAppAuthzCatalog(appDir);
    expect(catalog.permissions.has("post:read")).toBe(true);
  });

  test("an app with no declarations gets an empty catalog rather than an error", async () => {
    const root = mkdtempSync(join(tmpdir(), "wrnexus-authz-empty-"));
    mkdirSync(join(root, "app", "pages"), { recursive: true });
    const catalog = await loadAppAuthzCatalog(join(root, "app"));
    expect(catalog.permissions.size).toBe(0);
  });

  test("a conflicting declaration fails the boot loudly", async () => {
    const appDir = scaffold(
      `import { defineAuthz } from "@wrnexus/authz";
export default defineAuthz({ permissions: { "post:read": { risk: "low" } } });`,
    );
    writeFileSync(
      join(appDir, "authz", "other.ts"),
      `import { defineAuthz } from "@wrnexus/authz";
export default defineAuthz({ permissions: { "post:read": { risk: "high" } } });`,
      "utf8",
    );
    await expect(loadAppAuthzCatalog(appDir)).rejects.toThrow(/WRN-AUTHZ-CONFLICT/);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `bun test packages/dev-server/test/authz-boot.test.ts`
Expected: FAIL — cannot resolve `../src/authz-boot.ts`

- [ ] **Step 3: Write the loader**

Create `packages/dev-server/src/authz-boot.ts`:

```ts
import { pathToFileURL } from "node:url";
import { buildRouter } from "@wrnexus/router";
import {
  emptyCatalog,
  mergeCatalogs,
  type AuthzCatalog,
  type AuthzModule,
  type CatalogSource,
} from "@wrnexus/authz";

/**
 * Load and merge every `app/authz/*.ts` declaration. Conflicts throw so a
 * misconfigured catalog fails the boot rather than silently changing who can
 * do what.
 */
export async function loadAppAuthzCatalog(appDir: string): Promise<AuthzCatalog> {
  const router = buildRouter(appDir);
  if (!router.authz.length) return emptyCatalog();
  const sources: CatalogSource[] = [];
  for (const entry of router.authz) {
    // buildRouter already skips *.gen.ts, so only real declarations arrive here.
    const imported = (await import(pathToFileURL(entry.file).href)) as { default?: AuthzModule };
    if (!imported.default) continue;
    sources.push({ source: entry.file, module: imported.default });
  }
  return mergeCatalogs(sources);
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `bun test packages/dev-server/test/authz-boot.test.ts`
Expected: PASS, 3 tests

- [ ] **Step 5: Commit**

```bash
git add packages/dev-server/src/authz-boot.ts packages/dev-server/test/authz-boot.test.ts
git commit -m "feat(dev-server): load the authz catalog at boot"
```

---

## Task 15: Example app wiring and documentation

**Files:**

- Create: `examples/auth-showcase/app/authz/showcase.ts`
- Modify: `packages/authz/README.md`
- Test: `packages/authz/test/integration.test.ts`

**Interfaces:**

- Consumes: the full surface from Tasks 1-14
- Produces: a worked end-to-end example proving the pieces compose

- [ ] **Step 1: Write the failing integration test**

Create `packages/authz/test/integration.test.ts`:

```ts
import { describe, expect, test } from "bun:test";
import type { Context } from "@wrnexus/core";
import { createDb } from "@wrnexus/db";
import { sqlite } from "@wrnexus/db/sqlite";
import { dbPermissionStore, ensureAuthzTables } from "../src/db.ts";
import {
  authzMiddleware,
  can,
  cachedPermissionStore,
  defineAuthz,
  guardPermission,
  memoryAuditSink,
  mergeCatalogs,
} from "../src/index.ts";

const catalog = mergeCatalogs([
  {
    source: "showcase.ts",
    module: defineAuthz({
      permissions: {
        "post:read": { public: true },
        "post:write": {},
        "post:delete": { risk: "high" },
      },
      roles: { editor: ["post:write"], admin: ["role:editor", "post:delete"] },
      policies: {
        ownsPost: async (s: { id?: string }, r?: { authorId?: string }) =>
          r?.authorId === s?.id ? { allowed: true } : { allowed: false, reason: "not owner" },
      },
      bindings: { "post:delete": ["ownsPost"] },
    }),
  },
]);

function makeCtx(user: unknown, tenantId?: string): Context {
  return {
    user,
    tenant: tenantId ? { id: tenantId } : undefined,
    locals: {},
    url: new URL("http://localhost/"),
    req: new Request("http://localhost/"),
  } as unknown as Context;
}

describe("end-to-end authorization", () => {
  test("db store, cache, catalog, middleware, and audit compose", async () => {
    const db = createDb(sqlite(":memory:"));
    await ensureAuthzTables(db, "sqlite");
    const store = cachedPermissionStore(dbPermissionStore(db), { ttlMs: 1_000 });
    const audit = memoryAuditSink();
    await store.assignRole("alice", "admin", { tenantId: "acme" });

    const alice = makeCtx({ id: "alice" }, "acme");
    await authzMiddleware({ catalog, store, audit, strict: true })(
      alice,
      async () => new Response("ok"),
    );

    expect(await can(alice, "post:write")).toBe(true);
    expect(await can(alice, "post:delete", { id: 1, authorId: "alice" })).toBe(true);
    expect(await can(alice, "post:delete", { id: 2, authorId: "bob" })).toBe(false);

    // Wrong tenant: the admin role was scoped to acme.
    const elsewhere = makeCtx({ id: "alice" }, "other");
    await authzMiddleware({ catalog, store, strict: true })(
      elsewhere,
      async () => new Response("ok"),
    );
    expect(await can(elsewhere, "post:write")).toBe(false);

    // Anonymous can still read, because post:read is public.
    const guest = makeCtx(null);
    await authzMiddleware({ catalog, store, strict: true })(guest, async () => new Response("ok"));
    expect(await can(guest, "post:read")).toBe(true);
    expect(await can(guest, "post:write")).toBe(false);

    // Only denials were audited.
    expect(audit.events.every((event) => !event.allowed)).toBe(true);
    expect(audit.events.length).toBeGreaterThan(0);
  });

  test("revoking a role takes effect immediately through the cache", async () => {
    const db = createDb(sqlite(":memory:"));
    await ensureAuthzTables(db, "sqlite");
    const store = cachedPermissionStore(dbPermissionStore(db), { ttlMs: 60_000 });
    await store.assignRole("bob", "editor");

    const before = makeCtx({ id: "bob" });
    await authzMiddleware({ catalog, store, strict: true })(before, async () => new Response("ok"));
    expect(await can(before, "post:write")).toBe(true);

    await store.revokeRole("bob", "editor");

    const after = makeCtx({ id: "bob" });
    await authzMiddleware({ catalog, store, strict: true })(after, async () => new Response("ok"));
    expect(await can(after, "post:write")).toBe(false);
  });

  test("guardPermission returns an opaque 403", async () => {
    const db = createDb(sqlite(":memory:"));
    await ensureAuthzTables(db, "sqlite");
    const ctx = makeCtx({ id: "carol" });
    await authzMiddleware({ catalog, store: dbPermissionStore(db), strict: true })(
      ctx,
      async () => new Response("ok"),
    );
    const res = await guardPermission("post:write")(ctx, async () => new Response("passed"));
    expect(res.status).toBe(403);
    expect(await res.json()).toEqual({ ok: false, error: "Forbidden" });
  });
});
```

- [ ] **Step 2: Run test to verify it fails or passes**

Run: `bun test packages/authz/test/integration.test.ts`
Expected: PASS if Tasks 1-14 are correct. Any failure here is a real integration
defect — fix the underlying module, not the test.

- [ ] **Step 3: Add the example declaration**

Create `examples/auth-showcase/app/authz/showcase.ts`:

```ts
import { defineAuthz } from "@wrnexus/authz";

export default defineAuthz({
  permissions: {
    "post:read": { title: "View posts", public: true },
    "post:write": { title: "Create and edit posts" },
    "post:delete": { title: "Delete posts", risk: "high" },
    "admin:access": { title: "Reach the admin area", risk: "high" },
  },
  roles: {
    viewer: ["post:read"],
    editor: ["role:viewer", "post:write"],
    admin: ["role:editor", "post:delete", "admin:access"],
  },
  policies: {
    ownsPost: async (
      subject: { id?: string },
      resource?: { authorId?: string },
    ): Promise<{ allowed: boolean; reason?: string }> =>
      resource?.authorId === subject?.id
        ? { allowed: true }
        : { allowed: false, reason: "You are not the author" },
  },
  bindings: { "post:delete": ["ownsPost"] },
});
```

- [ ] **Step 4: Document the surface**

Append to `packages/authz/README.md`:

````markdown
## Declaring permissions

Put declarations in `app/authz/<name>.ts`. They are discovered automatically.

```ts
import { defineAuthz, owner } from "@wrnexus/authz";

export default defineAuthz({
  permissions: {
    "post:read": { title: "View posts", public: true },
    "post:delete": { title: "Delete posts", risk: "high" },
  },
  roles: { editor: ["post:*"], admin: ["role:editor"] },
  policies: { ownsPost: owner("id", "authorId") },
  bindings: { "post:delete": ["ownsPost"] },
});
```

## Checking permissions

Register the middleware once, then use `can()` and `guardPermission()`:

```ts
import { authzMiddleware, can, guardPermission } from "@wrnexus/authz";
import { dbPermissionStore } from "@wrnexus/authz/db";
import { getDb } from "@wrnexus/db";

export default [authzMiddleware({ catalog, store: dbPermissionStore(getDb()) })];

// in a route
export const middleware = [guardPermission("post:write")];
if (await can(ctx, "post:delete", post)) {
  /* ... */
}
```

`can()` is a free function, not `ctx.can` — `@wrnexus/core` must not depend on
`@wrnexus/authz`.

## Precedence

1. An explicit deny wins over everything, including `*`.
2. A bound policy can veto a permission a role grants.
3. Otherwise the permission must be held via a role or an explicit grant.
4. Default deny.

Every failure — unknown permission, store outage, policy exception — denies.

## CLI

```bash
wrnexus authz list      # every registered permission, role, and policy
wrnexus authz generate  # app/authz/permissions.gen.ts type unions
wrnexus authz init      # scaffold the assignment-table migration
```
````

- [ ] **Step 5: Run the full gate**

Run: `bun run check:production`
Expected: PASS. If `check:public-api` complains, run `bun run generate:public-api` and
re-run.

- [ ] **Step 6: Commit**

```bash
git add packages/authz/test/integration.test.ts packages/authz/README.md examples/auth-showcase/app/authz/showcase.ts docs/public-api-0.8.json
git commit -m "test(authz): end-to-end integration coverage, example, and docs"
```

---

## Deferred phases

These are **not** in scope for this plan. Each needs its own design pass.

### Phase 4 — `.wrn` view integration

Exposing `can()` inside compiler-generated `{#if}` expressions touches
`packages/compiler/src/codegen.ts`. Because `{#if}` compiles to a nested ternary inside a
template literal and `can()` is async, the resolution must happen **before** the view renders
— most likely by collecting referenced permissions at compile time and pre-resolving them
into the SSR scope, the way `collectControlExprs` already pre-resolves `ssr { api ... }`
bindings. Do not begin this without confirming that shape against the codegen.

### Phase 5 — Admin UI

`.wrn` components for listing subjects and assigning roles, shipped in `@wrnexus/ui` behind
the existing `wrnexus eject` mechanism. Depends on `listSubjects` and the CLI landing first.

### Inter-app communication seam

`exportSubjectContext(ctx)` / `importSubjectContext(token)` are specified in the design doc
but intentionally unbuilt. They belong to the inter-app communication system, which has not
been designed yet.
