rastrillo / idear Public
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 areplacedirective — the publishedgithub.com/carlosframework/rastrilloresolves, 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`WithoutGOPATH/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=1green.- 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.
Roleis never read from a form on any path. - Not-a-member answers exactly like doesn't-exist, byte-identically, via the configured
NotFoundhook.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):
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) errorrank() 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:
- actor must be non-nil and active, else forbidden;
- actor must be at least Admin, else forbidden;
- actor and target must differ (compare
ID) — nobody acts on themselves, including the Owner; - 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 initis already done —go.modexists 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.goandpolicy.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=1green. 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:
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")
// NOTE (ruled 2026-08-24): do NOT export a Models() for apps to consume.
// rastrillo's dump.Compute diffs the app's OWN Schema against the app's OWN
// Models; handing it idear's models against a Schema that has no idear
// migrations makes `migration check` permanently red and makes `generate`
// write a second, colliding CREATE TABLE into the app's migrations. No core
// subsystem (sessions, auth, blobs, passkey) exports one, for this reason.
// If idear's own tests want the model list, keep it unexported.token.go (unexported except where noted):
newToken() (token string, err error)— 32 bytes fromcrypto/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:
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 getsErrOwnerExists. The claimant becomesRoleOwner.Invite:actormust passMayActOn-style authority (at least Admin); refusesRoleOwneroutright, on every path, for every actor. Ownership moves only byTransfer. 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 theMemberinsert. Anything softer letsRevoke` 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;RoleOwneris refused — ownership moves only byTransfer.Deactivate:MayActOnmust pass; the Owner can never be deactivated (ErrLastOwner).Reactivate:MayActOnmust pass. Required, becauseSubjectis 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 aDeactivateyields 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.gofirst —New(t)returning a*Rosterover a temp SQLite file withmigrate.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 callClaimat once; exactly one succeeds, five getErrOwnerExists, and the table holds exactly one row.TestConcurrentTransfersKeepOneOwner— six concurrentTransfercalls; afterwardsSELECT count(*) WHERE role='owner'is exactly 1. Round 1 of the bake-off found precisely this bug in a hand-rolled version.TestRevokeRacingAcceptNeverAdmits— startRevokeandAcceptconcurrently on one invitation, repeated across many iterations; assert that a successfulAcceptand a successfulRevokenever both happen for the same invitation.TestTransferRacingDeactivateNeverStrandsOwner— concurrentTransferto a target andDeactivateof 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:
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:
- roster has zero rows →
RoleOwner(claim); - a valid token from the context — unexpired, unrevoked, unaccepted — whose
Emailequals the submitted address → the invitation's role; OpenSignUp→RoleMember;- 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 foradmin@corp.test; a signup for that address with noinvitefield 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. -TestRequireAnswersNotFoundForNonMemberand...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:
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 setHandler 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
chirouter with a cookie jar and real CSRF, usinginternal/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 postedrole=ownernever 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.RevokeracingAcceptnever admits; 8. an expired invitation is refused and an accepted one cannot be replayed; 9. an orphaned user is healed byPOST /invitations/{token}while signed in, and 404s on every route until they are; 10.TransferracingDeactivatenever 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
CarryTokenis mandatory on the password path; - the security discipline: never bind a form,
rolenever from a form, 404-not-403 for non-members, allow-lists,Refuse's constant-copy rule; - the traps:
Requiremounted outside a session group silently 404s everything;RequireRolemust stack insideRequire; 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.Schemamerges intoBootSchema, never into the app's ownSchema.
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.mdandREADME.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.