| 1 | # π€ idear: roles and membership as an addon |
| 2 | |
| 3 | Design, 2026-08-23. Approved by Paul the same evening. |
| 4 | |
| 5 | A second CARLOS authoring bake-off graded five stacks building the same |
| 6 | team Kanban app. Rastrillo and `carrillo-chassis` **tied on the rubric** β |
| 7 | 49/50 each, identical on all four axes, both a perfect 20/20 on the |
| 8 | weighted security axis with zero confirmed defects. The chassis was |
| 9 | ranked first on a metric the rubric records but does not score: builder |
| 10 | tokens, 165,944 against Rastrillo's 216,012. |
| 11 | |
| 12 | The gap has one cause, and it is not framework friction. Rastrillo's |
| 13 | builder was *more* efficient per line β 61 output tokens per written line |
| 14 | against the chassis's 95. It simply had to write 1,781 more of them: |
| 15 | 3,537 written lines against 1,756. That excess is almost exactly the |
| 16 | accounts-roles-invitations layer the chassis inherits and Rastrillo has |
| 17 | no answer for. The framework contributed nothing to the surface the |
| 18 | bake-off grades hardest, because it has no role concept: `scope.Owned` |
| 19 | is a per-user `WHERE user_id = ?`, and a team-global Kanban board is not |
| 20 | an owned row. |
| 21 | |
| 22 | This builds that layer β **outside the framework**. |
| 23 | |
| 24 | ## 1. Why an addon, and not core |
| 25 | |
| 26 | The obvious response to the bake-off is `rastrillo/team`. It is the wrong |
| 27 | one. |
| 28 | |
| 29 | Two arguments for it are weak and worth discarding before they get |
| 30 | repeated. The `SKILL.md` budget is one: the ceiling has already been |
| 31 | raised twice (15k β 16k β 17k), and an addon still costs the same ~330-byte |
| 32 | pointer a core package's `Full treatment:` line would. "Not every app has |
| 33 | members" is the other β it applies equally to `passkey`, `jobs` and |
| 34 | manifests, all of which are core. |
| 35 | |
| 36 | The real reasons are narrower and hold up. **Release cadence:** roles |
| 37 | will churn (reactivation, expiry policy, break-glass) at a rhythm that |
| 38 | should not drag the framework's tag along, and a v0.x framework already |
| 39 | asks enough of its consumers. **Gate honesty:** |
| 40 | `internal/docsite/symbols_test.go` requires every core package to carry a |
| 41 | reference page naming every exported symbol; that gate is valuable |
| 42 | exactly because it is expensive, and it should be spent on what every app |
| 43 | mounts. |
| 44 | |
| 45 | What the split genuinely costs, stated so nobody rediscovers it later: |
| 46 | MVS version skew (idear pins a rastrillo; a `sessions` or `migrate` |
| 47 | change can block an app's upgrade until idear catches up), a second |
| 48 | frozen-checksum and CI regime, and the vanity-path risk of Β§9. Those are |
| 49 | accepted, not waved off. |
| 50 | |
| 51 | So idear ships as its own module, versioned separately, and the |
| 52 | dependency arrow points one way: **idear imports rastrillo; rastrillo |
| 53 | never imports idear.** Inside this repo the change is a documentation |
| 54 | page, its nav entry, a pointer in `SKILL.md`, and one small seam in |
| 55 | `password` that Β§5 forces and Β§6 pays for. |
| 56 | |
| 57 | ### The tenancy ruling survives intact |
| 58 | |
| 59 | The 2026-08-22 ruling β instance-per-team, team tenancy is APP-level, no |
| 60 | membership package β was about *cross-team isolation*, and it stands |
| 61 | unchanged. `carrillo-chassis` agrees with it completely; its own skill |
| 62 | doc opens "the instance IS the tenant⦠there is no tenant field, no |
| 63 | tenant scope." |
| 64 | |
| 65 | Owner > Admin > Member *inside* one instance is a different question, and |
| 66 | the ruling never answered it. idear is the intra-instance half. Nothing |
| 67 | here reintroduces a tenant column, a tenant scope, or `/t/{slug}` |
| 68 | routing. |
| 69 | |
| 70 | ## 2. The seam this fills already exists |
| 71 | |
| 72 | `auth.Config.Authorize` is documented, in the framework, today: |
| 73 | |
| 74 | > Authorize is the admission gate: given a verified address, may it have |
| 75 | > a session? Nil admits every verified address. **Membership models |
| 76 | > (tables, roles, admin bootstrap) are app policy layered on this hook.** |
| 77 | |
| 78 | That hook was left open for precisely this. idear fills it rather than |
| 79 | inventing a parallel one β which is the difference between an addon and |
| 80 | a fork. |
| 81 | |
| 82 | ## 3. What idear is |
| 83 | |
| 84 | The **roster** for a Rastrillo instance: who is in it, at what role, and |
| 85 | who may change that. |
| 86 | |
| 87 | It is not an identity provider. It never mints a session, never hashes a |
| 88 | password, never renders a sign-in form. It sits on top of `sessions` and |
| 89 | whichever identity plugin the app already chose, so an app using keymail |
| 90 | or passkeys keeps them. |
| 91 | |
| 92 | Module path `amadan.net/rastrillo/idear`, repository |
| 93 | `https://amadan.net/rastrillo/idear`. It depends on |
| 94 | `rastrillo/{sessions,migrate,password,flash}` and `gorm.io/gorm`. It does |
| 95 | **not** depend on `rastrillo/ui`: the example styles its pages, the |
| 96 | library does not own a look. |
| 97 | |
| 98 | **Amended 2026-08-24 (review finding F3).** The list above was wrong in |
| 99 | both directions: it named `rastrillo/form`, which nothing in this |
| 100 | package imports (`grep -rl 'rastrillo/form' *.go` is empty), and it |
| 101 | omitted `rastrillo/password`, which `admit.go` hard-imports for |
| 102 | `password.Refuse` β idear's refusal sentinel (Β§5's "Refusals need a |
| 103 | channel"). That import is unconditional: a **keymail-only** app still |
| 104 | links `rastrillo/password`, even though nothing on the keymail path |
| 105 | calls it. Splitting the sentinel into its own subpackage so a |
| 106 | keymail-only app could drop the dependency is a **v2 consideration**, |
| 107 | not a v1 promise β not done here. |
| 108 | |
| 109 | ## 4. The data model |
| 110 | |
| 111 | Two tables and one migration set, following the `sessions` / `auth` / |
| 112 | `blobs` convention exactly. |
| 113 | |
| 114 | ```go |
| 115 | type Member struct { |
| 116 | ID int64 |
| 117 | Subject string `gorm:"uniqueIndex"` |
| 118 | Email string `gorm:"index"` |
| 119 | Name string |
| 120 | Role Role `gorm:"not null;index"` |
| 121 | DeactivatedAt *time.Time |
| 122 | CreatedAt time.Time |
| 123 | UpdatedAt time.Time |
| 124 | } |
| 125 | |
| 126 | type Invitation struct { |
| 127 | ID int64 |
| 128 | Email string `gorm:"index"` |
| 129 | Role Role `gorm:"not null"` |
| 130 | TokenHash string `gorm:"uniqueIndex"` |
| 131 | InvitedBy int64 |
| 132 | CreatedAt time.Time |
| 133 | ExpiresAt time.Time |
| 134 | AcceptedAt *time.Time |
| 135 | RevokedAt *time.Time |
| 136 | } |
| 137 | |
| 138 | var Schema = migrate.MustFromFS(migrationFS, "idear") |
| 139 | ``` |
| 140 | |
| 141 | Four decisions worth stating, because each has an alternative that looks |
| 142 | better until you try it. |
| 143 | |
| 144 | **`Subject` is a string, and it is the join.** idear does not own email |
| 145 | and password β the app keeps its own `User` row. The key between them is |
| 146 | the session `Subject`, because it is the only identifier both identity |
| 147 | plugins produce: the password plugin's Subject is a numeric user id, |
| 148 | keymail's is a verified email address. `sessions.UserID` returns |
| 149 | `(0, false)` under keymail, and a membership layer keyed on it would |
| 150 | resolve every keymail viewer to member zero. `jobs` already set this |
| 151 | precedent β its owner is the session Subject, for the same reason. |
| 152 | |
| 153 | **The token is hashed at rest, and it expires.** `TokenHash` stores a |
| 154 | SHA-256 digest; the plaintext token exists only in the emitted link. |
| 155 | `sessions` already holds nothing but `HashToken` digests, and an addon has |
| 156 | no business being laxer than the core it rides on. `ExpiresAt` defaults to |
| 157 | seven days. Round 1 of the bake-off penalised an arm for immortal, |
| 158 | anonymously-fetchable invitation tokens that disclosed team and email; |
| 159 | this is that finding, applied before it is earned a second time. Tokens |
| 160 | are 32 bytes of `crypto/rand`, and `GET /invitations/{token}` does not |
| 161 | echo the full invited address. |
| 162 | |
| 163 | **Removal is `DeactivatedAt`, never a delete.** A deleted row dangles |
| 164 | every `AuthorID` in the app's own tables. The corollary is that |
| 165 | reactivation must exist as a first-class operation (Β§5) β `Subject` is |
| 166 | unique, so without it a removed person can never be readmitted by any |
| 167 | path: keymail's `Authorize` sees the inactive row and refuses, password |
| 168 | re-signup hits the app's duplicate-email check, and a fresh invitation's |
| 169 | Member insert collides with the dead row. |
| 170 | |
| 171 | **Apps merge `idear.Schema` into `BootSchema`, never into `Schema`** β |
| 172 | `migrate.Merge(sessions.Schema, idear.Schema, Schema)`. Merging it into |
| 173 | the app's own `Schema` makes `rastrillo migration check` propose dropping |
| 174 | tables that `Models` does not know about. This is the documented rule for |
| 175 | every subsystem and idear is not an exception to it. |
| 176 | |
| 177 | ## 5. The API |
| 178 | |
| 179 | Mirrors `jobs`: a core built at boot, then handlers that error unless |
| 180 | their renderers are set. |
| 181 | |
| 182 | ```go |
| 183 | r, err := idear.New(idear.Config{DB: d.G, OpenSignUp: false}) |
| 184 | h, err := idear.NewHandlers(idear.HandlerConfig{ |
| 185 | Roster: r, |
| 186 | RenderMembers: renderMembers, |
| 187 | RenderInvitation: renderInvitation, |
| 188 | NotFound: renderNotFound, |
| 189 | }) |
| 190 | ``` |
| 191 | |
| 192 | `Config.Subject` defaults to reading `sessions.Current(r).Subject` and |
| 193 | exists as an override for an app whose viewer arrives another way. |
| 194 | |
| 195 | ### Middleware β the membership gate |
| 196 | |
| 197 | - `r.Require` β signed in *and* an active member. A non-member is |
| 198 | answered by `HandlerConfig.NotFound`, which **must be the same renderer |
| 199 | the app gives chi's own `NotFound`**. "Byte-identical 404" is otherwise |
| 200 | unimplementable: an app with a custom 404 page makes idear's default |
| 201 | `http.NotFound` distinguishable, and that delta *is* the membership |
| 202 | oracle `SKILL.md` Β§3 forbids. The default is `http.NotFound`; an app |
| 203 | with its own 404 page and no `NotFound` hook is the one misconfiguration |
| 204 | idear cannot detect, so the skill doc leads with it. |
| 205 | - `r.RequireRole(min)` β **403**, because a member can legitimately see |
| 206 | the page and merely may not act. It **stacks inside** `Require`; mounted |
| 207 | bare it would 403 a non-member and break the 404 rule. |
| 208 | - `Config.Subject` reads a session the caller must already have resolved: |
| 209 | mount `Require` *inside* a `sess.Require` (or `auth.RequireSession`) |
| 210 | group. Mounted outside one, the Subject is empty and every request 404s |
| 211 | β silently, and identically to a real refusal. Signed-out requests are |
| 212 | the upstream middleware's business; idear does not redirect. |
| 213 | - `idear.From(req) *Member` β the viewer, following `auth.From`. |
| 214 | |
| 215 | ### Policy, as pure functions |
| 216 | |
| 217 | `Role.AtLeast`, `ParseRole` (which accepts only the three known roles β |
| 218 | anything else is not a role, not a default), and: |
| 219 | |
| 220 | ```go |
| 221 | func MayActOn(actor, target *Member) error |
| 222 | ``` |
| 223 | |
| 224 | Admins manage Members only; nobody acts on themselves; the target's rank |
| 225 | must be strictly below the actor's. Keeping this a pure function is what |
| 226 | lets the role matrix be tested exhaustively without an HTTP server. |
| 227 | |
| 228 | ### Store operations |
| 229 | |
| 230 | Each is one transaction and each enforces its own invariant, because an |
| 231 | invariant checked outside the transaction that maintains it is not an |
| 232 | invariant: |
| 233 | |
| 234 | `Claim` Β· `Invite` Β· `Revoke` Β· `Accept` Β· `SetRole` Β· `Deactivate` Β· |
| 235 | `Reactivate` Β· `Transfer`. |
| 236 | |
| 237 | Three carry invariants that a naive implementation loses: |
| 238 | |
| 239 | **`Accept` consumes the invitation by CAS, not by lookup.** The update is |
| 240 | `SET accepted_at = ? WHERE id = ? AND accepted_at IS NULL AND revoked_at |
| 241 | IS NULL AND expires_at > ?`, with rows-affected checked, **inside** the |
| 242 | same transaction as the Member write. Anything softer lets `Revoke` race |
| 243 | acceptance and admits a revoked invitation. Single-use cannot be delegated |
| 244 | to the app's unique-email index β idear can neither see nor enforce that |
| 245 | index, so the CAS is the invariant. |
| 246 | |
| 247 | **`Transfer` checks the target is active inside its own transaction.** |
| 248 | Otherwise a transfer racing a `Deactivate` of the same target produces a |
| 249 | deactivated Owner: an instance with no one able to administer it and no |
| 250 | one able to be promoted. |
| 251 | |
| 252 | **`Claim` means zero rows, not zero active rows.** A roster whose members |
| 253 | have all been deactivated must not reopen the claim β that would hand a |
| 254 | stranger Owner of an instance full of dormant data. |
| 255 | |
| 256 | ### Admission: the token is the credential |
| 257 | |
| 258 | This is the part the first draft got wrong, and it was wrong in the one |
| 259 | place that mattered. |
| 260 | |
| 261 | The rule is **possession of the invitation token, plus an email match** β |
| 262 | not an email match alone. The chassis resolves the invitation *from the |
| 263 | token* and then checks `inv.Email == email` |
| 264 | (`carrillo-chassis/handlers_auth.go:115-129`). Email-match alone is |
| 265 | exploitable under the password plugin, which never verifies an address: |
| 266 | anyone who learns that `admin@corp.com` was invited registers that address |
| 267 | with their own password first and lands at the invited role. |
| 268 | |
| 269 | `password.Config.Create` is `func(ctx, email, hash) (int64, error)` β it |
| 270 | receives no `*http.Request`, so the token cannot be read from the form |
| 271 | inside it. It does receive `r.Context()`. So idear supplies a middleware: |
| 272 | |
| 273 | ```go |
| 274 | r.Post("/signup", roster.CarryToken(ph.Signup)) |
| 275 | ``` |
| 276 | |
| 277 | `CarryToken` is a **method on the Roster** (it logs a form-parse failure through `Config.Logger`, which a package function could not reach). It reads the `invite` field from the posted form and stashes it |
| 278 | in the request context; `Admitting` reads it back. Mounting it is |
| 279 | **mandatory** on the password path β without it every invited signup is |
| 280 | refused, which is loud and safe rather than quiet and permissive. |
| 281 | |
| 282 | Admission then decides the role **before reading anything else the form |
| 283 | says**, in this order: **claim** if the roster has zero rows; otherwise a |
| 284 | **valid token** β unexpired, unrevoked, unaccepted β whose `Email` equals |
| 285 | the submitted address; otherwise `OpenSignUp` at `RoleMember`; otherwise |
| 286 | refuse. The role is never read from the form on any path. |
| 287 | |
| 288 | ### Reconciliation: the signed-in half of the invitation routes |
| 289 | |
| 290 | `Admitting` cannot enlist the app's opaque `Create` into its transaction, |
| 291 | so a failure between "app user created" and "Member written" leaves a |
| 292 | user row with no membership. The first draft called this an honest edge |
| 293 | and claimed a retry heals it. **It does not:** the retry's `Create` fails |
| 294 | on the now-duplicate email, and `password.Signup` renders that as 422 |
| 295 | without ever reaching the Member write. The orphan can sign *in* and 404s |
| 296 | forever. |
| 297 | |
| 298 | The same terminal state is reachable with no failure at all. Two |
| 299 | concurrent first signups both observe an empty roster; one wins the |
| 300 | `Claim`, and the `ErrOwnerExists` loser is an orphan. |
| 301 | |
| 302 | So reconciliation is a designed path, not a footnote, and it is what the |
| 303 | public invitation routes are *for*: |
| 304 | |
| 305 | - `GET /invitations/{token}` β renders the invitation to anyone holding a |
| 306 | valid token, naming the instance and the role but not the full address. |
| 307 | - `POST /invitations/{token}` β **for a signed-in viewer with no Member |
| 308 | row**: given a valid token matching their session, or a roster with zero |
| 309 | rows, it writes the Member from the live session `Subject`. This heals |
| 310 | the orphan and is the only path by which a Member row is created from |
| 311 | an already-live session. It resolves the `Claim`-race loser too, but |
| 312 | only *after* someone invites them: the route needs a valid token, and |
| 313 | a claim-race loser holds none. They can still sign in β their app user |
| 314 | row exists β so once invited they redeem here rather than through |
| 315 | sign-up, which would fail on the duplicate email. |
| 316 | |
| 317 | **Amended 2026-08-24 (review finding F1).** "a valid token matching their |
| 318 | session" was only ever enforceable where idear knows the viewer's address, |
| 319 | which is keymail β there the Subject *is* the verified address. Under |
| 320 | password the Subject is an opaque user id, so the implementation matched |
| 321 | nothing and the code comment's claim of parity with admission was false. |
| 322 | `Config.EmailForSubject` is the optional hook that lets the app resolve its |
| 323 | own id, and with it set the match is made; left nil, reconciliation under |
| 324 | password trusts possession of the token alone, which SKILL.md Β§3 and Β§5 now |
| 325 | say in as many words. |
| 326 | |
| 327 | Both public routes are rate-limited; `GET` is an unauthenticated lookup |
| 328 | of a secret and must not be a free oracle. |
| 329 | |
| 330 | ### The two identity adapters |
| 331 | |
| 332 | ```go |
| 333 | // keymail β fills the hook auth already documents as the app's |
| 334 | auth.New(auth.Config{Sessions: sess, Authorize: r.Authorize}) |
| 335 | |
| 336 | // password β wraps the app's own Create |
| 337 | password.New(password.Config{Sessions: sess, Create: r.Admitting(createUser(d.G))}) |
| 338 | ``` |
| 339 | |
| 340 | Two asymmetries between them must be documented rather than smoothed |
| 341 | over, because a reader will otherwise assume the guarantee is uniform: |
| 342 | |
| 343 | **Refusals need a channel.** `password.Config.Create`'s contract is that |
| 344 | *any* error means duplicate-email, so an uninvited visitor would be told |
| 345 | their address is already registered β which is simply false. This design |
| 346 | therefore adds `password.ErrRefused` to the **core** package: `Signup` |
| 347 | checks `errors.Is(err, password.ErrRefused)` and renders the refusal's |
| 348 | own message at 403. That makes Β§6 more than the three files the first |
| 349 | draft promised, and the seam is worth it β anything gating signup needs |
| 350 | it. |
| 351 | |
| 352 | Be honest about the direction of the enumeration argument, because the |
| 353 | tempting version of it is backwards. The old behaviour was *less* |
| 354 | distinguishable, not more: an invite-only app answered 422 "already |
| 355 | registered" both to a registered address and to an uninvited |
| 356 | unregistered one, and a prober could not tell them apart. The 403 |
| 357 | **creates** that existence bit; it does not close one. The |
| 358 | justification is that the duplicate message is false, and that a true |
| 359 | answer someone can distinguish is worth more than a false answer they |
| 360 | cannot. idear's refusal copy must therefore be one string for every |
| 361 | refused address, never interpolating the address, or the 403 becomes a |
| 362 | finer oracle than the outcome alone. Logging which address was refused |
| 363 | is idear's job β the framework deliberately does not log refusals. |
| 364 | |
| 365 | **Deactivation is enforced per request, not at sign-in β under password.** |
| 366 | `password.Signin` runs Lookup β Verify β mint with no idear involvement; |
| 367 | only keymail's admission consults `Authorize`. So a deactivated member or |
| 368 | an orphan can still *mint a session* under the password plugin; what stops |
| 369 | them is `Require` on every route. The example must therefore gate `/` |
| 370 | itself, and the skill doc must say that an ungated landing page is the |
| 371 | one place this design leaks. The chassis refuses at sign-in because it |
| 372 | owns the credential check; idear does not, and should not claim to. |
| 373 | |
| 374 | **`Authorize` returns a bool with no error channel** (`func(address |
| 375 | string) bool`, verified in `auth/auth.go`). A database failure during |
| 376 | admission is therefore indistinguishable from a policy denial to the |
| 377 | visitor. idear logs the distinction even though it cannot render it. |
| 378 | `Authorize` also runs *before* `SecondFactor`, so a Member row can be |
| 379 | written for a sign-in that a 2FA gate never completes β self-healing on |
| 380 | the next attempt, and stated so nobody reads it as a bug. |
| 381 | |
| 382 | ### Handlers |
| 383 | |
| 384 | Paths belong to the app; these are the defaults the example mounts. |
| 385 | |
| 386 | ``` |
| 387 | GET /members |
| 388 | POST /members/invitations |
| 389 | POST /members/invitations/{id}/revoke |
| 390 | POST /members/{id}/role |
| 391 | POST /members/{id}/remove |
| 392 | POST /members/{id}/restore |
| 393 | POST /members/transfer |
| 394 | GET /invitations/{token} (public) |
| 395 | POST /invitations/{token} (public, signed-in reconciliation) |
| 396 | ``` |
| 397 | |
| 398 | Rendering goes through callbacks the app supplies, following |
| 399 | `password.Config.RenderSignin`. idear owns the flows, where the role |
| 400 | rules actually get enforced; the app owns its shell. |
| 401 | |
| 402 | ## 6. Repository shape |
| 403 | |
| 404 | ``` |
| 405 | role.go Role, AtLeast, ParseRole, Title |
| 406 | member.go Member, Invitation |
| 407 | roster.go Config, New, the store operations |
| 408 | policy.go MayActOn β pure, no net/http |
| 409 | middleware.go Require, RequireRole, From |
| 410 | admit.go Authorize, Admitting |
| 411 | handlers.go NewHandlers, the eight handlers, the PageData types |
| 412 | migrations/0001_init.sql |
| 413 | example/ a working app on rastrillo + idear |
| 414 | SKILL.md idear's own authoring doc |
| 415 | Makefile, .amadan/ci, .amadan/ci.d/ |
| 416 | ``` |
| 417 | |
| 418 | idear ships its **own `SKILL.md`** on the same contract as Rastrillo's β |
| 419 | an agent loads it instead of the source. That mechanism, not the code, is |
| 420 | what made the chassis arm cheap: a 361-line skill doc carrying a |
| 421 | ~3,110-line platform layer the builder mostly did not have to read. (Not |
| 422 | "never read" β round 2 records the carrillo builder's context as including |
| 423 | "the chassis Go studied to use it." The saving is real and it is partial.) |
| 424 | |
| 425 | ### How an agent gets it |
| 426 | |
| 427 | This is the part that decides whether the addon thesis holds at all, and |
| 428 | it does not come for free the way the framework's does. Rastrillo's |
| 429 | `SKILL.md` sits at the repo root the scaffold points to. idear's would |
| 430 | land in a versioned module-cache directory nobody names, and |
| 431 | `docs/addons` is a directory page, not a skill. |
| 432 | |
| 433 | So idear's `SKILL.md` is **fetched out of that module-cache directory**: |
| 434 | `go list -m -f '{{.Dir}}' amadan.net/rastrillo/idear` names it, pinned to |
| 435 | whatever version the app actually resolved, and `docs/site/addons.md` |
| 436 | carries the exact `cat "$(go list -m -f '{{.Dir}}' |
| 437 | amadan.net/rastrillo/idear)/SKILL.md"` line for it β a one-step fetch in |
| 438 | the same spirit as Rastrillo's own `SKILL.md` pointing agents at `curl -s |
| 439 | https://rastrillo.org/docs/<page>.md`, just without a URL to keep serving |
| 440 | (amadan.net has no raw-file route, only an HTML viewer, so a URL was never |
| 441 | going to stay a one-step fetch). No new machinery, and it generalises to |
| 442 | every future addon: the directory page's job is to hand an agent a |
| 443 | fetchable skill, not to describe one. |
| 444 | |
| 445 | An addon whose skill doc an agent cannot find saves an app the typing and |
| 446 | none of the reading β which is the whole cost argument, lost. |
| 447 | |
| 448 | ## 7. Testing |
| 449 | |
| 450 | Tests drive the HTTP surface with a cookie jar and real CSRF tokens |
| 451 | scraped from rendered pages β the path a browser and an attacker both |
| 452 | take. The authorization suite is the deliverable, not a supporting |
| 453 | artifact: |
| 454 | |
| 455 | 1. a non-member is refused **read and write on every route**, with |
| 456 | byte-identical 404s, including deeply nested ids; |
| 457 | 2. a Member is refused every management action; |
| 458 | 3. an Admin cannot change, demote or deactivate an Admin or the Owner; |
| 459 | 4. a posted `role=owner` never lands, on any path, for any actor; |
| 460 | 5. the single-owner invariant holds **across six concurrent transfers**, |
| 461 | run as an actual race β round 1 of the bake-off found exactly this bug |
| 462 | in the hand-rolled version; |
| 463 | 6. migration checksums frozen, matching `migrate/frozen_checksums_test.go`. |
| 464 | |
| 465 | Six more, each pinning a defect this design was revised to close: |
| 466 | |
| 467 | 7. an invited address cannot be claimed **without the token** β registering |
| 468 | `admin@corp.com` on an invite-only instance with no `invite` field |
| 469 | admits at no role, not the invited one; |
| 470 | 8. `Revoke` racing `Accept` never admits: the CAS is exercised |
| 471 | concurrently, not asserted about; |
| 472 | 9. an expired invitation is refused, and an accepted one cannot be |
| 473 | replayed; |
| 474 | 10. an orphaned user β created, then failed before the Member write β is |
| 475 | healed by `POST /invitations/{token}` while signed in, and 404s on |
| 476 | every route until they are; |
| 477 | 11. `Transfer` racing `Deactivate` of the same target never yields a |
| 478 | deactivated Owner; |
| 479 | 12. a deactivated member can be reactivated and regains exactly their |
| 480 | prior access, and no more. |
| 481 | |
| 482 | **One anti-pattern designed out.** Round 1's auditor found a test written |
| 483 | to whitelist the very payload it listed, so a green suite passed over a |
| 484 | live open redirect. Allow-list tests here derive their payloads from the |
| 485 | shared list under test; they never restate it. A test that quotes the |
| 486 | implementation proves the implementation equals itself. |
| 487 | |
| 488 | ## 8. The changes in this repository |
| 489 | |
| 490 | More than the three files the first draft promised, because |
| 491 | `password.ErrRefused` (Β§5) is a core change and has to be paid for |
| 492 | honestly: |
| 493 | |
| 494 | **`password/handlers.go`** gains an exported `ErrRefused` sentinel and one |
| 495 | branch in `Signup`: `errors.Is(err, password.ErrRefused)` renders the |
| 496 | wrapped message at 403 instead of the duplicate-email copy at 422. With |
| 497 | it come its tests and a line on `docs/site/reference/password.md`, which |
| 498 | `symbols_test.go` requires for any new exported symbol. This is a seam, |
| 499 | not a feature β anything gating signup needs it, and idear is simply the |
| 500 | first. |
| 501 | |
| 502 | **`docs/site/addons.md`** plus its `nav.json` section. Written as a |
| 503 | directory that scales past one entry: what an addon is β versioned |
| 504 | separately, depends on rastrillo and never the reverse, ships its own |
| 505 | namespaced `migrate.Set` and its own `SKILL.md` β then the idear entry |
| 506 | with its module path, what it does, what it deliberately does not, and |
| 507 | the wiring. Every Go fence must parse; that is a gate, not a style note. |
| 508 | |
| 509 | Living in `docs/site/` rather than the website repo means it inherits all |
| 510 | six docsite gates and rides the existing vendoring. The URL is |
| 511 | `rastrillo.org/docs/addons`. |
| 512 | |
| 513 | The addons page also carries the module-cache `cat` line for idear's own |
| 514 | `SKILL.md` (Β§6) β without it the directory describes a skill instead of |
| 515 | delivering one. |
| 516 | |
| 517 | **`SKILL.md`**, roughly 330 bytes into Β§3 β where the reader has just |
| 518 | been told scoping separates users and not tenants, and immediately |
| 519 | wonders how roles work: |
| 520 | |
| 521 | > **Roles and membership are an addon, not core.** Rastrillo has no role |
| 522 | > concept: who is *in* this instance and at what rank is |
| 523 | > `amadan.net/rastrillo/idear` β Owner/Admin/Member, invitations, and |
| 524 | > the members UI, over `sessions` and either identity plugin. |
| 525 | > Full treatment: docs/site/addons.md β rastrillo.org/docs/addons |
| 526 | |
| 527 | That lands the file near 16.4 KB against the 17 KB budget, buying the |
| 528 | pointer without a trim, in the existing `Full treatment:` convention. |
| 529 | |
| 530 | ## 9. Sequencing and open risk |
| 531 | |
| 532 | The in-repo documentation ships first, as its own pull request: it states |
| 533 | the doctrine and is independently useful before any of idear exists. |
| 534 | idear follows as its own plan against the amadan repository. |
| 535 | |
| 536 | **Open risk: the vanity import path.** `amadan.net/rastrillo/idear` |
| 537 | requires amadan.net to serve a `go-import` meta tag at |
| 538 | `/rastrillo/idear?go-get=1`. Paul believes it already does. Believing is |
| 539 | not verifying, and the whole premise of the addon is that an unattended |
| 540 | agent can `go get` it, so a smoke test against a throwaway module is task |
| 541 | zero of the idear plan β before anything depends on the path. |
| 542 | |
| 543 | ## What this does not claim |
| 544 | |
| 545 | idear closes a **cost** gap, not a correctness one. Both arms scored |
| 546 | 20/20 on security with zero confirmed defects; the hand-rolled gate held |
| 547 | against live adversarial probing. Nothing here says Rastrillo was unsafe. |
| 548 | |
| 549 | And the chassis's own caveat transfers wholesale: **a library buys the |
| 550 | mechanism, not the discipline.** idear makes the safe call the short |
| 551 | call. It does not remove the engineer at the seams it cannot cover. |
| 552 | |
| 553 | **Amended 2026-08-24 (review finding F7): idear keeps no audit trail.** |
| 554 | `Invitation.InvitedBy` records who sent one invitation, and that is the |
| 555 | whole of it. There is no record of who changed whose role, who |
| 556 | deactivated or reactivated whom, or who ran a `Transfer` β the store |
| 557 | mutates the row and moves on, and `HandlerConfig`'s handlers do not log |
| 558 | a successful mutation either, only a failed one (and then without |
| 559 | structured actor/target fields). After a rogue-admin incident or a |
| 560 | compromised Owner session, an operator has the roster's *current* shape |
| 561 | and nothing that answers "who made this member an Admin, and when." |
| 562 | This is deliberately not built for v1: it is a genuine gap, not an |
| 563 | oversight, and an app that needs one must layer it itself β a |
| 564 | `SetRole`/`Deactivate`/`Transfer` wrapper that writes its own audit row |
| 565 | before calling through, or database-level change tracking β rather |
| 566 | than assume idear provides it. |
| 567 | |