rastrillo / idear Public

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

Plain git — no account needed to clone.

Download

Download this file

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