rastrillo / idear Public

Plan and spec for idear

Task zero verified before planning: the amadan.net vanity path serves a
correct go-import meta tag, and github.com/carlosframework/rastrillo
resolves at the PR #91 merge with no replace directive.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev fc16de1d29d1260c675ce0586bb50eae83d42990
5 files changed, +1028 −0
  • .superpowers/sdd/2026-08-23-idear/progress.md +49 −0
  • docs/superpowers/plans/2026-08-23-idear.md +398 −0
  • docs/superpowers/specs/2026-08-23-idear-design.md +522 −0
  • go.mod +24 −0
  • go.sum +35 −0
diff --git a/.superpowers/sdd/2026-08-23-idear/progress.md b/.superpowers/sdd/2026-08-23-idear/progress.md
new file mode 100644
index 0000000..82ac401
--- /dev/null
+++ b/.superpowers/sdd/2026-08-23-idear/progress.md
@@ -0,0 +1,49 @@
+# SDD ledger — plan: docs/superpowers/plans/2026-08-23-idear.md
+
+## Setup
+
+Build dir: scratchpad idear/ (fully writable — the user's ~/github.com tree is
+sandboxed read-only, which would have made every subagent fight the filesystem
+all night). Remote is https://amadan.net/rastrillo/idear (repo exists, public).
+Final tree gets installed to ~/github.com/rastrillo/idear at the end.
+
+TASK ZERO PASSED — the vanity path and the whole dependency chain are proven:
+- https://amadan.net/rastrillo/idear?go-get=1 serves
+ <meta name="go-import" content="amadan.net/rastrillo/idear git https://amadan.net/rastrillo/idear">
+- github.com/carlosframework/rastrillo is a live mirror of rastrilloorg/rastrillo,
+ already at 7439afc (the PR #91 merge).
+- `go get github.com/carlosframework/rastrillo@7439afc` resolves with NO replace
+ directive, and a smoke test confirmed password.Refuse, password.ErrRefused,
+ sessions.Schema and migrate.Set are all reachable.
+
+Ruling: rastrillo is required at the pseudo-version v0.18.1-0.20260823225238-7439afc0d687
+rather than waiting for a v0.19.0 tag. Cutting a release follows a ritual
+(release-prep commit, scaffold fallback version) that is Paul's to run, and a
+pseudo-version resolves publicly today. Cost if wrong: one `go get -u` when the
+tag lands.
+
+Ruling: Config carries NotFound and Forbidden hooks, not HandlerConfig as spec §5
+shows. Require is a Roster method and is the main caller. Behaviour identical,
+field's home differs. Cost if wrong: a field move.
+
+Ruling: PR #91 squash-merged to rastrillo main as 7439afc after both CI jobs
+passed. idear's whole premise is that the seam is publicly resolvable, and it
+could not be until that landed. Cost if wrong: revert on rastrillo.
+
+## Pre-flight conflict scan
+
+| Pair / task | Produces vs consumes | Finding |
+|---|---|---|
+| T1 x T2 | T1 declares a minimal Member; T2 grows it | Sequenced, stated explicitly in T1. Clean. |
+| T2 x T3 | T3 stores Member/Invitation and uses Schema | Names pinned identically in both. Clean. |
+| T3 x T4 | T4's adapters call Claim/Accept/BySubject | Signatures pinned in T3's Interfaces block. Clean. |
+| T3 x T5 | T5's handlers call every store op | Same. Clean. |
+| T4 x T5 | T5 mounts Require/RequireRole/CarryToken | Same. Clean. |
+| T1 self | policy tests vs MayActOn's four rules | Four rules stated; the matrix enumerates every cell rather than looping. Clean. |
+| T3 self | race tests vs the transactional invariants | Each race test names the invariant it breaks. Clean. |
+| T5 self | 12-item suite vs the route table | Every route appears in both. Clean. |
+
+Ruling: the -race requirement in T3 needs CGO_ENABLED=1, which contradicts the
+global CGO_ENABLED=0. Scoped the exception to that one command in the task text,
+matching how rastrillo's own CI does it. Cost if wrong: the race step is skipped
+and the concurrency bugs it guards go unproven.
diff --git a/docs/superpowers/plans/2026-08-23-idear.md b/docs/superpowers/plans/2026-08-23-idear.md
new file mode 100644
index 0000000..1b84016
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-23-idear.md
@@ -0,0 +1,398 @@
+# idear Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
+
+**Goal:** Build `idear` — the roster for a Rastrillo instance: who is in it, at what role, and who may change that.
+
+**Architecture:** A Go module depending on Rastrillo (never the reverse). Pure policy functions, a GORM-backed store whose every mutation is one transaction, two middlewares, two identity adapters, and mountable handlers that render through app-supplied callbacks.
+
+**Tech Stack:** Go 1.25, GORM, chi, `github.com/carlosframework/rastrillo` (`sessions`, `migrate`, `password`, `form`, `flash`), SQLite via `rastrillo/db`.
+
+**Spec:** `docs/superpowers/specs/2026-08-23-idear-design.md` — read it. It is the binding authority; where this plan and the spec disagree, the spec wins and you should say so.
+
+## Global Constraints
+
+- Module path is `amadan.net/rastrillo/idear`. Never add a `replace` directive — the published `github.com/carlosframework/rastrillo` resolves, and a replace would hide a broken dependency.
+- **Build environment (use this verbatim, every command):**
+ ```
+ export GOFLAGS=-mod=mod CGO_ENABLED=0 \
+ GOPATH=/tmp/claude-1001/-home-paulca-github-com-rastrilloorg-rastrillo/a5746945-a081-4474-a890-44a32e59ccf9/scratchpad/go \
+ GOCACHE=/tmp/claude-1001/-home-paulca-github-com-rastrilloorg-rastrillo/a5746945-a081-4474-a890-44a32e59ccf9/scratchpad/gocache
+ ```
+ Without `GOPATH`/`GOCACHE` pointed at scratch, the Go toolchain fails writing to a read-only filesystem. That is an environment artifact, not a build error.
+- Repo root is the scratchpad `idear/` directory. It is fully writable; you do **not** need to disable any sandbox.
+- `gofmt -l .` must be empty. `go vet ./...` clean. `go test ./... -count=1` green.
+- **Errors are values, and every store mutation is ONE transaction.** An invariant checked outside the transaction that maintains it is not an invariant.
+- **Never bind a form into a struct.** Read named fields explicitly. `Role` is never read from a form on any path.
+- **Not-a-member answers exactly like doesn't-exist**, byte-identically, via the configured `NotFound` hook. `Forbidden` (403) is only for an action on something the caller may legitimately see.
+- Visitor-facing copy: sentence case, ending in a full stop.
+- Commit after each task. Do not push; do not create a PR.
+
+## Ruling that deviates from the spec, already decided
+
+The spec §5 shows `NotFound` on `HandlerConfig`. It belongs on `Config` (the `Roster`), because `Require` — which is a `Roster` method — is the main caller. `Config` therefore carries **both** `NotFound` and `Forbidden` hooks, each defaulting to the stdlib behaviour, and `Handlers` uses the same ones. Behaviour is exactly what the spec describes; only the field's home differs.
+
+---
+
+### Task 1: Roles and policy — pure, no database, no HTTP
+
+**Files:** Create `role.go`, `policy.go`, `role_test.go`, `policy_test.go`, `Makefile`, `.amadan/ci`, `.amadan/ci.d/{10-vet,20-fmt,30-test}`, `.gitignore`.
+
+**Interfaces produced** (later tasks depend on these exact names):
+
+```go
+type Role string
+
+const (
+ RoleOwner Role = "owner"
+ RoleAdmin Role = "admin"
+ RoleMember Role = "member"
+)
+
+func (r Role) Valid() bool
+func (r Role) AtLeast(min Role) bool
+func (r Role) Title() string // "Owner" / "Admin" / "Member"; "" for invalid
+func ParseRole(s string) (Role, bool) // accepts ONLY the three known roles
+
+var ErrForbidden = errors.New("idear: forbidden")
+
+// MayActOn reports whether actor may manage target, returning
+// ErrForbidden (wrapped with the reason) when it may not.
+func MayActOn(actor, target *Member) error
+```
+
+`rank()` is unexported: Owner 3, Admin 2, Member 1, anything else 0. `AtLeast` must return false for an invalid role even against an invalid minimum.
+
+**`MayActOn` rules, all four required:**
+1. actor must be non-nil and active, else forbidden;
+2. actor must be at least Admin, else forbidden;
+3. actor and target must differ (compare `ID`) — **nobody acts on themselves**, including the Owner;
+4. target's rank must be **strictly below** actor's — so Admin manages Member only, Owner manages Admin and Member, and nobody manages an Owner.
+
+`Member` is defined in Task 2; for this task declare it in `member.go` as a minimal struct with `ID int64`, `Role Role`, `DeactivatedAt *time.Time` and an `Active() bool` method, and let Task 2 grow it. Say so in your report.
+
+- [ ] **Step 1: `go mod init` is already done** — `go.mod` exists with the module path and requirements. Do not recreate it.
+
+- [ ] **Step 2: Write the failing tests.**
+
+`role_test.go` must include a table covering **every ordered pair** of `{owner, admin, member, "", "OWNER", "root"}` against `AtLeast`, and assert `ParseRole` rejects `""`, `"OWNER"`, `"root"`, `"Owner"` and accepts exactly the three lowercase spellings.
+
+`policy_test.go` must assert the full 3×3 actor/target matrix plus self-action and inactive-actor cases. Write it as a table with a `want error` column; every cell must be stated, not derived in a loop that mirrors the implementation. Required cells include: owner→admin allowed, owner→member allowed, owner→owner(other) refused, admin→member allowed, admin→admin(other) refused, admin→owner refused, member→anyone refused, anyone→self refused, deactivated-owner→member refused.
+
+- [ ] **Step 3: Run them, confirm they fail** for undefined symbols.
+
+- [ ] **Step 4: Implement `role.go` and `policy.go`.**
+
+- [ ] **Step 5: Run tests, confirm green.**
+
+- [ ] **Step 6: Add the build plumbing.**
+
+`Makefile` with `ci`, `vet`, `fmt-check`, `test` targets — CI steps delegate to make targets, never their own copies of the commands (amadan's rule). `.amadan/ci` is an executable single-script fallback; `.amadan/ci.d/{10-vet,20-fmt,30-test}` are executable steps delegating to make. `.gitignore` ignores build output only.
+
+- [ ] **Step 7: `gofmt -l .` empty, `go vet ./...`, `go test ./... -count=1` green. Commit.**
+
+---
+
+### Task 2: Models, migrations, and the token discipline
+
+**Files:** Modify `member.go`. Create `migrations/0001_init.sql`, `schema.go`, `token.go`, `member_test.go`, `token_test.go`, `schema_test.go`.
+
+**Interfaces produced:**
+
+```go
+type Member struct {
+ ID int64
+ Subject string `gorm:"uniqueIndex"`
+ Email string `gorm:"index"`
+ Name string
+ Role Role `gorm:"not null;index"`
+ DeactivatedAt *time.Time
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+func (m *Member) Active() bool // non-nil AND DeactivatedAt == nil
+
+type Invitation struct {
+ ID int64
+ Email string `gorm:"index"`
+ Role Role `gorm:"not null"`
+ TokenHash string `gorm:"uniqueIndex"`
+ InvitedBy int64
+ CreatedAt time.Time
+ ExpiresAt time.Time
+ AcceptedAt *time.Time
+ RevokedAt *time.Time
+}
+
+func (i *Invitation) Pending(now time.Time) bool // not accepted, not revoked, not expired
+
+// Schema is the package's migration set, merged into the app's BootSchema.
+var Schema = migrate.MustFromFS(migrationFS, "idear")
+
+// Models is what an app hands rastrillo's migration checker.
+func Models() []any // []any{&Member{}, &Invitation{}}
+```
+
+`token.go` (unexported except where noted):
+- `newToken() (token string, err error)` — **32 bytes** from `crypto/rand`, hex-encoded.
+- `hashToken(token string) string` — SHA-256, hex-encoded, lowercase.
+
+The plaintext token exists only in the value `Invite` returns and in the emitted link. **Only the hash is stored.** `sessions` already holds nothing but digests; an addon must not be laxer than the core it rides on.
+
+- [ ] **Step 1: Write the failing tests.**
+
+`token_test.go`: two `newToken()` calls differ; output is 64 hex characters; `hashToken` is stable, lowercase, 64 hex characters, and differs from its input.
+
+`member_test.go`: `Active()` is false for nil, false when `DeactivatedAt` is set, true otherwise. `Pending(now)` is false when accepted, false when revoked, false when `ExpiresAt` is before `now`, true otherwise — assert each independently, not one combined case.
+
+`schema_test.go`: applying `migrate.Merge(sessions.Schema, Schema)` to a fresh `db.Open` succeeds and creates `idear_members` and `idear_invitations`; applying twice is a no-op (the ledger prevents re-run). Add a **frozen checksum** test in the shape of rastrillo's `migrate/frozen_checksums_test.go`: the committed migration's checksum is pinned, so editing a shipped migration fails loudly.
+
+- [ ] **Step 2: Run them, confirm they fail.**
+
+- [ ] **Step 3: Write `migrations/0001_init.sql`.**
+
+Table names `idear_members` and `idear_invitations` (namespaced, so they cannot collide with an app's own). Columns must match the structs exactly, including the unique index on `subject`, the unique index on `token_hash`, and indexes on `email` and `role`. Read `sessions/migrations/0001_init.sql` in the module cache first and match its SQL style.
+
+- [ ] **Step 4: Implement, embedding the migrations with `//go:embed migrations`.**
+
+- [ ] **Step 5: Green. Commit.**
+
+---
+
+### Task 3: The roster store — every invariant, in a transaction
+
+**Files:** Create `roster.go`, `errors.go`, `roster_test.go`, `race_test.go`. Create `internal/ideartest/harness.go` (a shared test harness: temp SQLite, migrations applied, a `*Roster`).
+
+**Interfaces produced:**
+
+```go
+type Config struct {
+ DB *gorm.DB
+ OpenSignUp bool
+ InviteTTL time.Duration // default 7 * 24h
+ Subject func(*http.Request) (string, bool) // default: sessions.Current(r).Subject
+ NotFound func(http.ResponseWriter, *http.Request) // default http.NotFound
+ Forbidden func(http.ResponseWriter, *http.Request) // default 403 + plain text
+ Logger *slog.Logger
+}
+
+type Roster struct{ /* unexported */ }
+
+func New(cfg Config) (*Roster, error) // errors when DB is nil
+
+var (
+ ErrOwnerExists = errors.New("idear: this instance already has an owner")
+ ErrNoInvitation = errors.New("idear: no valid invitation for that address")
+ ErrNotFound = errors.New("idear: no such member")
+ ErrLastOwner = errors.New("idear: the owner cannot be removed")
+)
+
+func (rs *Roster) IsEmpty(ctx context.Context) (bool, error) // ZERO ROWS, not zero ACTIVE rows
+func (rs *Roster) Claim(ctx context.Context, subject, email, name string) (*Member, error)
+func (rs *Roster) Invite(ctx context.Context, actor *Member, email string, role Role) (inv *Invitation, token string, err error)
+func (rs *Roster) Revoke(ctx context.Context, actor *Member, id int64) error
+func (rs *Roster) Accept(ctx context.Context, token, subject, name string) (*Member, error)
+func (rs *Roster) SetRole(ctx context.Context, actor, target *Member, role Role) error
+func (rs *Roster) Deactivate(ctx context.Context, actor, target *Member) error
+func (rs *Roster) Reactivate(ctx context.Context, actor, target *Member) error
+func (rs *Roster) Transfer(ctx context.Context, owner, to *Member) error
+func (rs *Roster) BySubject(ctx context.Context, subject string) (*Member, error)
+func (rs *Roster) ByID(ctx context.Context, id int64) (*Member, error)
+func (rs *Roster) Members(ctx context.Context) ([]Member, error)
+func (rs *Roster) PendingInvitations(ctx context.Context) ([]Invitation, error)
+```
+
+**The invariants — each enforced INSIDE its own transaction:**
+
+- **`Claim`**: succeeds only when the members table has **zero rows**. A roster whose members are all deactivated must NOT reopen the claim — that would hand a stranger Owner of an instance full of dormant data. A racing second claim gets `ErrOwnerExists`. The claimant becomes `RoleOwner`.
+- **`Invite`**: `actor` must pass `MayActOn`-style authority (at least Admin); **refuses `RoleOwner` outright, on every path, for every actor**. Ownership moves only by `Transfer`. Returns the plaintext token exactly once; stores only its hash. `ExpiresAt = now + InviteTTL`.
+- **`Accept`**: consumes by **compare-and-swap**, not lookup-then-write:
+ ```sql
+ UPDATE idear_invitations SET accepted_at = ?
+ WHERE id = ? AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > ?
+ ```
+ with **rows-affected checked**, inside the same transaction as the `Member` insert. Anything softer lets `Revoke` race acceptance and admit a revoked invitation. Single-use cannot be delegated to the app's unique-email index — idear can neither see nor enforce that index.
+- **`SetRole`**: `MayActOn(actor, target)` must pass; the new role must parse; **`RoleOwner` is refused** — ownership moves only by `Transfer`.
+- **`Deactivate`**: `MayActOn` must pass; **the Owner can never be deactivated** (`ErrLastOwner`).
+- **`Reactivate`**: `MayActOn` must pass. Required, because `Subject` is unique and removal is deactivation — without it a removed person can never be readmitted by any path.
+- **`Transfer`**: Owner-initiated only. In ONE transaction: re-read both rows, confirm the actor is still Owner, **confirm the target is still active** (otherwise a transfer racing a `Deactivate` yields a deactivated Owner and an instance nobody can administer), demote the old Owner to Admin, promote the target to Owner. Exactly one Owner must exist at every commit boundary.
+
+- [ ] **Step 1: Build `internal/ideartest/harness.go` first** — `New(t)` returning a `*Roster` over a temp SQLite file with `migrate.Merge(sessions.Schema, idear.Schema)` applied, plus helpers to seed a member at a given role. Every later task uses it.
+
+- [ ] **Step 2: Write the failing tests**, one per invariant above, asserting the error values by `errors.Is`.
+
+Plus `race_test.go`, which is the point of this task and must use real concurrency, not sequential calls:
+- `TestConcurrentClaimYieldsOneOwner` — **six** goroutines call `Claim` at once; exactly one succeeds, five get `ErrOwnerExists`, and the table holds exactly one row.
+- `TestConcurrentTransfersKeepOneOwner` — **six** concurrent `Transfer` calls; afterwards `SELECT count(*) WHERE role='owner'` is exactly 1. Round 1 of the bake-off found precisely this bug in a hand-rolled version.
+- `TestRevokeRacingAcceptNeverAdmits` — start `Revoke` and `Accept` concurrently on one invitation, repeated across many iterations; assert that a successful `Accept` and a successful `Revoke` never both happen for the same invitation.
+- `TestTransferRacingDeactivateNeverStrandsOwner` — concurrent `Transfer` to a target and `Deactivate` of that same target; assert the resulting Owner is always active.
+
+Run the race tests with `-race`. Note that `-race` needs cgo, so use `CGO_ENABLED=1` **for that one command only**.
+
+- [ ] **Step 3: Confirm they fail. Step 4: Implement. Step 5: Green, including `-race`. Commit.**
+
+---
+
+### Task 4: Middleware and the two identity adapters
+
+**Files:** Create `middleware.go`, `admit.go`, `middleware_test.go`, `admit_test.go`.
+
+**Interfaces produced:**
+
+```go
+func (rs *Roster) Require(next http.Handler) http.Handler
+func (rs *Roster) RequireRole(min Role) func(http.Handler) http.Handler
+func From(r *http.Request) *Member // nil when absent
+func WithMember(r *http.Request, m *Member) *http.Request
+
+func (rs *Roster) CarryToken(next http.Handler) http.Handler
+func (rs *Roster) Authorize(address string) bool
+func (rs *Roster) Admitting(create func(ctx context.Context, email, hash string) (int64, error)) func(ctx context.Context, email, hash string) (int64, error)
+```
+
+**`Require`** resolves the subject via `Config.Subject`, loads the member, and calls `Config.NotFound` unless that member exists **and is active**. It never redirects — signed-out handling belongs to the upstream `sessions.Require`. Mounted outside a session group the subject is empty and every request 404s; that is correct-but-silent, so say it loudly in the doc comment.
+
+**`RequireRole(min)`** answers `Config.Forbidden` (**403**) when the member's role is below `min`. It **stacks inside `Require`**; mounted bare it would 403 a non-member and break the 404 rule. Document that.
+
+**`CarryToken`** reads the `invite` field from the posted form and stashes it in the request context for `Admitting` to read. This exists because `password.Config.Create` receives no `*http.Request` — only `r.Context()`. Mounting it on the signup route is **mandatory** on the password path; without it every invited signup is refused, which is loud and safe rather than quiet and permissive.
+
+**`Admitting`** decides the role **before reading anything else the form says**, in this order:
+1. roster has zero rows → `RoleOwner` (claim);
+2. a **valid token** from the context — unexpired, unrevoked, unaccepted — **whose `Email` equals the submitted address** → the invitation's role;
+3. `OpenSignUp` → `RoleMember`;
+4. otherwise → `password.Refuse(...)`.
+
+**Possession of the token is required. An email match alone is NOT enough** — `password.Signup` never verifies an address, so email-match admission would let anyone who learns an invited address register it first and land at the invited role.
+
+The refusal message must be **one constant string, identical for every refused address, and must never interpolate the submitted address** — `password.Refuse`'s own contract now says so.
+
+Then: call the app's `create`, and on success write the `Member` with `Subject = strconv.FormatInt(id, 10)` (matching what `password` mints as the session subject), CAS-accepting the invitation in the same transaction. A failure between `create` and the member write leaves an orphan; that is fail-closed and is healed by the reconciliation route in Task 5.
+
+On `ErrOwnerExists` from a racing claim, return `password.Refuse(...)` with the same constant copy.
+
+**`Authorize(address)`** is the keymail adapter, matching `auth.Config.Authorize`'s `func(string) bool`. Active member → true. Roster empty → claim as Owner → true. Valid pending invitation for that address → accept, creating the member with `Subject = address` → true. Otherwise false. It has **no error channel**, so a database failure is indistinguishable from a policy denial to the visitor: log the distinction even though you cannot render it.
+
+- [ ] **Step 1: Write the failing tests.** Required, at minimum:
+ - `TestAdmittingRequiresTheToken` — an invitation exists for `admin@corp.test`; a signup for that address **with no `invite` field** is refused, and no member row is created at the invited role. This is the blocker the Fable review caught; it must be pinned.
+ - `TestAdmittingRejectsMismatchedEmail` — a valid token, but a different submitted address, is refused.
+ - `TestAdmittingRejectsExpiredAndRevokedTokens` — two cases, separately.
+ - `TestAdmittingClaimsFirstAccountAsOwner`.
+ - `TestAdmittingOpenSignUpJoinsAsMember`.
+ - `TestAdmittingRefusalUsesConstantCopy` — refuse two different addresses, assert the two messages are byte-identical and neither contains either address.
+ - `TestRequireAnswersNotFoundForNonMember` and `...ForDeactivatedMember`.
+ - `TestRequireRoleForbidsBelowMinimum` (403, not 404).
+ - `TestAuthorizeAdmitsInvitedAddressOnce` — second call for the same consumed invitation is false.
+
+- [ ] **Step 2: Confirm they fail. Step 3: Implement. Step 4: Green. Commit.**
+
+---
+
+### Task 5: HTTP handlers, including the reconciliation route
+
+**Files:** Create `handlers.go`, `handlers_test.go`.
+
+**Interfaces produced:**
+
+```go
+type HandlerConfig struct {
+ Roster *Roster
+ RenderMembers func(w http.ResponseWriter, r *http.Request, d MembersPage)
+ RenderInvitation func(w http.ResponseWriter, r *http.Request, d InvitationPage)
+}
+
+type MembersPage struct {
+ Viewer *Member
+ Members []Member
+ Invitations []Invitation
+ Error string
+ Notice string
+}
+
+type InvitationPage struct {
+ Role Role
+ Site string
+ Token string
+ Error string
+ SignedIn bool
+ Reconcile bool // the viewer is signed in with no member row
+}
+
+type Handlers struct{ /* unexported */ }
+
+func NewHandlers(cfg HandlerConfig) (*Handlers, error) // errors unless Roster and BOTH renderers are set
+```
+
+Handler methods, mounted by the app at these default paths:
+
+```
+GET /members Handlers.Members (Require)
+POST /members/invitations Handlers.Invite (RequireRole admin)
+POST /members/invitations/{id}/revoke Handlers.Revoke (RequireRole admin)
+POST /members/{id}/role Handlers.SetRole (RequireRole admin)
+POST /members/{id}/remove Handlers.Remove (RequireRole admin)
+POST /members/{id}/restore Handlers.Restore (RequireRole admin)
+POST /members/transfer Handlers.Transfer (RequireRole owner)
+GET /invitations/{token} Handlers.Invitation (public)
+POST /invitations/{token} Handlers.Accept (public, signed-in reconciliation)
+```
+
+**The reconciliation route is the point of `POST /invitations/{token}`.** For a signed-in viewer with **no member row**, given a valid token matching their session, or a roster with zero rows, it writes the member from the live session `Subject`. This heals the orphan `Admitting` can leave, resolves the `Claim`-race loser, and is the only path by which a member row is created from an already-live session. Without it an orphaned user can sign in and 404 forever — the retry cannot help, because the app's `Create` now fails on the duplicate email and `password.Signup` renders that as 422 without ever reaching the member write.
+
+**`GET /invitations/{token}` must not be a free oracle:** it is an unauthenticated lookup of a secret. It must not echo the full invited address (show the role and the instance, not the address), and both public routes must be rate-limited per-IP-ish in memory, bounded and documented.
+
+Mutations read each permitted field by name and never bind a struct; ids come from the URL; after a successful mutation, `flash` a notice and 303 back to the members page.
+
+- [ ] **Step 1: Write the failing tests, driving real HTTP** through a `chi` router with a cookie jar and real CSRF, using `internal/ideartest`. This suite IS the deliverable — the spec §7 list, in full:
+ 1. a non-member is refused **read and write on every route**, with byte-identical 404s, including deeply nested ids;
+ 2. a Member is refused every management action;
+ 3. an Admin cannot change, demote, deactivate or restore an Admin or the Owner;
+ 4. a posted `role=owner` never lands, on any path, for any actor — assert the database row, not just the response;
+ 5. the single-owner invariant holds across concurrent transfers (already covered in Task 3; assert it through HTTP here too);
+ 6. an invited address cannot be claimed without the token;
+ 7. `Revoke` racing `Accept` never admits;
+ 8. an expired invitation is refused and an accepted one cannot be replayed;
+ 9. an orphaned user is healed by `POST /invitations/{token}` while signed in, and 404s on every route until they are;
+ 10. `Transfer` racing `Deactivate` never yields a deactivated Owner;
+ 11. a deactivated member can be reactivated and regains exactly their prior access and no more;
+ 12. `GET /invitations/{token}` does not disclose the invited address.
+
+**Test-quality rule, enforced:** allow-list and payload tests must derive their payloads from the shared list under test, never restate it. Round 1 of the bake-off found a test written to whitelist the very payload it listed, so a green suite passed over a live vulnerability. A test that quotes the implementation proves the implementation equals itself.
+
+- [ ] **Step 2: Confirm they fail. Step 3: Implement. Step 4: Green. Commit.**
+
+---
+
+### Task 6: The example app, `SKILL.md`, and `README.md`
+
+**Files:** Create `example/main.go`, `example/models.go`, `example/pages/*.html`, `example/README.md`, `SKILL.md`, `README.md`.
+
+**The example** is a complete working app on rastrillo + idear: its own `User` model with email and password hash, `password.New` wired with `Create: roster.Admitting(createUser(db))` and the signup route wrapped in `roster.CarryToken`, the members and invitation pages rendered through the callbacks, and **`/` behind `Require`**. That last point is load-bearing: under the password plugin, deactivation is enforced per-request by `Require`, **not at sign-in** — `password.Signin` runs Lookup → Verify → mint with no idear involvement. An ungated landing page is the one place this design leaks, so the example must not have one, and `SKILL.md` must say why.
+
+It must seed at least two accounts at different roles so the role gates are something you can click on.
+
+**`SKILL.md`** is the authoring doc an agent loads instead of the source — the mechanism that makes an addon cheap. Model it on `carrillo-chassis/SKILL.md` in structure and on Rastrillo's `SKILL.md` in voice. It must cover, at minimum:
+- the one-line install (`go get amadan.net/rastrillo/idear`), with **no replace directive**;
+- `New`, `NewHandlers`, and the exact route table;
+- the two identity adapters, and that `CarryToken` is **mandatory** on the password path;
+- the security discipline: never bind a form, `role` never from a form, 404-not-403 for non-members, allow-lists, `Refuse`'s constant-copy rule;
+- the traps: `Require` mounted outside a session group silently 404s everything; `RequireRole` must stack inside `Require`; deactivation is per-request under password, not at sign-in, so gate `/`;
+- **one identity plugin per app** — mounting both password and keymail gives one human two subjects and two roster rows whose roles drift apart;
+- Owner break-glass: a lost Owner credential otherwise means a permanently unadministrable instance. Document the recovery (the SQL, plainly).
+- that `idear.Schema` merges into `BootSchema`, never into the app's own `Schema`.
+
+**`README.md`** is short: what idear is, what it is not, the install line, and a pointer to `SKILL.md`.
+
+- [ ] **Step 1: Write the example and get it building and serving.**
+- [ ] **Step 2: Write `SKILL.md` and `README.md`.**
+- [ ] **Step 3: Full gate green. Commit.**
+
+---
+
+## Self-Review
+
+**Spec coverage.** §4 model → Task 2. §5 middleware/policy/store/adapters/handlers → Tasks 1, 3, 4, 5. §5 reconciliation → Task 5. §6 repo shape and the SKILL.md delivery mechanism → Tasks 1 and 6. §7 test list → Tasks 3 and 5. §9's "one identity plugin per app", invitation expiry and Owner break-glass → Tasks 2 and 6.
+
+**Deliberately deferred:** rebind-on-address-change, which the spec names as an open question and does not answer. Do not invent an answer; if you touch it, note it in your report.
+
+**Type consistency.** `Role`, `Member`, `Invitation`, `Roster`, `Config`, `HandlerConfig`, `MembersPage`, `InvitationPage` and every method name above are spelled once here and must be spelled identically everywhere. `Subject` is a `string` on every path — never an `int64`, never `sessions.UserID`.
diff --git a/docs/superpowers/specs/2026-08-23-idear-design.md b/docs/superpowers/specs/2026-08-23-idear-design.md
new file mode 100644
index 0000000..9adc1d3
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-23-idear-design.md
@@ -0,0 +1,522 @@
+# 🤖 idear: roles and membership as an addon
+
+Design, 2026-08-23. Approved by Paul the same evening.
+
+A second CARLOS authoring bake-off graded five stacks building the same
+team Kanban app. Rastrillo and `carrillo-chassis` **tied on the rubric** —
+49/50 each, identical on all four axes, both a perfect 20/20 on the
+weighted security axis with zero confirmed defects. The chassis was
+ranked first on a metric the rubric records but does not score: builder
+tokens, 165,944 against Rastrillo's 216,012.
+
+The gap has one cause, and it is not framework friction. Rastrillo's
+builder was *more* efficient per line — 61 output tokens per written line
+against the chassis's 95. It simply had to write 1,781 more of them:
+3,537 written lines against 1,756. That excess is almost exactly the
+accounts-roles-invitations layer the chassis inherits and Rastrillo has
+no answer for. The framework contributed nothing to the surface the
+bake-off grades hardest, because it has no role concept: `scope.Owned`
+is a per-user `WHERE user_id = ?`, and a team-global Kanban board is not
+an owned row.
+
+This builds that layer — **outside the framework**.
+
+## 1. Why an addon, and not core
+
+The obvious response to the bake-off is `rastrillo/team`. It is the wrong
+one.
+
+Two arguments for it are weak and worth discarding before they get
+repeated. The `SKILL.md` budget is one: the ceiling has already been
+raised twice (15k → 16k → 17k), and an addon still costs the same ~330-byte
+pointer a core package's `Full treatment:` line would. "Not every app has
+members" is the other — it applies equally to `passkey`, `jobs` and
+manifests, all of which are core.
+
+The real reasons are narrower and hold up. **Release cadence:** roles
+will churn (reactivation, expiry policy, break-glass) at a rhythm that
+should not drag the framework's tag along, and a v0.x framework already
+asks enough of its consumers. **Gate honesty:**
+`internal/docsite/symbols_test.go` requires every core package to carry a
+reference page naming every exported symbol; that gate is valuable
+exactly because it is expensive, and it should be spent on what every app
+mounts.
+
+What the split genuinely costs, stated so nobody rediscovers it later:
+MVS version skew (idear pins a rastrillo; a `sessions` or `migrate`
+change can block an app's upgrade until idear catches up), a second
+frozen-checksum and CI regime, and the vanity-path risk of §9. Those are
+accepted, not waved off.
+
+So idear ships as its own module, versioned separately, and the
+dependency arrow points one way: **idear imports rastrillo; rastrillo
+never imports idear.** Inside this repo the change is a documentation
+page, its nav entry, a pointer in `SKILL.md`, and one small seam in
+`password` that §5 forces and §8 pays for.
+
+### The tenancy ruling survives intact
+
+The 2026-08-22 ruling — instance-per-team, team tenancy is APP-level, no
+membership package — was about *cross-team isolation*, and it stands
+unchanged. `carrillo-chassis` agrees with it completely; its own skill
+doc opens "the instance IS the tenant… there is no tenant field, no
+tenant scope."
+
+Owner > Admin > Member *inside* one instance is a different question, and
+the ruling never answered it. idear is the intra-instance half. Nothing
+here reintroduces a tenant column, a tenant scope, or `/t/{slug}`
+routing.
+
+## 2. The seam this fills already exists
+
+`auth.Config.Authorize` is documented, in the framework, today:
+
+> Authorize is the admission gate: given a verified address, may it have
+> a session? Nil admits every verified address. **Membership models
+> (tables, roles, admin bootstrap) are app policy layered on this hook.**
+
+That hook was left open for precisely this. idear fills it rather than
+inventing a parallel one — which is the difference between an addon and
+a fork.
+
+## 3. What idear is
+
+The **roster** for a Rastrillo instance: who is in it, at what role, and
+who may change that.
+
+It is not an identity provider. It never mints a session, never hashes a
+password, never renders a sign-in form. It sits on top of `sessions` and
+whichever identity plugin the app already chose, so an app using keymail
+or passkeys keeps them.
+
+Module path `amadan.net/rastrillo/idear`, repository
+`https://amadan.net/rastrillo/idear`. It depends on
+`rastrillo/{sessions,migrate,form,flash}` and `gorm.io/gorm`. It does
+**not** depend on `rastrillo/ui`: the example styles its pages, the
+library does not own a look.
+
+## 4. The data model
+
+Two tables and one migration set, following the `sessions` / `auth` /
+`blobs` convention exactly.
+
+```go
+type Member struct {
+ ID int64
+ Subject string `gorm:"uniqueIndex"`
+ Email string `gorm:"index"`
+ Name string
+ Role Role `gorm:"not null;index"`
+ DeactivatedAt *time.Time
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+type Invitation struct {
+ ID int64
+ Email string `gorm:"index"`
+ Role Role `gorm:"not null"`
+ TokenHash string `gorm:"uniqueIndex"`
+ InvitedBy int64
+ CreatedAt time.Time
+ ExpiresAt time.Time
+ AcceptedAt *time.Time
+ RevokedAt *time.Time
+}
+
+var Schema = migrate.MustFromFS(migrationFS, "idear")
+```
+
+Four decisions worth stating, because each has an alternative that looks
+better until you try it.
+
+**`Subject` is a string, and it is the join.** idear does not own email
+and password — the app keeps its own `User` row. The key between them is
+the session `Subject`, because it is the only identifier both identity
+plugins produce: the password plugin's Subject is a numeric user id,
+keymail's is a verified email address. `sessions.UserID` returns
+`(0, false)` under keymail, and a membership layer keyed on it would
+resolve every keymail viewer to member zero. `jobs` already set this
+precedent — its owner is the session Subject, for the same reason.
+
+**The token is hashed at rest, and it expires.** `TokenHash` stores a
+SHA-256 digest; the plaintext token exists only in the emitted link.
+`sessions` already holds nothing but `HashToken` digests, and an addon has
+no business being laxer than the core it rides on. `ExpiresAt` defaults to
+seven days. Round 1 of the bake-off penalised an arm for immortal,
+anonymously-fetchable invitation tokens that disclosed team and email;
+this is that finding, applied before it is earned a second time. Tokens
+are 32 bytes of `crypto/rand`, and `GET /invitations/{token}` does not
+echo the full invited address.
+
+**Removal is `DeactivatedAt`, never a delete.** A deleted row dangles
+every `AuthorID` in the app's own tables. The corollary is that
+reactivation must exist as a first-class operation (§5) — `Subject` is
+unique, so without it a removed person can never be readmitted by any
+path: keymail's `Authorize` sees the inactive row and refuses, password
+re-signup hits the app's duplicate-email check, and a fresh invitation's
+Member insert collides with the dead row.
+
+**Apps merge `idear.Schema` into `BootSchema`, never into `Schema`** —
+`migrate.Merge(sessions.Schema, idear.Schema, Schema)`. Merging it into
+the app's own `Schema` makes `rastrillo migration check` propose dropping
+tables that `Models` does not know about. This is the documented rule for
+every subsystem and idear is not an exception to it.
+
+## 5. The API
+
+Mirrors `jobs`: a core built at boot, then handlers that error unless
+their renderers are set.
+
+```go
+r, err := idear.New(idear.Config{DB: d.G, OpenSignUp: false})
+h, err := idear.NewHandlers(idear.HandlerConfig{
+ Roster: r,
+ RenderMembers: renderMembers,
+ RenderInvitation: renderInvitation,
+ NotFound: renderNotFound,
+})
+```
+
+`Config.Subject` defaults to reading `sessions.Current(r).Subject` and
+exists as an override for an app whose viewer arrives another way.
+
+### Middleware — the membership gate
+
+- `r.Require` — signed in *and* an active member. A non-member is
+ answered by `HandlerConfig.NotFound`, which **must be the same renderer
+ the app gives chi's own `NotFound`**. "Byte-identical 404" is otherwise
+ unimplementable: an app with a custom 404 page makes idear's default
+ `http.NotFound` distinguishable, and that delta *is* the membership
+ oracle `SKILL.md` §3 forbids. The default is `http.NotFound`; an app
+ with its own 404 page and no `NotFound` hook is the one misconfiguration
+ idear cannot detect, so the skill doc leads with it.
+- `r.RequireRole(min)` — **403**, because a member can legitimately see
+ the page and merely may not act. It **stacks inside** `Require`; mounted
+ bare it would 403 a non-member and break the 404 rule.
+- `Config.Subject` reads a session the caller must already have resolved:
+ mount `Require` *inside* a `sess.Require` (or `auth.RequireSession`)
+ group. Mounted outside one, the Subject is empty and every request 404s
+ — silently, and identically to a real refusal. Signed-out requests are
+ the upstream middleware's business; idear does not redirect.
+- `idear.From(req) *Member` — the viewer, following `auth.From`.
+
+### Policy, as pure functions
+
+`Role.AtLeast`, `ParseRole` (which accepts only the three known roles —
+anything else is not a role, not a default), and:
+
+```go
+func MayActOn(actor, target *Member) error
+```
+
+Admins manage Members only; nobody acts on themselves; the target's rank
+must be strictly below the actor's. Keeping this a pure function is what
+lets the role matrix be tested exhaustively without an HTTP server.
+
+### Store operations
+
+Each is one transaction and each enforces its own invariant, because an
+invariant checked outside the transaction that maintains it is not an
+invariant:
+
+`Claim` · `Invite` · `Revoke` · `Accept` · `SetRole` · `Deactivate` ·
+`Reactivate` · `Transfer`.
+
+Three carry invariants that a naive implementation loses:
+
+**`Accept` consumes the invitation by CAS, not by lookup.** The update is
+`SET accepted_at = ? WHERE id = ? AND accepted_at IS NULL AND revoked_at
+IS NULL AND expires_at > ?`, with rows-affected checked, **inside** the
+same transaction as the Member write. Anything softer lets `Revoke` race
+acceptance and admits a revoked invitation. Single-use cannot be delegated
+to the app's unique-email index — idear can neither see nor enforce that
+index, so the CAS is the invariant.
+
+**`Transfer` checks the target is active inside its own transaction.**
+Otherwise a transfer racing a `Deactivate` of the same target produces a
+deactivated Owner: an instance with no one able to administer it and no
+one able to be promoted.
+
+**`Claim` means zero rows, not zero active rows.** A roster whose members
+have all been deactivated must not reopen the claim — that would hand a
+stranger Owner of an instance full of dormant data.
+
+### Admission: the token is the credential
+
+This is the part the first draft got wrong, and it was wrong in the one
+place that mattered.
+
+The rule is **possession of the invitation token, plus an email match** —
+not an email match alone. The chassis resolves the invitation *from the
+token* and then checks `inv.Email == email`
+(`carrillo-chassis/handlers_auth.go:115-129`). Email-match alone is
+exploitable under the password plugin, which never verifies an address:
+anyone who learns that `admin@corp.com` was invited registers that address
+with their own password first and lands at the invited role.
+
+`password.Config.Create` is `func(ctx, email, hash) (int64, error)` — it
+receives no `*http.Request`, so the token cannot be read from the form
+inside it. It does receive `r.Context()`. So idear supplies a middleware:
+
+```go
+r.Post("/signup", idear.CarryToken(ph.Signup))
+```
+
+`CarryToken` reads the `invite` field from the posted form and stashes it
+in the request context; `Admitting` reads it back. Mounting it is
+**mandatory** on the password path — without it every invited signup is
+refused, which is loud and safe rather than quiet and permissive.
+
+Admission then decides the role **before reading anything else the form
+says**, in this order: **claim** if the roster has zero rows; otherwise a
+**valid token** — unexpired, unrevoked, unaccepted — whose `Email` equals
+the submitted address; otherwise `OpenSignUp` at `RoleMember`; otherwise
+refuse. The role is never read from the form on any path.
+
+### Reconciliation: the signed-in half of the invitation routes
+
+`Admitting` cannot enlist the app's opaque `Create` into its transaction,
+so a failure between "app user created" and "Member written" leaves a
+user row with no membership. The first draft called this an honest edge
+and claimed a retry heals it. **It does not:** the retry's `Create` fails
+on the now-duplicate email, and `password.Signup` renders that as 422
+without ever reaching the Member write. The orphan can sign *in* and 404s
+forever.
+
+The same terminal state is reachable with no failure at all. Two
+concurrent first signups both observe an empty roster; one wins the
+`Claim`, and the `ErrOwnerExists` loser is an orphan.
+
+So reconciliation is a designed path, not a footnote, and it is what the
+public invitation routes are *for*:
+
+- `GET /invitations/{token}` — renders the invitation to anyone holding a
+ valid token, naming the instance and the role but not the full address.
+- `POST /invitations/{token}` — **for a signed-in viewer with no Member
+ row**: given a valid token matching their session, or a roster with zero
+ rows, it writes the Member from the live session `Subject`. This heals
+ the orphan, resolves the `Claim`-race loser, and is the only path by
+ which a Member row is created from an already-live session.
+
+Both public routes are rate-limited; `GET` is an unauthenticated lookup
+of a secret and must not be a free oracle.
+
+### The two identity adapters
+
+```go
+// keymail — fills the hook auth already documents as the app's
+auth.New(auth.Config{Sessions: sess, Authorize: r.Authorize})
+
+// password — wraps the app's own Create
+password.New(password.Config{Sessions: sess, Create: r.Admitting(createUser(d.G))})
+```
+
+Two asymmetries between them must be documented rather than smoothed
+over, because a reader will otherwise assume the guarantee is uniform:
+
+**Refusals need a channel.** `password.Config.Create`'s contract is that
+*any* error means duplicate-email, so an uninvited visitor would be told
+their address is already registered — which is simply false. This design
+therefore adds `password.ErrRefused` to the **core** package: `Signup`
+checks `errors.Is(err, password.ErrRefused)` and renders the refusal's
+own message at 403. That makes §8 more than the three files the first
+draft promised, and the seam is worth it — anything gating signup needs
+it.
+
+Be honest about the direction of the enumeration argument, because the
+tempting version of it is backwards. The old behaviour was *less*
+distinguishable, not more: an invite-only app answered 422 "already
+registered" both to a registered address and to an uninvited
+unregistered one, and a prober could not tell them apart. The 403
+**creates** that existence bit; it does not close one. The
+justification is that the duplicate message is false, and that a true
+answer someone can distinguish is worth more than a false answer they
+cannot. idear's refusal copy must therefore be one string for every
+refused address, never interpolating the address, or the 403 becomes a
+finer oracle than the outcome alone. Logging which address was refused
+is idear's job — the framework deliberately does not log refusals.
+
+**Deactivation is enforced per request, not at sign-in — under password.**
+`password.Signin` runs Lookup → Verify → mint with no idear involvement;
+only keymail's admission consults `Authorize`. So a deactivated member or
+an orphan can still *mint a session* under the password plugin; what stops
+them is `Require` on every route. The example must therefore gate `/`
+itself, and the skill doc must say that an ungated landing page is the
+one place this design leaks. The chassis refuses at sign-in because it
+owns the credential check; idear does not, and should not claim to.
+
+**`Authorize` returns a bool with no error channel** (`func(address
+string) bool`, verified in `auth/auth.go`). A database failure during
+admission is therefore indistinguishable from a policy denial to the
+visitor. idear logs the distinction even though it cannot render it.
+`Authorize` also runs *before* `SecondFactor`, so a Member row can be
+written for a sign-in that a 2FA gate never completes — self-healing on
+the next attempt, and stated so nobody reads it as a bug.
+
+### Handlers
+
+Paths belong to the app; these are the defaults the example mounts.
+
+```
+GET /members
+POST /members/invitations
+POST /members/invitations/{id}/revoke
+POST /members/{id}/role
+POST /members/{id}/remove
+POST /members/{id}/restore
+POST /members/transfer
+GET /invitations/{token} (public)
+POST /invitations/{token} (public, signed-in reconciliation)
+```
+
+Rendering goes through callbacks the app supplies, following
+`password.Config.RenderSignin`. idear owns the flows, where the role
+rules actually get enforced; the app owns its shell.
+
+## 6. Repository shape
+
+```
+role.go Role, AtLeast, ParseRole, Title
+member.go Member, Invitation
+roster.go Config, New, the store operations
+policy.go MayActOn — pure, no net/http
+middleware.go Require, RequireRole, From
+admit.go Authorize, Admitting
+handlers.go NewHandlers, the eight handlers, the PageData types
+migrations/0001_init.sql
+example/ a working app on rastrillo + idear
+SKILL.md idear's own authoring doc
+Makefile, .amadan/ci, .amadan/ci.d/
+```
+
+idear ships its **own `SKILL.md`** on the same contract as Rastrillo's —
+an agent loads it instead of the source. That mechanism, not the code, is
+what made the chassis arm cheap: a 361-line skill doc carrying a
+~3,110-line platform layer the builder mostly did not have to read. (Not
+"never read" — round 2 records the carrillo builder's context as including
+"the chassis Go studied to use it." The saving is real and it is partial.)
+
+### How an agent gets it
+
+This is the part that decides whether the addon thesis holds at all, and
+it does not come for free the way the framework's does. Rastrillo's
+`SKILL.md` sits at the repo root the scaffold points to. idear's would
+land in a versioned module-cache directory nobody names, and
+`docs/addons` is a directory page, not a skill.
+
+So idear's `SKILL.md` is **served at a stable URL**,
+`https://amadan.net/rastrillo/idear/SKILL.md`, and `docs/site/addons.md`
+carries the exact `curl` line for it — matching the convention Rastrillo's
+own `SKILL.md` already uses for `curl -s
+https://rastrillo.org/docs/<page>.md`. No new machinery, and it
+generalises to every future addon: the directory page's job is to hand an
+agent a fetchable skill, not to describe one.
+
+An addon whose skill doc an agent cannot find saves an app the typing and
+none of the reading — which is the whole cost argument, lost.
+
+## 7. Testing
+
+Tests drive the HTTP surface with a cookie jar and real CSRF tokens
+scraped from rendered pages — the path a browser and an attacker both
+take. The authorization suite is the deliverable, not a supporting
+artifact:
+
+1. a non-member is refused **read and write on every route**, with
+ byte-identical 404s, including deeply nested ids;
+2. a Member is refused every management action;
+3. an Admin cannot change, demote or deactivate an Admin or the Owner;
+4. a posted `role=owner` never lands, on any path, for any actor;
+5. the single-owner invariant holds **across six concurrent transfers**,
+ run as an actual race — round 1 of the bake-off found exactly this bug
+ in the hand-rolled version;
+6. migration checksums frozen, matching `migrate/frozen_checksums_test.go`.
+
+Six more, each pinning a defect this design was revised to close:
+
+7. an invited address cannot be claimed **without the token** — registering
+ `admin@corp.com` on an invite-only instance with no `invite` field
+ admits at no role, not the invited one;
+8. `Revoke` racing `Accept` never admits: the CAS is exercised
+ concurrently, not asserted about;
+9. an expired invitation is refused, and an accepted one cannot be
+ replayed;
+10. an orphaned user — created, then failed before the Member write — is
+ healed by `POST /invitations/{token}` while signed in, and 404s on
+ every route until they are;
+11. `Transfer` racing `Deactivate` of the same target never yields a
+ deactivated Owner;
+12. a deactivated member can be reactivated and regains exactly their
+ prior access, and no more.
+
+**One anti-pattern designed out.** Round 1's auditor found a test written
+to whitelist the very payload it listed, so a green suite passed over a
+live open redirect. Allow-list tests here derive their payloads from the
+shared list under test; they never restate it. A test that quotes the
+implementation proves the implementation equals itself.
+
+## 8. The changes in this repository
+
+More than the three files the first draft promised, because
+`password.ErrRefused` (§5) is a core change and has to be paid for
+honestly:
+
+**`password/handlers.go`** gains an exported `ErrRefused` sentinel and one
+branch in `Signup`: `errors.Is(err, password.ErrRefused)` renders the
+wrapped message at 403 instead of the duplicate-email copy at 422. With
+it come its tests and a line on `docs/site/reference/password.md`, which
+`symbols_test.go` requires for any new exported symbol. This is a seam,
+not a feature — anything gating signup needs it, and idear is simply the
+first.
+
+**`docs/site/addons.md`** plus its `nav.json` section. Written as a
+directory that scales past one entry: what an addon is — versioned
+separately, depends on rastrillo and never the reverse, ships its own
+namespaced `migrate.Set` and its own `SKILL.md` — then the idear entry
+with its module path, what it does, what it deliberately does not, and
+the wiring. Every Go fence must parse; that is a gate, not a style note.
+
+Living in `docs/site/` rather than the website repo means it inherits all
+six docsite gates and rides the existing vendoring. The URL is
+`rastrillo.org/docs/addons`.
+
+The addons page also carries the `curl` line for idear's own `SKILL.md`
+(§6) — without it the directory describes a skill instead of delivering
+one.
+
+**`SKILL.md`**, roughly 330 bytes into §3 — where the reader has just
+been told scoping separates users and not tenants, and immediately
+wonders how roles work:
+
+> **Roles and membership are an addon, not core.** Rastrillo has no role
+> concept: who is *in* this instance and at what rank is
+> `amadan.net/rastrillo/idear` — Owner/Admin/Member, invitations, and
+> the members UI, over `sessions` and either identity plugin.
+> Full treatment: docs/site/addons.md — rastrillo.org/docs/addons
+
+That lands the file near 16.4 KB against the 17 KB budget, buying the
+pointer without a trim, in the existing `Full treatment:` convention.
+
+## 9. Sequencing and open risk
+
+The in-repo documentation ships first, as its own pull request: it states
+the doctrine and is independently useful before any of idear exists.
+idear follows as its own plan against the amadan repository.
+
+**Open risk: the vanity import path.** `amadan.net/rastrillo/idear`
+requires amadan.net to serve a `go-import` meta tag at
+`/rastrillo/idear?go-get=1`. Paul believes it already does. Believing is
+not verifying, and the whole premise of the addon is that an unattended
+agent can `go get` it, so a smoke test against a throwaway module is task
+zero of the idear plan — before anything depends on the path.
+
+## What this does not claim
+
+idear closes a **cost** gap, not a correctness one. Both arms scored
+20/20 on security with zero confirmed defects; the hand-rolled gate held
+against live adversarial probing. Nothing here says Rastrillo was unsafe.
+
+And the chassis's own caveat transfers wholesale: **a library buys the
+mechanism, not the discipline.** idear makes the safe call the short
+call. It does not remove the engineer at the seams it cannot cover.
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..c360f41
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,24 @@
+module amadan.net/rastrillo/idear
+
+go 1.25.0
+
+require github.com/carlosframework/rastrillo v0.18.1-0.20260823225238-7439afc0d687
+
+require (
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/go-chi/chi/v5 v5.3.2 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/sys v0.46.0 // indirect
+ golang.org/x/text v0.20.0 // indirect
+ gorm.io/gorm v1.31.2 // indirect
+ gorm.io/plugin/dbresolver v1.6.2 // indirect
+ modernc.org/libc v1.74.1 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
+ modernc.org/sqlite v1.55.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..f11315a
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,35 @@
+github.com/carlosframework/rastrillo v0.18.1-0.20260823225238-7439afc0d687 h1:sfD7narHWcha0swHvv3vblvzxhbwgHRI8kI5F4P/YgQ=
+github.com/carlosframework/rastrillo v0.18.1-0.20260823225238-7439afc0d687/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
+github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
+golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
+gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
+gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc=
+gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM=
+modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
+modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
+modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=