rastrillo / idear Public

Clone
git clone https://amadan.net/rastrillo/idear

Plain git — no account needed to clone.

Download

Download this file

1# idear Implementation Plan
2
3> **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.
4
5**Goal:** Build `idear` — the roster for a Rastrillo instance: who is in it, at what role, and who may change that.
6
7**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.
8
9**Tech Stack:** Go 1.25, GORM, chi, `github.com/carlosframework/rastrillo` (`sessions`, `migrate`, `password`, `form`, `flash`), SQLite via `rastrillo/db`.
10
11**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.
12
13## Global Constraints
14
15- 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.
16- **Build environment (use this verbatim, every command):**
17 ```
18 export GOFLAGS=-mod=mod CGO_ENABLED=0 \
19 GOPATH=/tmp/claude-1001/-home-paulca-github-com-rastrilloorg-rastrillo/a5746945-a081-4474-a890-44a32e59ccf9/scratchpad/go \
20 GOCACHE=/tmp/claude-1001/-home-paulca-github-com-rastrilloorg-rastrillo/a5746945-a081-4474-a890-44a32e59ccf9/scratchpad/gocache
21 ```
22 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.
23- Repo root is the scratchpad `idear/` directory. It is fully writable; you do **not** need to disable any sandbox.
24- `gofmt -l .` must be empty. `go vet ./...` clean. `go test ./... -count=1` green.
25- **Errors are values, and every store mutation is ONE transaction.** An invariant checked outside the transaction that maintains it is not an invariant.
26- **Never bind a form into a struct.** Read named fields explicitly. `Role` is never read from a form on any path.
27- **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.
28- Visitor-facing copy: sentence case, ending in a full stop.
29- Commit after each task. Do not push; do not create a PR.
30
31## Ruling that deviates from the spec, already decided
32
33The 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.
34
35---
36
37### Task 1: Roles and policy — pure, no database, no HTTP
38
39**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`.
40
41**Interfaces produced** (later tasks depend on these exact names):
42
43```go
44type Role string
45
46const (
47 RoleOwner Role = "owner"
48 RoleAdmin Role = "admin"
49 RoleMember Role = "member"
50)
51
52func (r Role) Valid() bool
53func (r Role) AtLeast(min Role) bool
54func (r Role) Title() string // "Owner" / "Admin" / "Member"; "" for invalid
55func ParseRole(s string) (Role, bool) // accepts ONLY the three known roles
56
57var ErrForbidden = errors.New("idear: forbidden")
58
59// MayActOn reports whether actor may manage target, returning
60// ErrForbidden (wrapped with the reason) when it may not.
61func MayActOn(actor, target *Member) error
62```
63
64`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.
65
66**`MayActOn` rules, all four required:**
671. actor must be non-nil and active, else forbidden;
682. actor must be at least Admin, else forbidden;
693. actor and target must differ (compare `ID`) — **nobody acts on themselves**, including the Owner;
704. target's rank must be **strictly below** actor's — so Admin manages Member only, Owner manages Admin and Member, and nobody manages an Owner.
71
72`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.
73
74- [ ] **Step 1: `go mod init` is already done** — `go.mod` exists with the module path and requirements. Do not recreate it.
75
76- [ ] **Step 2: Write the failing tests.**
77
78`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.
79
80`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.
81
82- [ ] **Step 3: Run them, confirm they fail** for undefined symbols.
83
84- [ ] **Step 4: Implement `role.go` and `policy.go`.**
85
86- [ ] **Step 5: Run tests, confirm green.**
87
88- [ ] **Step 6: Add the build plumbing.**
89
90`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.
91
92- [ ] **Step 7: `gofmt -l .` empty, `go vet ./...`, `go test ./... -count=1` green. Commit.**
93
94---
95
96### Task 2: Models, migrations, and the token discipline
97
98**Files:** Modify `member.go`. Create `migrations/0001_init.sql`, `schema.go`, `token.go`, `member_test.go`, `token_test.go`, `schema_test.go`.
99
100**Interfaces produced:**
101
102```go
103type Member struct {
104 ID int64
105 Subject string `gorm:"uniqueIndex"`
106 Email string `gorm:"index"`
107 Name string
108 Role Role `gorm:"not null;index"`
109 DeactivatedAt *time.Time
110 CreatedAt time.Time
111 UpdatedAt time.Time
112}
113
114func (m *Member) Active() bool // non-nil AND DeactivatedAt == nil
115
116type Invitation struct {
117 ID int64
118 Email string `gorm:"index"`
119 Role Role `gorm:"not null"`
120 TokenHash string `gorm:"uniqueIndex"`
121 InvitedBy int64
122 CreatedAt time.Time
123 ExpiresAt time.Time
124 AcceptedAt *time.Time
125 RevokedAt *time.Time
126}
127
128func (i *Invitation) Pending(now time.Time) bool // not accepted, not revoked, not expired
129
130// Schema is the package's migration set, merged into the app's BootSchema.
131var Schema = migrate.MustFromFS(migrationFS, "idear")
132
133// NOTE (ruled 2026-08-24): do NOT export a Models() for apps to consume.
134// rastrillo's dump.Compute diffs the app's OWN Schema against the app's OWN
135// Models; handing it idear's models against a Schema that has no idear
136// migrations makes `migration check` permanently red and makes `generate`
137// write a second, colliding CREATE TABLE into the app's migrations. No core
138// subsystem (sessions, auth, blobs, passkey) exports one, for this reason.
139// If idear's own tests want the model list, keep it unexported.
140```
141
142`token.go` (unexported except where noted):
143- `newToken() (token string, err error)` — **32 bytes** from `crypto/rand`, hex-encoded.
144- `hashToken(token string) string` — SHA-256, hex-encoded, lowercase.
145
146The 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.
147
148- [ ] **Step 1: Write the failing tests.**
149
150`token_test.go`: two `newToken()` calls differ; output is 64 hex characters; `hashToken` is stable, lowercase, 64 hex characters, and differs from its input.
151
152`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.
153
154`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.
155
156- [ ] **Step 2: Run them, confirm they fail.**
157
158- [ ] **Step 3: Write `migrations/0001_init.sql`.**
159
160Table 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.
161
162- [ ] **Step 4: Implement, embedding the migrations with `//go:embed migrations`.**
163
164- [ ] **Step 5: Green. Commit.**
165
166---
167
168### Task 3: The roster store — every invariant, in a transaction
169
170**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`).
171
172**Interfaces produced:**
173
174```go
175type Config struct {
176 DB *gorm.DB
177 OpenSignUp bool
178 InviteTTL time.Duration // default 7 * 24h
179 Subject func(*http.Request) (string, bool) // default: sessions.Current(r).Subject
180 NotFound func(http.ResponseWriter, *http.Request) // default http.NotFound
181 Forbidden func(http.ResponseWriter, *http.Request) // default 403 + plain text
182 Logger *slog.Logger
183}
184
185type Roster struct{ /* unexported */ }
186
187func New(cfg Config) (*Roster, error) // errors when DB is nil
188
189var (
190 ErrOwnerExists = errors.New("idear: this instance already has an owner")
191 ErrNoInvitation = errors.New("idear: no valid invitation for that address")
192 ErrNotFound = errors.New("idear: no such member")
193 ErrLastOwner = errors.New("idear: the owner cannot be removed")
194)
195
196func (rs *Roster) IsEmpty(ctx context.Context) (bool, error) // ZERO ROWS, not zero ACTIVE rows
197func (rs *Roster) Claim(ctx context.Context, subject, email, name string) (*Member, error)
198func (rs *Roster) Invite(ctx context.Context, actor *Member, email string, role Role) (inv *Invitation, token string, err error)
199func (rs *Roster) Revoke(ctx context.Context, actor *Member, id int64) error
200func (rs *Roster) Accept(ctx context.Context, token, subject, name string) (*Member, error)
201func (rs *Roster) SetRole(ctx context.Context, actor, target *Member, role Role) error
202func (rs *Roster) Deactivate(ctx context.Context, actor, target *Member) error
203func (rs *Roster) Reactivate(ctx context.Context, actor, target *Member) error
204func (rs *Roster) Transfer(ctx context.Context, owner, to *Member) error
205func (rs *Roster) BySubject(ctx context.Context, subject string) (*Member, error)
206func (rs *Roster) ByID(ctx context.Context, id int64) (*Member, error)
207func (rs *Roster) Members(ctx context.Context) ([]Member, error)
208func (rs *Roster) PendingInvitations(ctx context.Context) ([]Invitation, error)
209```
210
211**The invariants — each enforced INSIDE its own transaction:**
212
213- **`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`.
214- **`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`.
215- **`Accept`**: consumes by **compare-and-swap**, not lookup-then-write:
216 ```sql
217 UPDATE idear_invitations SET accepted_at = ?
218 WHERE id = ? AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > ?
219 ```
220 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.
221- **`SetRole`**: `MayActOn(actor, target)` must pass; the new role must parse; **`RoleOwner` is refused** — ownership moves only by `Transfer`.
222- **`Deactivate`**: `MayActOn` must pass; **the Owner can never be deactivated** (`ErrLastOwner`).
223- **`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.
224- **`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.
225
226- [ ] **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.
227
228- [ ] **Step 2: Write the failing tests**, one per invariant above, asserting the error values by `errors.Is`.
229
230Plus `race_test.go`, which is the point of this task and must use real concurrency, not sequential calls:
231- `TestConcurrentClaimYieldsOneOwner` — **six** goroutines call `Claim` at once; exactly one succeeds, five get `ErrOwnerExists`, and the table holds exactly one row.
232- `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.
233- `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.
234- `TestTransferRacingDeactivateNeverStrandsOwner` — concurrent `Transfer` to a target and `Deactivate` of that same target; assert the resulting Owner is always active.
235
236Run the race tests with `-race`. Note that `-race` needs cgo, so use `CGO_ENABLED=1` **for that one command only**.
237
238- [ ] **Step 3: Confirm they fail. Step 4: Implement. Step 5: Green, including `-race`. Commit.**
239
240---
241
242### Task 4: Middleware and the two identity adapters
243
244**Files:** Create `middleware.go`, `admit.go`, `middleware_test.go`, `admit_test.go`.
245
246**Interfaces produced:**
247
248```go
249func (rs *Roster) Require(next http.Handler) http.Handler
250func (rs *Roster) RequireRole(min Role) func(http.Handler) http.Handler
251func From(r *http.Request) *Member // nil when absent
252func WithMember(r *http.Request, m *Member) *http.Request
253
254func (rs *Roster) CarryToken(next http.Handler) http.Handler
255func (rs *Roster) Authorize(address string) bool
256func (rs *Roster) Admitting(create func(ctx context.Context, email, hash string) (int64, error)) func(ctx context.Context, email, hash string) (int64, error)
257```
258
259**`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.
260
261**`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.
262
263**`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.
264
265**`Admitting`** decides the role **before reading anything else the form says**, in this order:
2661. roster has zero rows → `RoleOwner` (claim);
2672. a **valid token** from the context — unexpired, unrevoked, unaccepted — **whose `Email` equals the submitted address** → the invitation's role;
2683. `OpenSignUp` → `RoleMember`;
2694. otherwise → `password.Refuse(...)`.
270
271**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.
272
273The 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.
274
275Then: 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.
276
277On `ErrOwnerExists` from a racing claim, return `password.Refuse(...)` with the same constant copy.
278
279**`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.
280
281- [ ] **Step 1: Write the failing tests.** Required, at minimum:
282 - `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.
283 - `TestAdmittingRejectsMismatchedEmail` — a valid token, but a different submitted address, is refused.
284 - `TestAdmittingRejectsExpiredAndRevokedTokens` — two cases, separately.
285 - `TestAdmittingClaimsFirstAccountAsOwner`.
286 - `TestAdmittingOpenSignUpJoinsAsMember`.
287 - `TestAdmittingRefusalUsesConstantCopy` — refuse two different addresses, assert the two messages are byte-identical and neither contains either address.
288 - `TestRequireAnswersNotFoundForNonMember` and `...ForDeactivatedMember`.
289 - `TestRequireRoleForbidsBelowMinimum` (403, not 404).
290 - `TestAuthorizeAdmitsInvitedAddressOnce` — second call for the same consumed invitation is false.
291
292- [ ] **Step 2: Confirm they fail. Step 3: Implement. Step 4: Green. Commit.**
293
294---
295
296### Task 5: HTTP handlers, including the reconciliation route
297
298**Files:** Create `handlers.go`, `handlers_test.go`.
299
300**Interfaces produced:**
301
302```go
303type HandlerConfig struct {
304 Roster *Roster
305 RenderMembers func(w http.ResponseWriter, r *http.Request, d MembersPage)
306 RenderInvitation func(w http.ResponseWriter, r *http.Request, d InvitationPage)
307}
308
309type MembersPage struct {
310 Viewer *Member
311 Members []Member
312 Invitations []Invitation
313 Error string
314 Notice string
315}
316
317type InvitationPage struct {
318 Role Role
319 Site string
320 Token string
321 Error string
322 SignedIn bool
323 Reconcile bool // the viewer is signed in with no member row
324}
325
326type Handlers struct{ /* unexported */ }
327
328func NewHandlers(cfg HandlerConfig) (*Handlers, error) // errors unless Roster and BOTH renderers are set
329```
330
331Handler methods, mounted by the app at these default paths:
332
333```
334GET /members Handlers.Members (Require)
335POST /members/invitations Handlers.Invite (RequireRole admin)
336POST /members/invitations/{id}/revoke Handlers.Revoke (RequireRole admin)
337POST /members/{id}/role Handlers.SetRole (RequireRole admin)
338POST /members/{id}/remove Handlers.Remove (RequireRole admin)
339POST /members/{id}/restore Handlers.Restore (RequireRole admin)
340POST /members/transfer Handlers.Transfer (RequireRole owner)
341GET /invitations/{token} Handlers.Invitation (public)
342POST /invitations/{token} Handlers.Accept (public, signed-in reconciliation)
343```
344
345**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.
346
347**`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.
348
349Mutations 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.
350
351- [ ] **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:
352 1. a non-member is refused **read and write on every route**, with byte-identical 404s, including deeply nested ids;
353 2. a Member is refused every management action;
354 3. an Admin cannot change, demote, deactivate or restore an Admin or the Owner;
355 4. a posted `role=owner` never lands, on any path, for any actor — assert the database row, not just the response;
356 5. the single-owner invariant holds across concurrent transfers (already covered in Task 3; assert it through HTTP here too);
357 6. an invited address cannot be claimed without the token;
358 7. `Revoke` racing `Accept` never admits;
359 8. an expired invitation is refused and an accepted one cannot be replayed;
360 9. an orphaned user is healed by `POST /invitations/{token}` while signed in, and 404s on every route until they are;
361 10. `Transfer` racing `Deactivate` never yields a deactivated Owner;
362 11. a deactivated member can be reactivated and regains exactly their prior access and no more;
363 12. `GET /invitations/{token}` does not disclose the invited address.
364
365**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.
366
367- [ ] **Step 2: Confirm they fail. Step 3: Implement. Step 4: Green. Commit.**
368
369---
370
371### Task 6: The example app, `SKILL.md`, and `README.md`
372
373**Files:** Create `example/main.go`, `example/models.go`, `example/pages/*.html`, `example/README.md`, `SKILL.md`, `README.md`.
374
375**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.
376
377It must seed at least two accounts at different roles so the role gates are something you can click on.
378
379**`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:
380- the one-line install (`go get amadan.net/rastrillo/idear`), with **no replace directive**;
381- `New`, `NewHandlers`, and the exact route table;
382- the two identity adapters, and that `CarryToken` is **mandatory** on the password path;
383- 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;
384- 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 `/`;
385- **one identity plugin per app** — mounting both password and keymail gives one human two subjects and two roster rows whose roles drift apart;
386- Owner break-glass: a lost Owner credential otherwise means a permanently unadministrable instance. Document the recovery (the SQL, plainly).
387- that `idear.Schema` merges into `BootSchema`, never into the app's own `Schema`.
388
389**`README.md`** is short: what idear is, what it is not, the install line, and a pointer to `SKILL.md`.
390
391- [ ] **Step 1: Write the example and get it building and serving.**
392- [ ] **Step 2: Write `SKILL.md` and `README.md`.**
393- [ ] **Step 3: Full gate green. Commit.**
394
395---
396
397## Self-Review
398
399**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.
400
401**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.
402
403**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`.
404