rastrillo / idear Public

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

Plain git — no account needed to clone.

Download

Download this file

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):

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 donego.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:

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 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:

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 firstNew(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:

  • TestConcurrentClaimYieldsOneOwnersix goroutines call Claim at once; exactly one succeeds, five get ErrOwnerExists, and the table holds exactly one row.
  • TestConcurrentTransfersKeepOneOwnersix 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:

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. OpenSignUpRoleMember;
  4. otherwise → password.Refuse(...).

Possession of the token is required. An email match alone is NOT enoughpassword.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 addresspassword.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:

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-inpassword.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.