@vibes.diy/prompts 14.3.119 → 14.3.121

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/llms/access.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Access Control (`access.js`) — permission design
2
2
 
3
- This doc is in view because the app is getting its `access.js`: a new app's first build writes its rules with its screens, and a later edit adds or changes them. Either way the records carry their keys (`creatorHandle` on the object, the parent id on each child, `authorHandle` on every record), and this build writes the rules that key on them. The rules and those keys are plumbing with nothing on screen of their own; the invite screen joins the build when the ask names other people, sharing, a group or a person by name, and is otherwise offered as an option after the build. Every app gets record rules, and the group shape taught below is the resting default. A person the ask names is let in by a record — see _A person the ask names is let in by a record_ below — so the build states the membership record's shape and the invite handler that writes it.
3
+ This doc is in view because the app is getting its `access.js`: a new app's first build writes its rules with its screens, and a later edit adds or changes them. Either way the records carry their keys (`creatorHandle` on the object, the parent id on each child, `authorHandle` on every record), and this build writes the rules that key on them. The rules and those keys are plumbing with nothing on screen of their own; the invite screen joins the build when the ask names other people, sharing, a group or a person by name, and when the app is a person's own collection it is the first screen's add-a-friend control, since adding a friend is how a collection is shared; otherwise it is offered as an option after the build. Every app gets record rules, and the group shape taught below is the resting default. A person the ask names is let in by a record — see _A person the ask names is let in by a record_ below — so the build states the membership record's shape and the invite handler that writes it.
4
4
 
5
5
  **When an edit adds the rules to an app that already exists, the edit owes only what the rules need.** Read `App.jsx` for the object type, the field that names its creator and the parent id its children carry; name the channels from those records; write the complete `access.js`; then change `App.jsx` only where the rules require it — stable creator membership, typed content queries, any write surface not yet gated on `can`, and the invite screen when the ask names other people, sharing or a group — with SEARCH/REPLACE edits. A file the rules do not touch is not emitted, and stays as it is.
6
6
 
@@ -476,9 +476,11 @@ export function board(doc, oldDoc, user, ctx) {
476
476
 
477
477
  `ctx.requireAccess(channel)` gates on **membership** (a `grant.users`/`grant.roles` grant), not `grant.public` (read-only) — so an open feed anyone signed-in may post to must not gate writes on it; check the author and route the doc. For writes needing no sign-in ("anyone can sign/submit"), return `allowAnonymous: true` instead of throwing on `!user`. A grant/share/request doc must also return `channels` — a channel-less result is rejected. On updates, keep ownership immutable (`doc.authorHandle !== oldDoc.authorHandle` → throw) and let the author edit their own (as `wall.post` above shows). Pictures other viewers make for a shared doc are their own records in the media database, so an author-only update keeps every picture flowing.
478
478
 
479
+ **The sharing rule.** Each person's own records live on a channel named by their handle, granted on every write they make, so the app opens on their tree with nothing to create first; a friend joins by a `member` record the tree's owner writes. A second or shared thing is its own document, written by the person's own action (a tap, a submit, a pick), with a type `access.js` has a branch for, carrying the creator's handle and naming the channel its records route to. App-wide authority is a role the owner holds from the seed and hands out with a role record; rules check `ctx.requireRole`, never `user.isOwner`. The notes example below is the tree, _A second or shared object_ is the next node, and _App-wide authority is a role the owner hands out_ is the third. A tree's name or settings is a `settings` record (`_id: "settings-" + handle`) written only when the person changes them: its control checks `can.create` on the complete record while none is saved and `can.edit` on the saved record spread with the change once one is. A person's own action — a tap, a submit, a pick — is what starts a write; nothing writes on load or on mount.
480
+
479
481
  **Build the permission model around what a newcomer should be able to do.** When a stranger opens the app, they should immediately be able to do the thing it's _for_ — add their own entries, post a note, drop a pin, join a shared canvas. So the default is: every signed-in visitor is a first-class participant who creates their own objects and edits what they created (`doc.authorHandle === user.userHandle`, checking `oldDoc` on updates), from first load, with no one needing to let them in.
480
482
 
481
- **Every app ships `access.js`. When the person says "just for me", "no sharing" or "keep it single-user", each signed-in person gets their own record tree, and a publish step shares a chosen item with everyone who can open the app.** The rules key on the writer's own handle, so they read the same for the person who made the app and for anyone they invite later. The app's nature is sensitive, and who can open the app is its sharing setting, not the rules: a published item is world-readable in `access.js` and reaches whoever can open the app — its members while it stays private, everyone once the owner opens it up. Publish copy says that plainly ("Published — everyone who can open this app sees it"):
483
+ **Every app ships `access.js`. When the person says "just for me", "no sharing" or "keep it single-user", each signed-in person gets their own collection, private to them, and the way to share it is adding a friend, who then sees the collection.** That is the private per-user shape below (the notes example, whose `member` branch is the add-a-friend control's write): the person's tree is the channel named by their handle, and the add-a-friend control writes the `member` doc that grants the friend that channel. The rules key on the writer's own handle, so they read the same for the person who made the app and for anyone they invite later. The app's nature is sensitive, and who can open the app is its sharing setting, not the rules. A piece made for readers keeps its own publish step instead — a sketch drafted for a gallery, a post, an article — on the per-item channel this example shows, whose read-grant flips from a `visibility` field: a published piece is world-readable in `access.js` and reaches whoever can open the app — its members while it stays private, everyone once the owner opens it up. Publish copy says that plainly ("Published — everyone who can open this app sees it"):
482
484
 
483
485
  ### `access.js`
484
486
  ```js partial
@@ -508,6 +510,7 @@ export function sketches(doc, oldDoc, user, ctx) {
508
510
  ```js partial
509
511
  export function notes(doc, oldDoc, user, ctx) {
510
512
  if (!user) throw { forbidden: "sign in" };
513
+ if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" };
511
514
  const ch = `user:${user.userHandle}`; // this visitor's own private space
512
515
 
513
516
  if (doc.type === "note") {
@@ -520,17 +523,32 @@ export function notes(doc, oldDoc, user, ctx) {
520
523
  return { channels: [ch], grant: { users: { [user.userHandle]: [ch] } } };
521
524
  }
522
525
 
526
+ if (doc.type === "member") {
527
+ // the add-a-friend control's write: the named friend reads this person's whole collection.
528
+ // A membership stays its writer's, and who it names is fixed, so a friend who can read these
529
+ // records rewrites or removes none of them.
530
+ if (doc.addedBy !== user.userHandle) throw { forbidden: "addedBy must be you" };
531
+ if (oldDoc && (oldDoc.addedBy !== user.userHandle || doc.userHandle !== oldDoc.userHandle)) throw { forbidden: "a membership is its writer's" };
532
+ return { channels: [ch], grant: { users: { [user.userHandle]: [ch], [doc.userHandle]: [ch] } } };
533
+ }
534
+
523
535
  throw { forbidden: "unknown document type" };
524
536
  }
525
537
 
526
- // Summary: each person's notes are theirs alone, private from the first one they write. A note carries its writer's handle in authorHandle.
538
+ // Summary: each person's notes are theirs alone, private from the first one they write, and a friend they add reads their notes. A note carries its writer's handle in authorHandle; a member record names the friend in userHandle and who added them in addedBy.
527
539
  ```
528
540
 
529
- Every item a visitor creates lands on their own `user:<handle>` channel, private to them from the first load. Pair it with a small "Only you can see this" cue beside the publish control (beside their content when the app has none), while the title, subtitle and hero say what the app is for. This is the shape whenever the app is one person's own collection kept for themselves — the whole space is one private channel, no membership step in the way. The word "personal" routes on what the data is FOR: a personal tracker/journal/notes app is this private per-user shape, while a personal blog or portfolio exists to be READ — that one is the publication shape below (public read + author-owned posts, a roster of one), not a private channel.
541
+ Every item a visitor creates lands on their own `user:<handle>` channel, private to them from the first load. Pair it with a small who-can-see cue beside the add-a-friend control ("Yours alone — add a friend to share your sill"), while the title, subtitle and hero say what the app is for. This is the shape whenever the app is one person's own collection kept for themselves — the whole space is one private channel, no membership step in the way — and the `member` doc the add-a-friend control writes grants the friend that one channel (`grant: { users: { [doc.userHandle]: [`user:${user.userHandle}`] } }`). The word "personal" routes on what the data is FOR: a personal tracker/journal/notes app is this private per-user shape, while a personal blog or portfolio exists to be READ — that one is the publication shape below (public read + author-owned posts, a roster of one), not a private channel.
530
542
 
531
543
  **When the prompt asks to share, invite, or collaborate — bring in a partner, a buddy, a friend, a team, or a group that co-edits — each shared thing becomes its own object others can be invited into.** A seed-swap shelf you invite your sibling to, a board a group co-edits, a document you open to a collaborator: route that shared thing to its **own object channel** (`list:<id>`/`board:<id>`) and self-grant it at creation (`grant: { users: { [user.userHandle]: [ch] } }`), with the `authorHandle` create + `oldDoc` author checks so each item stays on its object. Then let the creator invite a chosen friend in: a `share` doc the creator authors grants that friend the same channel (`grant: { users: { [doc.invitee]: [ch] } }`), so they collaborate on that one list — sharing a single list or a whole space is the same grant at a different node of the object graph. A wall, guestbook, or map where each visitor adds their _own_ items is author-owned writes + public read: any signed-in visitor authors their own and everyone reads.
532
544
 
533
- **When a user's work is private by default, show it — and offer a way to publish.** If everything a user does routes to a channel only they can read (a per-user `user:<handle>`, a private journal/notes/tracker with no `grant.public`), the UI must say so: a small, persistent "Only you can see this" / "Private to you" cue beside the publish control that changes it (beside their content when the app has none), so no one wonders who's watching their unfinished work. Then give them a publish control — sharing fits every app that is not privacy-sensitive, so this one ships on the first build (or, where the records are made to be seen, put them on an always-on public channel instead, as the character-cast example below does) — but publish at the granularity of a **channel**, not a doc: channels are the unit of read isolation, so adding `grant.public` to the shared `user:<handle>` channel would expose _every_ private item on it, not just the one they meant to share. To publish a single item, route it to its **own** channel and flip only that channel's read-grant from a `visibility` field the access fn reads — exactly the per-item-channel shape the worked example below uses (`const ch = \`entry:${doc._id}\`; const grant = { users: { [user.userHandle]: [ch] } }; if (doc.visibility === "public") grant.public = [ch];`) — for anonymous visitors, or grant a shared app channel for all granted members. Gate the control on `useVibe(dbName).can`, and reflect the result back in the affordance ("Published — anyone can see this", with an unpublish to flip it back). Making the user's _whole_ space public is fine when that's the intent; silently leaking their other private items by publishing one is the trap to avoid. **When the person asked, in their own words, to keep it to themselves — "only I", "just me", "nobody else" — the publish control is the one sharing surface:** one tap on an item they choose, shared with everyone who can open the app. A privacy-sensitive domain on its own (a journal, a health tracker) is not that ask: it gets the two controls from _Two controls_ above, off until the person says so. Keep publish opt-in and one tap for user-selected items. **Every app that is neither privacy-sensitive nor an only-me ask ships with a sharing path already in it: either an always-on shared channel its records land on, or a publish control.** **An open-nature app — an image or meme wall, a bingo card, a party game — puts its records on an always-on `grant.public` channel from the first rules it ships, builds no publish step, and admits anonymous readers**, so everyone with the link sees what people make the moment they make it. The always-on channel is the least-surprise shape for anything playful and low-stakes (an image or meme wall, a group list, a guestbook): a `grant.public` channel, a members' channel, or an `audience` arm, so a thing someone makes is seen the moment they make it. The publish control is the shape for work people draft first: the per-item channel whose read-grant flips from a `visibility` field, behind one tap. A publish control that names its destination — "Publish to the wall", "Share to the group" — is the best shape of the second, not a requirement. An ordinary app with neither is a bug: every record lands on a channel only its writer can read, and nobody asked for that.
545
+ **The sharing shape follows what one record is.** A record a person keeps for their own life — a check-in, an entry, a line in a list, a plant on a windowsill, a stretch in a routine — belongs to the collection it sits in: the whole collection is private to its person, and the way to share it is adding a friend, who then sees the collection. A piece made for readers — a post, an article, a gallery piece, a portfolio entry — carries its own draft-then-publish step, the ritual readers already know from blogs. A thing made to be seen the moment it exists — a meme, a caption, a guestbook line, a character in a cast — lands on an always-on shared channel. The person's own words move the dial: "let me pick which ones each friend sees" adds choosing items per friend to the add-a-friend control, and "drafts" or "publish" brings the per-item step to any record. Each shape ships with its one who-can-see line beside the control it describes, while the title, subtitle and hero say what the app is for:
546
+
547
+ - **A collection, shared by adding a friend.** Everything the person keeps routes to their tree, the channel named by their handle (`user:<handle>`), which only they read, and the first screen carries the add-a-friend control — the platform's `HandleInput` writing a `member` doc that grants that handle the collection's channel (`grant: { users: { [doc.userHandle]: [ch] } }`), the shape the notes example above works through. The friend sees the collection, and the cue beside the control reads in the app's words ("Yours alone — add a friend to share your sill"). This is the resting shape for a tracker, a log, a list, a routine, a shelf, and for an ask kept to themselves ("only I", "just me", "nobody else"). When the person asks to choose what each friend sees, the `member` doc carries the chosen ids and grants that friend each item's own channel — the worked example _A friend sees the items picked for them_ below.
548
+ - **A piece, drafted and then published.** Publish at the granularity of a **channel**: channels are the unit of read isolation, so the piece gets its **own** channel and only that channel's read-grant flips, from a `visibility` field the access fn reads — the sketches shape above (`const ch = \`entry:${doc._id}\`; const grant = { users: { [user.userHandle]: [ch] } }; if (doc.visibility === "public") grant.public = [ch];`) — for anonymous visitors, or grant a shared app channel for all granted members; the person's other pieces stay on their own channels, each with its own switch. Gate the control on `useVibe(dbName).can`, and reflect the result back in the affordance ("Published — anyone can see this", with an unpublish to flip it back). A publish control that names its destination — "Publish to the wall", "Share to the group" — is the best shape. Making the person's _whole_ space public is fine when that is the intent.
549
+ - **A thing, seen the moment it is made.** **An open-nature app — an image or meme wall, a bingo card, a party game — puts its records on an always-on `grant.public` channel from the first rules it ships, builds no publish step, and admits anonymous readers**, so everyone with the link sees what people make the moment they make it. The always-on channel is the least-surprise shape for anything playful and low-stakes (an image or meme wall, a group list, a guestbook): a `grant.public` channel, a members' channel, or an `audience` arm.
550
+
551
+ A privacy-sensitive domain on its own (a journal, a health tracker) gets the two controls from _Two controls_ above, off until the person says so — its buddy picker is this same add-a-friend control. **Every app that is neither privacy-sensitive nor an only-me ask ships with a sharing path already in it: either an always-on shared channel its records land on, a publish control, or an add-a-friend control on its first screen**, so what a person makes has a way to reach someone from the first build.
534
552
 
535
553
  **Worked example — a cast everyone can meet: shared characters, private chats (a character chat, a creature gallery, a songbook).** Sharing is the baseline, so an app where people make things ships a way to see them on its first build, even when the ask names nobody else and the invite screen waits for an option. Here every character lands on one always-on public channel, so every visitor meets the whole cast with no step at all, and each maker edits only their own. A one-tap publish per character (the sketches shape above) is an equally good answer; this example shows the always-on one. Each chat, the thread and the story so far, is private to the person having it. Letting a friend join a chat is an option the app can offer later, as a membership on that chat. The app's code holds no notion of an owner. The canned characters the ask starts with are a fixed set of documents in `seed.json`, seeded where everyone reads them, and the app writes no copy of them for each visitor. Adding the seed is the owner's choice, so the app works without it: until then the cast opens empty, inviting the first person to make a character. The screen says who can see the cast ("Anyone with the link can meet the cast") beside a copy-link.
536
554
 
@@ -613,7 +631,7 @@ export function pieces(doc, oldDoc, user, ctx) {
613
631
 
614
632
  The leaderboard is just the access model: read the public `track:` channels and sum them; each viewer additionally sees their own tallies and any buddy who granted them in. A practice log whose owner **asked to keep it to themselves** is instead the per-visitor shape shown above: every visitor's pieces and check-ins on their single `user:${user.userHandle}` channel, self-granted and private to them.
615
633
 
616
- **Per-object collaboration is the resting shape of almost every app** (the second worked example above), and certainly of anything that says "invite", "join", "people can join", "collaborate", "share with", "together", "with my partner/team", or names a board/canvas/room/whiteboard a group co-edits — each shared thing is ONE object its members reach directly; it needs no owner. Use the per-object recipe: a channel per object, named from the record (`board:${doc._id}` on the object, `board:${doc.boardId}` on its children); the creator self-grants at creation (`grant: { users: { [user.userHandle]: [ch] } }`); child docs gate on `ctx.requireAccess(ch)` so any member edits any child in it (not just their own); a member-authored `share` doc grants a peer the same channel; a `request` doc — which takes **no** `requireAccess` — lets a not-yet-member ask to join. Keep the _object's own_ creator field write-once (`if (oldDoc && doc.author !== oldDoc.author) throw`) and a child's object-id immutable. **Two traps to avoid:** don't build it as an open public feed where each person only owns their own items (that abandons the shared membership), and don't gate it behind a single writer (members self-serve via share/request). The app owner is not special in the data model: any signed-in person creates their own object and invites a friend into it, and the rule keys on `user.userHandle` or the object's creator, never on `user.isOwner`.
634
+ **Per-object collaboration is the shape of every second or shared thing** (the second worked example above), and certainly of anything that says "invite", "join", "people can join", "collaborate", "share with", "together", "with my partner/team", or names a board/canvas/room/whiteboard a group co-edits — each shared thing is ONE object its members reach directly; it needs no owner. Use the per-object recipe: a channel per object, named from the record (`board:${doc._id}` on the object, `board:${doc.boardId}` on its children); the creator self-grants at creation (`grant: { users: { [user.userHandle]: [ch] } }`); child docs gate on `ctx.requireAccess(ch)` so any member edits any child in it (not just their own); a member-authored `share` doc grants a peer the same channel; a `request` doc — which takes **no** `requireAccess` — lets a not-yet-member ask to join. Keep the _object's own_ creator field write-once (`if (oldDoc && doc.author !== oldDoc.author) throw`) and a child's object-id immutable. **Two traps to avoid:** don't build it as an open public feed where each person only owns their own items (that abandons the shared membership), and don't gate it behind a single writer (members self-serve via share/request). The app owner is not special in the data model: any signed-in person creates their own object and invites a friend into it, and the rule keys on `user.userHandle` or the object's creator, never on `user.isOwner`.
617
635
 
618
636
  **Ownership is just the object graph** — whoever authored or created a doc owns it (`doc.authorHandle === user.userHandle`, checked against `oldDoc` on updates). There's no broadcaster shape to reach for by default, and **owner-only publishing is a dead end** — never gate the content itself on `requireRole("owner")`. **A blog, magazine, or publication is public read + author-owned posts, with the owner controlling the _author roster_:** the owner approves authors with a grant doc (`if (doc.type === "author") { ctx.requireRole("owner"); return { channels: ["blog:authors"], grant: { users: { [doc.authorHandle]: ["blog:authors"] }, roles: { owner: ["blog:authors"] } } }; }` — the **one** place `requireRole("owner")` belongs, gating who may author, never the posts); a post then gates on `ctx.requireAccess("blog:authors")` (membership) and is author-owned (`doc.authorHandle === user.userHandle` + the `oldDoc` check), so once approved each author's post is _their own_ object — only they edit it, and they moderate the comments on it (a comment is allowed if it's your comment **or** you own the post: `doc.authorHandle === user.userHandle || doc.postAuthorHandle === user.userHandle`). A personal blog is just this with a roster of one. Always gate write UI on `useVibe(dbName).can`.
619
637
 
@@ -933,7 +951,7 @@ Channel `_id` is the channel identifier everywhere. The access function uses `do
933
951
 
934
952
  ### Example: Per-object sharing (collaborate on your own objects, no admin)
935
953
 
936
- Reach for this by default, and certainly whenever the prompt says **invite, join, collaborate, share with, together, with my partner/team** — a shared seed-swap shelf you invite a sibling to, a whiteboard people can join, a quilting circle's pattern queue a group builds together, a tracker one person starts and later shares with a coach. A list app where every signed-in user makes their own lists, sees only their own, and can invite anyone to collaborate on a specific list — peer to peer, with no app admin in the loop. The pattern: **a channel per object** (`list:<id>`); the creator grants themselves that channel at creation; child docs (items) gate on `ctx.requireAccess` of the list's channel, so **any member edits any item**; any current member shares the list by granting another user the same channel. Membership is direct `grant.users`, so each viewer's access scales with their own memberships. Nothing a person writes reaches anyone else until they chose it: a per-object channel starts with its creator as the only reader, and every other reader arrives through a grant that creator wrote.
954
+ Reach for this for a second or shared thing, and certainly whenever the prompt says **invite, join, collaborate, share with, together, with my partner/team** — a shared seed-swap shelf you invite a sibling to, a whiteboard people can join, a quilting circle's pattern queue a group builds together, a tracker one person starts and later shares with a coach. A list app where every signed-in user makes their own lists, sees only their own, and can invite anyone to collaborate on a specific list — peer to peer, with no app admin in the loop. The pattern: **a channel per object** (`list:<id>`); the creator grants themselves that channel at creation; child docs (items) gate on `ctx.requireAccess` of the list's channel, so **any member edits any item**; any current member shares the list by granting another user the same channel. Membership is direct `grant.users`, so each viewer's access scales with their own memberships. Nothing a person writes reaches anyone else until they chose it: a per-object channel starts with its creator as the only reader, and every other reader arrives through a grant that creator wrote.
937
955
 
938
956
  ### `access.js`
939
957
  ```js partial
@@ -1019,11 +1037,11 @@ App.jsx — `access.hasChannel()` shows only the lists the viewer belongs to; `u
1019
1037
  >>>>>>> REPLACE
1020
1038
  ```
1021
1039
 
1022
- #### The same shape with a default object per person, and a member doc that names the friend
1040
+ #### A second or shared object, and a member doc that names the friend
1023
1041
 
1024
- Reach for this variant whenever the app is built around a *thing* people come back to — a board, a seed-swap shelf, a quilting circle's pattern queue, a beekeeping club's hive log, a sourdough feeding log one person keeps and a baking friend is invited into next month. Everything above still holds; four additions make it feel finished from the first load:
1042
+ Reach for this when the person makes a *second* thing of the same kind, or a thing several people share — a second board beside their own records, a seed-swap shelf a sibling is invited into, a beekeeping club's hive log, a trip a group plans together. The person's own records still open on their tree, the channel named by their handle (the notes example above); the object is the next node of the graph. Four parts make it feel finished:
1025
1043
 
1026
- - **Every signed-in person gets a default object automatically.** Its stable board record uses `` `default-${me.userHandle}` `` as its id and grants its creator the board channel and admin channel. Establish that record before the first content write or invitation, including when inviting is the first action. The creator remains a member when the board is empty, when a collaborator writes first, and after the last creator-authored item is deleted. Read the board by its fixed id before creating it; an existing board keeps its name and other settings. Child records route to the board channel; the board record carries the lasting creator grant. **The child doc stores that board id rather than having each writer derive one**: the create uses the displayed object’s id, with `` `default-${me.userHandle}` `` for a first action before any object exists and the rule holds it fixed from then on, so a card stays on the board its creator put it on however many people edit it later.
1044
+ - **The object is a stored document written by the person's own action** — a submit that names it ("Start a board"), a tap on a template, a pick from a list. It has its own branch in `access.js`, carries `creatorHandle`, and its `_id` names the channel its records route to (`board:<_id>`). The creator holds that channel and its admin channel from the object's own write, so they remain a member when the board is empty, when a collaborator writes first, and after the last creator-authored item is deleted. Nothing creates it on load or on mount. **The child doc stores that board id rather than having each writer derive one**: the create uses the displayed object's id and the rule holds it fixed from then on, so a card stays on the board its creator put it on however many people edit it later.
1027
1045
  - **Membership is a `member` doc, and picking the friend is `HandleInput`** (`useViewer()`, see use-viewer.md — the platform's people-picker, so the handle on the doc is one the platform resolved). The doc grants the named person the object's channel: `` grant: { users: { [doc.userHandle]: [chan] } } ``.
1028
1046
  - **The object's creator is its admin**, held on a second channel (`` `${chan}/admin` ``) that the creator self-grants at creation. Adding a member is gated on that admin channel, so members collaborate and the creator decides who joins. The app owner holds no place in any of this: every rule reads `user.userHandle` or the object's own `creatorHandle`.
1029
1047
  - **Recipients can select every readable object.** The ordinary object query already applies the access rules, including grants from independent membership records. Its complete result supplies the picker. Prefer the person's own object as the initial selection while keeping invited objects selectable alongside it. Resolve the selected id against the current readable results on each render, so a revoked object leaves both the picker and the content view. Use the selected object's id for child queries, new content, and invitations; `can` continues to gate each write.
@@ -1037,102 +1055,94 @@ export function boards(doc, oldDoc, user, ctx) {
1037
1055
  if (typeof id !== "string" || !/^[A-Za-z0-9_-]+$/.test(id)) throw { forbidden: "bad id" };
1038
1056
  return id;
1039
1057
  };
1040
- const myDefault = "default-" + user.userHandle;
1041
1058
  const ch = (id) => "board:" + safeId(id);
1042
1059
 
1043
1060
  if (doc.type === "board") {
1044
- // Anyone signed in makes their own board; the creator holds it and its admin channel.
1061
+ // The person's own submit makes a board; its creator holds it and its admin channel.
1045
1062
  if (oldDoc) {
1046
1063
  if (doc.creatorHandle !== oldDoc.creatorHandle) throw { forbidden: "creator is fixed" };
1047
1064
  ctx.requireAccess(ch(doc._id) + "/admin"); // renaming is an admin act — updates only
1048
- } else if (doc.creatorHandle !== user.userHandle || (doc._id.startsWith("default-") && doc._id !== myDefault)) {
1065
+ } else if (doc.creatorHandle !== user.userHandle) {
1049
1066
  throw { forbidden: "create your own board" };
1050
1067
  }
1051
1068
  return { channels: [ch(doc._id)], grant: { users: { [doc.creatorHandle]: [ch(doc._id), ch(doc._id) + "/admin"] } } };
1052
1069
  }
1053
1070
 
1054
1071
  if (doc.type === "card") {
1055
- // The card STORES its board id from the moment it is created (the client writes the
1056
- // creator's own default when the person picked no board) and holds it fixed after —
1057
- // including the absent-to-present move, so a later editor's handle always finds the
1058
- // id already on the doc rather than supplying one.
1072
+ // The card STORES its board id from the moment it is created and holds it fixed after —
1073
+ // including the absent-to-present move, so a later editor always finds the id already on the doc.
1059
1074
  if (!oldDoc && (typeof doc.boardId !== "string" || doc.boardId.length === 0)) {
1060
1075
  throw { forbidden: "a card names its board" };
1061
1076
  }
1062
1077
  if (oldDoc && doc.boardId !== oldDoc.boardId) throw { forbidden: "card stays on its board" };
1063
1078
  const chan = ch(doc.boardId);
1064
- if (doc.boardId !== myDefault) ctx.requireAccess(chan); // any member edits any card on it
1065
- // The stable board record carries its creator's grant, independently of cards.
1079
+ ctx.requireAccess(chan); // any member edits any card on it; the creator holds it from the board record
1066
1080
  return { channels: [chan] };
1067
1081
  }
1068
1082
 
1069
1083
  if (doc.type === "member") {
1070
1084
  // The board's admin invites a person the picker resolved; the doc IS the grant.
1071
- // Fixed once written — an identical re-put (a cold replica replaying a first-render
1072
- // write) is harmless and admitted; any change to who, where or by whom is refused.
1085
+ // Fixed once written — an identical re-put (a second device replaying the same save) is
1086
+ // harmless and admitted; any change to who, where or by whom is refused.
1073
1087
  if (oldDoc && (doc.userHandle !== oldDoc.userHandle || doc.boardId !== oldDoc.boardId || doc.addedBy !== oldDoc.addedBy))
1074
1088
  throw { forbidden: "membership grants are fixed" };
1075
1089
  if (doc.addedBy !== user.userHandle) throw { forbidden: "addedBy must be you" };
1076
- if (doc.boardId !== myDefault) ctx.requireAccess(ch(doc.boardId) + "/admin");
1090
+ ctx.requireAccess(ch(doc.boardId) + "/admin");
1077
1091
  return { channels: [ch(doc.boardId)], grant: { users: { [doc.userHandle]: [ch(doc.boardId)] } } };
1078
1092
  }
1079
1093
 
1080
1094
  throw { forbidden: "unknown document type" };
1081
1095
  }
1082
1096
 
1083
- // Summary: everyone signed in has their own boards, a board's creator lets a friend in with a member record, and members add and edit every card on it. A board carries its creator's handle in creatorHandle, a card names its board in boardId, and a member record names the friend in userHandle and its writer in addedBy.
1097
+ // Summary: anyone signed in starts boards of their own, a board's creator lets a friend in with a member record, and members add and edit every card on it. A board carries its creator's handle in creatorHandle, a card names its board in boardId, and a member record names the friend in userHandle and its writer in addedBy.
1084
1098
  ```
1085
1099
 
1086
1100
  App.jsx — the invite surface is `HandleInput` plus one `member` write, shown to whoever the access fn would accept:
1087
1101
 
1088
1102
  ```jsx
1089
1103
  const { HandleInput } = useViewer();
1104
+ const { database } = useFireproof("boards");
1090
1105
  const { me, can } = useVibe("boards");
1091
- const canInvite = (boardId) => can.create({ type: "member", boardId, userHandle: "x", addedBy: me?.userHandle }).ok;
1092
- // This read/create is awaited before either first action. Existing board settings survive.
1093
- const ensureDefaultBoard = async (boardId) => {
1094
- if (boardId !== `default-${me.userHandle}`) return;
1095
- const { docs } = await database.query("_id", { key: boardId });
1096
- if (docs.length === 0) {
1097
- await database.put({ _id: boardId, type: "board", creatorHandle: me.userHandle });
1098
- }
1099
- };
1106
+ // One fixed id per (board, handle): the same person added twice is the same record.
1107
+ const memberFor = (boardId, handle) => ({ _id: `member:${boardId}:${handle}`, type: "member", boardId, userHandle: handle, addedBy: me?.userHandle });
1100
1108
  const addMember = async (boardId, handle) => {
1101
- if (!handle) return;
1102
- await ensureDefaultBoard(boardId);
1103
- await database.put({
1104
- _id: `member:${boardId}:${handle}`,
1105
- type: "member",
1106
- boardId,
1107
- userHandle: handle,
1108
- addedBy: me.userHandle,
1109
- });
1109
+ const member = memberFor(boardId, handle);
1110
+ if (!handle || !can.create(member).ok) return;
1111
+ await database.put(member); // the exact value the control checked
1110
1112
  };
1111
- // Pass the displayed board's id; a first action before any board exists uses the person's own default.
1112
- const addCard = async (text, boardId = `default-${me.userHandle}`) => {
1113
- await ensureDefaultBoard(boardId);
1114
- return database.put({ type: "card", boardId, text });
1113
+ // Pass the displayed board's id; the card keeps it for good.
1114
+ const addCard = async (text, boardId) => {
1115
+ const card = { type: "card", boardId, text };
1116
+ if (!can.create(card).ok) return;
1117
+ return database.put(card);
1115
1118
  };
1116
1119
  // …
1117
- {canInvite(board._id) && <HandleInput onChange={(h) => addMember(board._id, h)} placeholder="Add a friend…" />}
1120
+ {can.create(memberFor(board._id, "friend")).ok && <HandleInput onChange={(h) => h && addMember(board._id, h)} placeholder="Add a friend…" />}
1118
1121
  ```
1119
1122
 
1120
1123
  An email typed into that `HandleInput` reaches `addMember` as an opaque `invite:<id>` subject, stored as `userHandle` like any handle, and `ViewerTag` shows it as invited until they arrive.
1121
1124
 
1122
- App.jsx — the same board list serves the creator and the invited recipient. `BoardContents` uses `board._id` for its content queries, calls `addCard(text, board._id)` and `addMember(board._id, handle)`, and gates its write controls with `can`. Pass the `ensureDefaultBoard` helper above into `BoardWorkspace`; its creation action stays reachable with an empty readable list and selects the person's new board after saving it.
1125
+ App.jsx — the same board list serves the creator and the invited recipient. `BoardContents` uses `board._id` for its content queries, calls `addCard(text, board._id)` and `addMember(board._id, handle)`, and gates its write controls with `can`. The creation form stays reachable with an empty readable list, and it selects the person's new board after saving it.
1123
1126
 
1124
1127
  ### `App.jsx`
1125
1128
  ```jsx partial
1126
- function BoardWorkspace({ ensureDefaultBoard }) {
1127
- const { useLiveQuery } = useFireproof("boards");
1129
+ function BoardWorkspace() {
1130
+ const { database, useLiveQuery } = useFireproof("boards");
1128
1131
  const { me, can, ready } = useVibe("boards");
1129
1132
  const [pickedBoardId, setPickedBoardId] = React.useState();
1133
+ const [name, setName] = React.useState("");
1134
+ // The new board's id is made once per draft, so the gate and the put check one complete record.
1135
+ const [draftId, setDraftId] = React.useState(() => "board-" + crypto.randomUUID());
1130
1136
  const { docs: boards } = useLiveQuery("type", { key: "board" });
1131
- const ownBoardId = me?.userHandle ? `default-${me.userHandle}` : undefined;
1132
- const ownBoard = { _id: ownBoardId, type: "board", creatorHandle: me?.userHandle };
1133
- const createOwnBoard = async () => {
1134
- await ensureDefaultBoard(ownBoardId);
1135
- setPickedBoardId(ownBoardId);
1137
+ // The person's own submit starts a board: the form checks and saves this exact record.
1138
+ const newBoard = { _id: draftId, type: "board", name: name.trim(), creatorHandle: me?.userHandle };
1139
+ const startBoard = async (event) => {
1140
+ event.preventDefault();
1141
+ if (!ready || !newBoard.name || !can.create(newBoard).ok) return;
1142
+ await database.put(newBoard);
1143
+ setName("");
1144
+ setPickedBoardId(newBoard._id);
1145
+ setDraftId("board-" + crypto.randomUUID());
1136
1146
  };
1137
1147
  const board =
1138
1148
  boards.find((board) => board._id === pickedBoardId) ??
@@ -1140,30 +1150,100 @@ function BoardWorkspace({ ensureDefaultBoard }) {
1140
1150
  boards[0];
1141
1151
  return (
1142
1152
  <section>
1143
- {ownBoardId && !boards.some((board) => board._id === ownBoardId) && (!ready || can.create(ownBoard).ok) && (
1144
- <Button type="button" onClick={createOwnBoard} disabled={!ready}>Create my board</Button>
1153
+ {boards.length > 0 && (
1154
+ <Field label="Board">
1155
+ {(id, describedBy) => (
1156
+ <Select
1157
+ id={id}
1158
+ describedBy={describedBy}
1159
+ value={board?._id ?? ""}
1160
+ onChange={setPickedBoardId}
1161
+ options={boards.map((board) => ({ value: board._id, label: board.name ?? board._id }))}
1162
+ />
1163
+ )}
1164
+ </Field>
1145
1165
  )}
1146
- <Field label="Board">
1147
- {(id, describedBy) => (
1148
- <Select
1149
- id={id}
1150
- describedBy={describedBy}
1151
- value={board?._id ?? ""}
1152
- onChange={setPickedBoardId}
1153
- disabled={boards.length === 0}
1154
- options={boards.map((board) => ({ value: board._id, label: board.name ?? board._id }))}
1155
- />
1156
- )}
1157
- </Field>
1158
1166
  {board && <BoardContents board={board} />}
1167
+ <form onSubmit={startBoard}>
1168
+ <Field label="New board">
1169
+ {(id, describedBy) => <Input id={id} describedBy={describedBy} value={name} onChange={setName} />}
1170
+ </Field>
1171
+ <Button type="submit" disabled={!ready || !newBoard.name || !can.create(newBoard).ok}>Start a board</Button>
1172
+ </form>
1159
1173
  </section>
1160
1174
  );
1161
1175
  }
1162
1176
  ```
1163
1177
 
1178
+ #### A friend sees the items picked for them
1179
+
1180
+ Reach for this only when the person asks to choose what each friend sees — "when I add a friend, let me pick which plants they can see". Each plant lands on the person's tree and on its own channel, named by its keeper and its id, and the `member` doc carries the chosen ids and grants that friend each chosen item's channel: the friend sees those items and nothing else on the sill, and saving the member doc again with a different list changes what they see. Because an item's channel carries its keeper's handle, a member doc can only ever grant its writer's own items. The add-a-friend control is the same `HandleInput`, with the person's items as checkboxes beside it, and the cue beside it says what the friend will see ("Pick the plants this friend sees").
1181
+
1182
+ ### `access.js`
1183
+ ```js partial
1184
+ export function sill(doc, oldDoc, user, ctx) {
1185
+ if (!user?.userHandle) throw { forbidden: "sign in" };
1186
+ if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" };
1187
+ const safeId = (id) => {
1188
+ if (typeof id !== "string" || !/^[A-Za-z0-9_-]+$/.test(id)) throw { forbidden: "bad id" };
1189
+ return id;
1190
+ };
1191
+ const mine = `user:${user.userHandle}`; // the person's own sill: the channel named by their handle
1192
+ const item = (id) => `plant:${user.userHandle}:${safeId(id)}`; // only ever this writer's own plants
1193
+ if (doc.type === "plant") {
1194
+ if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
1195
+ if (oldDoc && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "a plant stays its writer's" };
1196
+ return { channels: [mine, item(doc._id)], grant: { users: { [user.userHandle]: [mine, item(doc._id)] } } };
1197
+ }
1198
+ if (doc.type === "member") {
1199
+ // The sill's keeper writes a membership; who it names and who wrote it stay fixed,
1200
+ // and the picked plants are the one thing a later save changes.
1201
+ if (doc.addedBy !== user.userHandle) throw { forbidden: "addedBy must be you" };
1202
+ if (oldDoc && (doc.userHandle !== oldDoc.userHandle || doc.addedBy !== oldDoc.addedBy)) throw { forbidden: "who is fixed" };
1203
+ const picked = (doc.plantIds || []).map(item);
1204
+ // A first pick names at least one plant; a re-pick may clear them all, and the friend then sees none.
1205
+ if (!oldDoc && picked.length === 0) throw { forbidden: "pick a plant to share" };
1206
+ const grant = { users: { [user.userHandle]: [mine] } };
1207
+ if (picked.length > 0) grant.users[doc.userHandle] = picked;
1208
+ return { channels: [mine], grant };
1209
+ }
1210
+ throw { forbidden: "unknown document type" };
1211
+ }
1212
+
1213
+ // Summary: each person's plants are theirs alone, and a friend they add sees exactly the plants picked for them. A plant carries its writer's handle in authorHandle; a member record names the friend in userHandle, its writer in addedBy and the plants they see in plantIds.
1214
+ ```
1215
+
1216
+ ### `App.jsx`
1217
+ ```jsx partial
1218
+ const { HandleInput } = useViewer();
1219
+ const { database, useLiveQuery } = useFireproof("sill");
1220
+ const { me, can } = useVibe("sill");
1221
+ const [picked, setPicked] = React.useState([]);
1222
+ const addFriend = async (handle) => {
1223
+ const member = { _id: `member:${me.userHandle}:${handle}`, type: "member", userHandle: handle, plantIds: picked, addedBy: me.userHandle };
1224
+ if (can.create(member).ok) await database.put(member);
1225
+ };
1226
+ // Changing what a friend sees is the same record, saved again with the new list.
1227
+ const repick = async (member, plantIds) => {
1228
+ const next = { ...member, plantIds };
1229
+ if (can.edit(next).ok) await database.put(next);
1230
+ };
1231
+ // …
1232
+ {plants.map((p) => (
1233
+ <Checkbox
1234
+ key={p._id}
1235
+ label={p.name}
1236
+ checked={picked.includes(p._id)}
1237
+ onChange={(checked) => setPicked(checked === true ? [...picked, p._id] : picked.filter((id) => id !== p._id))}
1238
+ />
1239
+ ))}
1240
+ <HandleInput onChange={(h) => h && addFriend(h)} placeholder="Add a friend who sees these…" />
1241
+ ```
1242
+
1243
+
1164
1244
  #### The list holds what the person made and what they were let into
1165
1245
 
1166
- **The app's list of objects reads them by their `type`, so it holds the ones the signed-in person made and the ones a membership lets them into, read through the same rules.** The `member` record grants its board's channel, so the friend's own `useLiveQuery("type", { key: "board" })` returns that board beside their own the moment the membership lands; `creatorHandle` then says who made each one. A shared board carries `ViewerTag` for the person who made it, in the same list or in a section that renders once there is at least one, so a person keeping the app to themselves sees one plain list. Opening a shared board is the same `BoardContents`: its cards are read by its id and written with that id, the rules route them to its channel, and `can` gates each write.
1246
+ **The app's list of objects reads them by their `type`, so it holds the ones the signed-in person made and the ones a membership lets them into, read through the same rules.** The `member` record grants its board's channel, so the friend's own `useLiveQuery("type", { key: "board" })` returns that board beside their own the moment the membership lands; `creatorHandle` then says who made each one. A friend let into someone's tree finds it the same way: read `member` records by type (`useLiveQuery("type", { key: "member" })`), and each readable one in someone else's tree names a tree they can open — dedupe by that tree's handle. Match on the type, never on the friend's own handle: a friend invited by email is stored as `invite:<id>`, and the grant reaches their handle once they arrive without rewriting the record. A shared board carries `ViewerTag` for the person who made it, in the same list or in a section that renders once there is at least one, so a person keeping the app to themselves sees one plain list. Opening a shared board is the same `BoardContents`: its cards are read by its id and written with that id, the rules route them to its channel, and `can` gates each write.
1167
1247
 
1168
1248
  ```jsx
1169
1249
  const { ViewerTag } = useViewer();
@@ -1182,26 +1262,140 @@ const sharedWithMe = boards.filter((board) => board.creatorHandle !== me?.userHa
1182
1262
  )}
1183
1263
  ```
1184
1264
 
1185
- A new access function has no use for `user.isOwner` at all: a rule about a **person** keys on `user.userHandle` or the object's own creator field, and a rule about the app's **own automation** keys on `user.backend`.
1265
+ A new access function has no use for `user.isOwner` at all: a rule about a **person** keys on `user.userHandle` or the object's own creator field, a rule about **app-wide authority** keys on a role through `ctx.requireRole`, and a rule about the app's **own automation** keys on `user.backend`.
1266
+
1267
+ #### App-wide authority is a role the owner hands out
1268
+
1269
+ Some authority belongs to the whole app rather than to one person's records — who edits the menu, who posts announcements, who reviews submissions. That authority is a **role**. The owner holds the reserved `owner` role from the start, with no record needed; every other role is filled by a `roleGrant` record the owner writes, gated on `ctx.requireRole("owner")`, whose output names the member: `members: { [doc.role]: [doc.userHandle] }`. The seed carries the owner's own grant (`{ "type": "roleGrant", "role": "menu-editor", "userHandle": "@owner" }`, above the records it unlocks), so the demo opens with the owner able to edit, and the same control hands the role to a friend later. The records the role governs check `ctx.requireRole("menu-editor")`; the owner and the first visitor arrive the same way, and the rules never ask who the owner is.
1270
+
1271
+ Worked example — a cookie shop's order form: the owner adds flavors and friends place orders. The menu is app-wide, so a flavor needs the `menu-editor` role and reads for everyone. An order is a customer's own record, so it lives in their tree, and a `grant.roles` entry lets every menu editor read the orders placed with the shop.
1272
+
1273
+ ### `access.js`
1274
+ ```js partial
1275
+ export function shop(doc, oldDoc, user, ctx) {
1276
+ if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" }; // a delete arrives as the stored record, type and all
1277
+
1278
+ if (doc.type === "flavor") {
1279
+ // The menu is app-wide: a menu editor adds and changes flavors, and everyone reads them.
1280
+ ctx.requireRole("menu-editor");
1281
+ return { channels: ["menu"], grant: { public: ["menu"] } };
1282
+ }
1283
+
1284
+ if (!user?.userHandle) throw { forbidden: "sign in" };
1285
+ const me = user.userHandle;
1286
+
1287
+ if (doc.type === "roleGrant") {
1288
+ // App-wide authority: the owner holds the owner role from the start and hands out the rest.
1289
+ ctx.requireRole("owner");
1290
+ if (doc.role !== "menu-editor") throw { forbidden: "unknown role" };
1291
+ return { channels: ["admin:roles"], members: { [doc.role]: [doc.userHandle] }, grant: { roles: { owner: ["admin:roles"] } } };
1292
+ }
1293
+
1294
+ if (doc.type === "order") {
1295
+ // An order is its customer's own record, in their tree; every menu editor reads it.
1296
+ if (doc.customerHandle !== me) throw { forbidden: "order for yourself" };
1297
+ if (oldDoc && oldDoc.customerHandle !== me) throw { forbidden: "an order stays its customer's" };
1298
+ const tree = "user:" + me;
1299
+ return { channels: [tree], grant: { users: { [me]: [tree] }, roles: { "menu-editor": [tree] } } };
1300
+ }
1301
+
1302
+ throw { forbidden: "unknown document type" };
1303
+ }
1304
+
1305
+ // Summary: everyone reads the menu and only menu editors change it, the owner names the menu editors with a role grant, and each customer's orders are their own, read by the menu editors. An order carries its customer's handle in customerHandle; a role grant names its role in role and its member in userHandle.
1306
+ ```
1307
+
1308
+ ### `seed.json`
1309
+ ```json partial
1310
+ {
1311
+ "shop": [
1312
+ { "key": "owner-edits-menu", "type": "roleGrant", "role": "menu-editor", "userHandle": "@owner" },
1313
+ { "key": "snickerdoodle", "type": "flavor", "name": "Snickerdoodle", "price": 3 },
1314
+ { "key": "brown-butter", "type": "flavor", "name": "Brown butter chocolate chip", "price": 4 }
1315
+ ]
1316
+ }
1317
+ ```
1318
+
1319
+ App.jsx — every control checks `can.create` on the exact record it saves: the flavor form, each flavor's order button, and the add-a-menu-editor picker, which only the owner role passes.
1320
+
1321
+ ### `App.jsx`
1322
+ ```jsx partial
1323
+ function Shop() {
1324
+ const { database, useLiveQuery } = useFireproof("shop");
1325
+ const { me, can, ready } = useVibe("shop");
1326
+ const { HandleInput, ViewerTag, requestLogin } = useViewer();
1327
+ const [name, setName] = React.useState("");
1328
+ const { docs: flavors } = useLiveQuery("type", { key: "flavor" });
1329
+ // A customer reads their own orders; a menu editor reads every customer's.
1330
+ const { docs: orders } = useLiveQuery("type", { key: "order" });
1331
+ const flavor = { type: "flavor", name: name.trim() };
1332
+ const orderFor = (f) => ({ type: "order", flavorId: f._id, flavorName: f.name, customerHandle: me?.userHandle });
1333
+ const editorFor = (handle) => ({ _id: "role-menu-editor-" + handle, type: "roleGrant", role: "menu-editor", userHandle: handle });
1334
+ const addFlavor = async (event) => {
1335
+ event.preventDefault();
1336
+ if (!ready || !flavor.name || !can.create(flavor).ok) return;
1337
+ await database.put(flavor); // the exact value the control checked
1338
+ setName("");
1339
+ };
1340
+ const order = async (f) => {
1341
+ const next = orderFor(f);
1342
+ if (!ready || !can.create(next).ok) return;
1343
+ await database.put(next);
1344
+ };
1345
+ const addEditor = async (handle) => {
1346
+ const grant = editorFor(handle);
1347
+ if (!ready || !handle || !can.create(grant).ok) return;
1348
+ await database.put(grant);
1349
+ };
1350
+ if (!ready) return <p role="status">Opening the shop…</p>;
1351
+ return (
1352
+ <Stack gap={3}>
1353
+ {can.create(flavor).ok && (
1354
+ <form onSubmit={addFlavor}>
1355
+ <Field label="New flavor">
1356
+ {(id, describedBy) => <Input id={id} describedBy={describedBy} value={name} onChange={setName} />}
1357
+ </Field>
1358
+ <Button type="submit" disabled={!flavor.name}>Add flavor</Button>
1359
+ </form>
1360
+ )}
1361
+ {flavors.map((f) => (
1362
+ <RowCard key={f._id}>
1363
+ <Text>{f.name}</Text>
1364
+ {can.create(orderFor(f)).ok && <Button type="button" size="sm" onClick={() => order(f)}>Order</Button>}
1365
+ </RowCard>
1366
+ ))}
1367
+ {!me && <Button type="button" variant="quiet" onClick={() => requestLogin()}>Sign in to place an order</Button>}
1368
+ {orders.map((o) => (
1369
+ <RowCard key={o._id}>
1370
+ <Text>{o.flavorName}</Text>
1371
+ {o.customerHandle !== me?.userHandle && <ViewerTag userHandle={o.customerHandle} />}
1372
+ </RowCard>
1373
+ ))}
1374
+ {can.create(editorFor("friend")).ok && (
1375
+ <HandleInput onChange={(h) => h && addEditor(h)} placeholder="Add a menu editor…" />
1376
+ )}
1377
+ </Stack>
1378
+ );
1379
+ }
1380
+ ```
1186
1381
 
1187
1382
  #### A person the ask names is let in by a record
1188
1383
 
1189
- When the instruction names somebody by their handle — "let my friend priya-k see and add to my seed-swap shelf", "an expedition notebook for me and theo" — **the build's part is the rule that admits a membership and the screen that writes one; the membership itself is a record.** It is the same `member` doc the rule admits and the invite surface above writes, saved for that handle on the asker's own default object once the rules are live, by whoever is driving the build acting as the maker, read back, and only then spoken of as in. An app whose first build carried the rules and no invite screen still states this shape in full, in `access.js`'s member branch: the type and fields that branch admits are the record a later save writes, and an edit that adds the screen afterwards writes that same record.
1384
+ When the instruction names somebody by their handle — "let my friend priya-k see and add to my seed-swap shelf", "an expedition notebook for me and theo" — **the build's part is the rule that admits a membership and the screen that writes one; the membership itself is a record.** It is the same `member` doc the rule admits and the invite surface above writes, saved for that handle in the asker's own tree — or on the second object the ask names — once the rules are live, by whoever is driving the build acting as the maker, read back, and only then spoken of as in. An app whose first build carried the rules and no invite screen still states this shape in full, in `access.js`'s member branch: the type and fields that branch admits are the record a later save writes, and an edit that adds the screen afterwards writes that same record.
1190
1385
 
1191
- So the record's shape is a contract the files state plainly. The `_id` is fixed by the object and the handle — `` `member:${boardId}:${userHandle}` `` — so a second attempt is the same record rather than a twin, and a read by that id answers "is this person already in?" on its own. The fields are the ones the rule reads: `type`, `boardId`, `userHandle`, and `addedBy` set to the writer's own handle. The invite handler first ensures the stable board record, then writes the membership. The `member` rule admits an identical re-put, so a replay lands quietly. The board record keeps the creator in the collaboration independently of every content and invitation record. One invite handler in `App.jsx` writes exactly that shape, which is what makes the app's own screen and any later save agree on one record.
1386
+ So the record's shape is a contract the files state plainly. The `_id` is fixed by the place and the handle — `` `member:${me.userHandle}:${userHandle}` `` in the person's tree, `` `member:${boardId}:${userHandle}` `` on a board — so a second attempt is the same record rather than a twin, and a read by that id answers "is this person already in?" on its own. The fields are the ones the rule reads: `type`, `userHandle`, `addedBy` set to the writer's own handle, and the object's id when the membership is on a second object. The `member` rule admits an identical re-put, so a replay lands quietly. The tree's channel stays its person's because their own writes grant it, and a second object keeps its creator in the collaboration through its own record, independently of every content and invitation record. One invite handler in `App.jsx` writes exactly that shape, which is what makes the app's own screen and any later save agree on one record.
1192
1387
 
1193
1388
  ```jsx
1194
1389
  const { HandleInput } = useViewer();
1195
- const { database } = useFireproof("boards");
1196
- const { me, can } = useVibe("boards");
1197
- const addMember = async (boardId, handle) => {
1198
- await ensureDefaultBoard(boardId); // the read/create helper above
1199
- // One fixed id per (board, handle): the same person added twice is the same record.
1200
- const member = { _id: `member:${boardId}:${handle}`, type: "member", boardId, userHandle: handle, addedBy: me.userHandle };
1390
+ const { database } = useFireproof("notes");
1391
+ const { me, can } = useVibe("notes");
1392
+ const addFriend = async (handle) => {
1393
+ // One fixed id per (tree, handle): the same person added twice is the same record.
1394
+ const member = { _id: `member:${me.userHandle}:${handle}`, type: "member", userHandle: handle, addedBy: me.userHandle };
1201
1395
  if (can.create(member).ok) await database.put(member);
1202
1396
  };
1203
1397
  // …
1204
- <HandleInput onChange={(h) => h && addMember(board._id, h)} placeholder="Add a friend…" />;
1398
+ <HandleInput onChange={(h) => h && addFriend(h)} placeholder="Add a friend…" />;
1205
1399
  ```
1206
1400
 
1207
1401
  **A handle is the thing the person typed as one — a platform handle, the same token `HandleInput` would resolve.** A plain first name ("share it with Priya") or an email address is a person the maker adds through the invite screen, which takes a handle or an email: the build has no way to know which account that name means, so the invite screen ships with this build and the narration says the maker can add that person there.
@@ -215,7 +215,7 @@ export default function App() {
215
215
  const pool = game?.squares ?? [];
216
216
  const poolFull = pool.length >= N * N - 1;
217
217
 
218
- const newGame = { _id: `default-${handle}`, type: "game", hostHandle: handle, title: "Bingo", squares: [] };
218
+ const newGame = { _id: `game-${handle}`, type: "game", hostHandle: handle, title: "Bingo", squares: [] };
219
219
  const canStart = handle && can.create(newGame).ok && !games.some((g) => g._id === newGame._id);
220
220
  const lines = (draft ?? pool.join("\n")).split("\n").map((s) => s.trim().slice(0, 40)).filter(Boolean);
221
221
  const canEditPool = game && can.edit({ ...game, squares: lines }).ok;
package/llms/callai.md CHANGED
@@ -96,7 +96,7 @@ function TaskCard({ task, database, can, onCorrect, onSaved, disabled }) {
96
96
  }
97
97
 
98
98
  export default function App() {
99
- const { database, useLiveQuery, writeRejection, clearWriteRejection } = useFireproof("tasks");
99
+ const { database, useLiveQuery } = useFireproof("tasks");
100
100
  const { me, can, ready } = useVibe("tasks");
101
101
  const { docs } = useLiveQuery("type", { key: "task" });
102
102
  const [sentence, setSentence] = useState("");
@@ -182,7 +182,6 @@ export default function App() {
182
182
  </form>
183
183
  <Text>{answer}</Text>
184
184
  <p role="status">{message}</p>
185
- {writeRejection && <Row gap={2} align="center" wrap><p role="status">{writeRejection.message}</p><Button type="button" size="sm" variant="quiet" onClick={clearWriteRejection}>Dismiss</Button></Row>}
186
185
  <Row gap={2} wrap>
187
186
  {lastWrite !== undefined && <Button type="button" size="sm" variant="quiet" disabled={busy} onClick={undoLast}>Undo</Button>}
188
187
  {pending !== undefined && !busy && <Button type="button" size="sm" variant="quiet" tone="down" onClick={() => { setPending(undefined); setSentence(""); setCurrent(undefined); }}>Discard unsaved tasks</Button>}
@@ -161,7 +161,7 @@ rather than land. Build on text and images; do not work around it with a raw
161
161
  `ctx.connections` call, which hits the same race.
162
162
 
163
163
  Posting on someone's behalf is a real act, so **let the person press the
164
- button** — post from a `fetch` route the app calls on an explicit action, and
164
+ button** — post from a `fetch` route the app calls on the person's own action, and
165
165
  never from a `scheduled` tick that posts for a visitor who is not there. A post
166
166
  that fails is worth telling them about, so surface the thrown error rather than
167
167
  swallowing it.