@vibes.diy/prompts 6.2.4 → 6.2.5

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/fireproof.md CHANGED
@@ -506,17 +506,14 @@ export function announcements(doc, oldDoc, user, ctx) {
506
506
 
507
507
  if (doc.type === "post") {
508
508
  // Author fixed at create; ownership immutable. On update a non-author may
509
- // touch ONLY the ImgGen version fields (so a viewer's <ImgGen> append passes)
510
- // every other key must match oldDoc (an allowlist; unlisted fields stay author-owned).
511
- const IMG = ["versions", "currentVersion", "currentPromptKey", "prompts", "prompt", "_files"];
512
- if (!oldDoc) {
509
+ // only append one legitimate ImgGen version the platform predicate
510
+ // ctx.isImgGenVersionAppend decides that (oldDoc is null on create).
511
+ if (oldDoc === null) {
513
512
  if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
514
- } else {
515
- if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "cannot change author" };
516
- const editsAuthorField = Object.keys({ ...doc, ...oldDoc }).some(
517
- (k) => !IMG.includes(k) && JSON.stringify(doc[k]) !== JSON.stringify(oldDoc[k])
518
- );
519
- if (editsAuthorField && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
513
+ } else if (doc.authorHandle !== oldDoc.authorHandle) {
514
+ throw { forbidden: "cannot change author" };
515
+ } else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
516
+ throw { forbidden: "not author" };
520
517
  }
521
518
  ctx.requireAccess(doc.channel);
522
519
  return { channels: [doc.channel] };
@@ -643,17 +640,14 @@ export function chat(doc, oldDoc, user, ctx) {
643
640
 
644
641
  if (doc.type === "post") {
645
642
  // Author fixed at create; ownership immutable. On update a non-author may
646
- // touch ONLY the ImgGen version fields every other key must match oldDoc
647
- // (an allowlist; unlisted fields stay author-owned by default).
648
- const IMG = ["versions", "currentVersion", "currentPromptKey", "prompts", "prompt", "_files"];
649
- if (!oldDoc) {
643
+ // only append one legitimate ImgGen version — the platform predicate
644
+ // ctx.isImgGenVersionAppend decides that (oldDoc is null on create).
645
+ if (oldDoc === null) {
650
646
  if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
651
- } else {
652
- if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "cannot change author" };
653
- const editsAuthorField = Object.keys({ ...doc, ...oldDoc }).some(
654
- (k) => !IMG.includes(k) && JSON.stringify(doc[k]) !== JSON.stringify(oldDoc[k])
655
- );
656
- if (editsAuthorField && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
647
+ } else if (doc.authorHandle !== oldDoc.authorHandle) {
648
+ throw { forbidden: "cannot change author" };
649
+ } else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
650
+ throw { forbidden: "not author" };
657
651
  }
658
652
  // Any signed-in author may post to this open channel. Do NOT call
659
653
  // ctx.requireAccess(doc.channel) here: the channel is grant.public
@@ -798,7 +792,7 @@ Access functions live in `/access.js`, a separate file in the vibe's filesystem
798
792
 
799
793
  **`requireAccess` checks _membership_, not public read — don't gate an open channel's writes on it.** `ctx.requireAccess(channelId)` passes only for a channel the user is a member of: granted directly through `grant.users[handle]`, or through a `grant.roles` role they hold. **`grant.public` does NOT satisfy `requireAccess`** — public is read-only ("anyone through the door can _read_"), it never confers write membership. So a channel that is only `grant.public` and gated on `ctx.requireAccess` can be written by **nobody** but the owner-in-admin-mode — every other write returns `not in channel`, silently hiding the form (`useVibe().can` faithfully reflects this). Choose by intent:
800
794
 
801
- - **Open channel — any signed-in user may post** (public board, guestbook, comment wall): do **not** call `ctx.requireAccess`. Route the doc to the channel and check the author on create — `if (!oldDoc && doc.authorHandle !== user.userHandle) throw { forbidden: "not author" }` — keep ownership immutable on update (`else if (doc.authorHandle !== oldDoc.authorHandle) throw`), and let a non-author's `<ImgGen>` version append through via the field-allowlist in "Author-equality gates create and ownership change" below; `return { channels: [doc.channelId] }`. `grant.public` on the channel doc gives everyone read; the write is open to any author.
795
+ - **Open channel — any signed-in user may post** (public board, guestbook, comment wall): do **not** call `ctx.requireAccess`. Route the doc to the channel and check the author on create — `if (oldDoc === null && doc.authorHandle !== user.userHandle) throw { forbidden: "not author" }` — keep ownership immutable on update (`else if (doc.authorHandle !== oldDoc.authorHandle) throw`), and let a non-author's `<ImgGen>` version append through via `ctx.isImgGenVersionAppend(doc, oldDoc)` as shown in "Author-equality gates create and ownership change" below; `return { channels: [doc.channelId] }`. `grant.public` on the channel doc gives everyone read; the write is open to any author.
802
796
  - **Restricted channel — only members may post**: gate the write on `ctx.requireAccess(doc.channelId)` **and** grant writers membership explicitly — `grant.users` (direct) or `grant.roles` + a `members`/role-grant doc. `public` alongside is read-only and is fine for letting non-members read, but it is never what lets a member write.
803
797
 
804
798
  ### AccessDescriptor return type
@@ -807,22 +801,19 @@ All fields are optional, but a stored document must be routed to at least one ch
807
801
 
808
802
  **Grant/member/meta docs need a channel too.** A role grant, membership, or config singleton that returns only `members`/`grant` with **no `channels`** is refused exactly like any other channel-less write — so the owner can't even create it. Route these to an owner-readable **admin channel** (e.g. `channels: ["admin:grants"]` with `grant: { users: { [user.userHandle]: ["admin:grants"] } }`), not a public channel. The `members`/`grant` still take effect globally; the channel just makes the doc persist and lets the owner read the roster back.
809
803
 
810
- **Author-equality gates create and ownership change, not every update.** Checking the new author field on create isn't enough — also pin ownership with `oldDoc`. But a blanket `if (oldDoc && oldDoc.<authorField> !== user.userHandle) throw` denies `<ImgGen>` version appends on a shared/public-read doc: a version append runs as the VIEWING user, and denying it after the platform already billed it arms a billed-retry loop (#3784/#3832). Fix the author at create, forbid re-authoring, and for a non-author update require every field EXCEPT the ImgGen-owned version fields to match `oldDoc` an **allowlist**, so a field you didn't anticipate stays author-protected by default (a denylist of a few named content fields silently unprotects the rest):
804
+ **Author-equality gates create and ownership change, not every update.** Checking the new author field on create isn't enough — also pin ownership with `oldDoc`. But a blanket `if (oldDoc && oldDoc.<authorField> !== user.userHandle) throw` denies `<ImgGen>` version appends on a shared/public-read doc: a version append runs as the VIEWING user, and denying it after the platform already billed it arms a billed-retry loop (#3784/#3832). Fix the author at create, forbid re-authoring, and for a non-author update accept only a legitimate ImgGen version appendthe platform predicate `ctx.isImgGenVersionAppend(doc, oldDoc)` decides that (`oldDoc` is `null` on create):
811
805
 
812
806
  ```js
813
- const IMG_FIELDS = ["versions", "currentVersion", "currentPromptKey", "prompts", "prompt", "_files"];
814
- if (!oldDoc) {
807
+ if (oldDoc === null) {
815
808
  if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
816
- } else {
817
- if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "cannot change author" };
818
- const changed = Object.keys({ ...doc, ...oldDoc }).some(
819
- (k) => !IMG_FIELDS.includes(k) && JSON.stringify(doc[k]) !== JSON.stringify(oldDoc[k])
820
- );
821
- if (changed && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
809
+ } else if (doc.authorHandle !== oldDoc.authorHandle) {
810
+ throw { forbidden: "cannot change author" };
811
+ } else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
812
+ throw { forbidden: "not author" };
822
813
  }
823
814
  ```
824
815
 
825
- `<authorField>` is whatever your doc uses (`authorHandle`, `userHandle`, `senderHandle`, …); `IMG_FIELDS` is exactly what `buildImgGenDocUpdate` writes. Write-once docs can simply `if (oldDoc) throw`; a genuinely private per-user doc no other viewer can reach may stay author-only on update.
816
+ `<authorField>` is whatever your doc uses (`authorHandle`, `userHandle`, `senderHandle`, …). The platform predicate `ctx.isImgGenVersionAppend` accepts exactly one legitimate ImgGen version append by a non-author — prior versions/files and all other fields must be unchanged, so a field you didn't anticipate stays author-protected by default; deletes stay author-only. Because `versions`/`currentVersion` are among the version fields it allows, a non-author can also advance the DISPLAYED version (the shared-generation feature); guard `currentVersion` specifically for author-only display control. Write-once docs can simply `if (oldDoc === null) {} else throw`; a genuinely private per-user doc no other viewer can reach may stay author-only on update.
826
817
 
827
818
  ```ts
828
819
  type AccessDescriptor = {
@@ -882,16 +873,14 @@ export function chat(doc, oldDoc, user, ctx) {
882
873
 
883
874
  if (doc.type === "message") {
884
875
  // Author fixed at create; ownership immutable. On update a non-author may
885
- // touch ONLY the ImgGen version fields every other key must match oldDoc.
886
- const IMG = ["versions", "currentVersion", "currentPromptKey", "prompts", "prompt", "_files"];
887
- if (!oldDoc) {
876
+ // only append one legitimate ImgGen version — the platform predicate
877
+ // ctx.isImgGenVersionAppend decides that (oldDoc is null on create).
878
+ if (oldDoc === null) {
888
879
  if (doc.userHandle !== user.userHandle) throw { forbidden: "not author" };
889
- } else {
890
- if (doc.userHandle !== oldDoc.userHandle) throw { forbidden: "cannot change author" };
891
- const editsAuthorField = Object.keys({ ...doc, ...oldDoc }).some(
892
- (k) => !IMG.includes(k) && JSON.stringify(doc[k]) !== JSON.stringify(oldDoc[k])
893
- );
894
- if (editsAuthorField && oldDoc.userHandle !== user.userHandle) throw { forbidden: "not author" };
880
+ } else if (doc.userHandle !== oldDoc.userHandle) {
881
+ throw { forbidden: "cannot change author" };
882
+ } else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.userHandle !== user.userHandle) {
883
+ throw { forbidden: "not author" };
895
884
  }
896
885
  ctx.requireAccess(doc.channelId);
897
886
  return { channels: [doc.channelId] };
package/llms/image-gen.md CHANGED
@@ -148,22 +148,19 @@ Prefer attaching images to the host doc whose access you already grant; reach fo
148
148
 
149
149
  **ImgGen does not stamp your app's author field.** The doc ImgGen writes (a standalone `type: "image"` doc, or the version it appends to a host doc) carries no `authorHandle`/`userHandle`/`senderHandle` of yours — that field is `null`/`undefined`. So an access-fn branch that author-gates the type ImgGen writes (`if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" }`) **rejects the write**: `null !== user.userHandle`, the signed-in maker included, and the image silently fails to persist as the doc you meant (`Failed to put document:`). Two fixes: (1) **attach the image to a host doc you created with the author field already stamped** — `<ImgGen _id={hostDoc._id} database={db} />` inherits the host's `type` and access, so the author check already passed when you wrote the host; this is the preferred shape. (2) For a genuinely **standalone** image doc, do NOT author-gate the `"image"` branch on strict author equality — route it to the channel you want and skip the author check (the platform's ImgGen write is server-authoritative, so there's no spoof to guard against). Never author-gate a type whose docs ImgGen originates without stamping the author yourself first.
150
150
 
151
- **A version append (regenerate, or an img2img edit on an existing `_id`) arrives as the VIEWING user, not the original author.** So even when you DID stamp the author on the host doc, an `if (oldDoc && doc.authorHandle !== user.userHandle) throw { forbidden: "not author" }` on that type denies any _other_ signed-in viewer's generation on a shared/public-read doc — after the platform already billed it, arming an unbounded billed-retry loop (#3784/#3832). Author-equality belongs on **create and ownership change**, not on every update. Fix the author at create, forbid re-authoring, and for a non-author update require every field EXCEPT the ImgGen-owned version fields to match `oldDoc` an allowlist, so a field you didn't anticipate stays author-protected by default:
151
+ **A version append (regenerate, or an img2img edit on an existing `_id`) arrives as the VIEWING user, not the original author.** So even when you DID stamp the author on the host doc, an `if (oldDoc && doc.authorHandle !== user.userHandle) throw { forbidden: "not author" }` on that type denies any _other_ signed-in viewer's generation on a shared/public-read doc — after the platform already billed it, arming an unbounded billed-retry loop (#3784/#3832). Author-equality belongs on **create and ownership change**, not on every update. Fix the author at create, forbid re-authoring, and for a non-author update accept only a legitimate ImgGen version append the platform predicate `ctx.isImgGenVersionAppend(doc, oldDoc)` decides that (`oldDoc` is `null` on create):
152
152
 
153
153
  ```js
154
- const IMG_FIELDS = ["versions", "currentVersion", "currentPromptKey", "prompts", "prompt", "_files"];
155
- if (!oldDoc) {
154
+ if (oldDoc === null) {
156
155
  if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
157
- } else {
158
- if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "cannot change author" };
159
- const changed = Object.keys({ ...doc, ...oldDoc }).some(
160
- (k) => !IMG_FIELDS.includes(k) && JSON.stringify(doc[k]) !== JSON.stringify(oldDoc[k])
161
- );
162
- if (changed && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
156
+ } else if (doc.authorHandle !== oldDoc.authorHandle) {
157
+ throw { forbidden: "cannot change author" };
158
+ } else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
159
+ throw { forbidden: "not author" };
163
160
  }
164
161
  ```
165
162
 
166
- `IMG_FIELDS` is exactly what `buildImgGenDocUpdate` writes, so a viewer's version append passes while any other field stays author-owned.
163
+ The platform predicate `ctx.isImgGenVersionAppend` accepts exactly one legitimate ImgGen version append by a non-author — prior versions/files and all other fields must be unchanged, so a field you didn't anticipate stays author-protected by default; deletes stay author-only. Because `versions`/`currentVersion` are among the version fields it allows, a non-author can also advance the DISPLAYED version (the shared-generation feature); guard `currentVersion` specifically if you need author-only display control.
167
164
 
168
165
  ## Choosing a Model
169
166
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibes.diy/prompts",
3
- "version": "6.2.4",
3
+ "version": "6.2.5",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "description": "",
@@ -24,9 +24,9 @@
24
24
  "license": "Apache-2.0",
25
25
  "dependencies": {
26
26
  "@adviser/cement": "~0.5.34",
27
- "@vibes.diy/call-ai-v2": "^6.2.4",
28
- "@vibes.diy/identity": "^6.2.4",
29
- "@vibes.diy/use-vibes-types": "^6.2.4",
27
+ "@vibes.diy/call-ai-v2": "^6.2.5",
28
+ "@vibes.diy/identity": "^6.2.5",
29
+ "@vibes.diy/use-vibes-types": "^6.2.5",
30
30
  "arktype": "~2.2.3",
31
31
  "json-schema-faker": "~0.6.2"
32
32
  },
@@ -75,17 +75,14 @@ export function wall(doc, oldDoc, user, ctx) {
75
75
 
76
76
  if (doc.type === "post") {
77
77
  // Author fixed at create; ownership immutable. On update a non-author may
78
- // touch ONLY the ImgGen version fields every other key must match oldDoc
79
- // (an allowlist, so unlisted fields stay author-owned by default).
80
- const IMG = ["versions", "currentVersion", "currentPromptKey", "prompts", "prompt", "_files"];
81
- if (!oldDoc) {
78
+ // only append one legitimate ImgGen version — the platform predicate
79
+ // ctx.isImgGenVersionAppend decides that (oldDoc is null on create).
80
+ if (oldDoc === null) {
82
81
  if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
83
- } else {
84
- if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "cannot change author" };
85
- const editsAuthorField = Object.keys({ ...doc, ...oldDoc }).some(
86
- (k) => !IMG.includes(k) && JSON.stringify(doc[k]) !== JSON.stringify(oldDoc[k])
87
- );
88
- if (editsAuthorField && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
82
+ } else if (doc.authorHandle !== oldDoc.authorHandle) {
83
+ throw { forbidden: "cannot change author" };
84
+ } else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
85
+ throw { forbidden: "not author" };
89
86
  }
90
87
  return { channels: [doc.channelId] };
91
88
  }
@@ -121,11 +118,36 @@ export function board(doc, oldDoc, user, ctx) {
121
118
  }
122
119
  ```
123
120
 
124
- `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 of shared-visible docs, keep ownership immutable (`doc.authorHandle !== oldDoc.authorHandle` → throw) rather than requiring the updater to BE the author — image version appends run as the viewing user, and an author-only update gate turns every other viewer's generation into a billed deny-retry. Reserve author-only updates for genuinely private per-user docs; on shared docs, let a non-author change ONLY the ImgGen version fields and require every other field to match `oldDoc` (an allowlist, as `wall.post` above shows — never a denylist of a few named content fields).
121
+ `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 of shared-visible docs, keep ownership immutable (`doc.authorHandle !== oldDoc.authorHandle` → throw) rather than requiring the updater to BE the author — image version appends run as the viewing user, and an author-only update gate turns every other viewer's generation into a billed deny-retry. Reserve author-only updates for genuinely private per-user docs; on shared docs, let a non-author through only when `ctx.isImgGenVersionAppend(doc, oldDoc)` accepts the write (as `wall.post` above shows)it permits exactly one legitimate ImgGen version append and requires every other field unchanged.
125
122
 
126
123
  **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 todos, 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.
127
124
 
128
- **Most apps are collaborative even when they sound sologive each person their own, and let them invite others in.** A todo list, a habit tracker, a journal, a notes app, a workout log, or a budget belongs to its creator by default: route each list/log/board to its **own object channel** (`list:<id>`/`log:<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 owner invite a chosen friend in: a `share` doc the owner 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. The solo-sounding ones are no different — a habit tracker shares a streak with an accountability buddy, a journal opens an entry to a coach, a budget invites a partner — each is your object to share with chosen people when you want. 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.
125
+ **A personal list, tracker, journal, or notes app one the prompt frames as the user's own, with no sharing asked for gives every visitor their own private space on a single per-user channel.** A todo list, a daily habit tracker, a reading list, a diary, a notes app, a workout log, or a budget where the data is one person's own routes every doc that visitor creates to the one channel keyed on their handle `user:${user.userHandle}` self-granted so only they read it, with `authorHandle: user.userHandle` fixed at create and held immutable on update (`oldDoc.authorHandle === user.userHandle`). Their whole collection lives on that single private channel, reachable from first load, each visitor's space entirely their own:
126
+
127
+ access.js
128
+
129
+ ```js
130
+ export function notes(doc, oldDoc, user, ctx) {
131
+ if (!user) throw { forbidden: "sign in" };
132
+ const ch = `user:${user.userHandle}`; // this visitor's own private space
133
+
134
+ if (doc.type === "note") {
135
+ if (!oldDoc) {
136
+ if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
137
+ } else {
138
+ if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "cannot change author" };
139
+ if (oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
140
+ }
141
+ return { channels: [ch], grant: { users: { [user.userHandle]: [ch] } } };
142
+ }
143
+
144
+ throw { forbidden: "unknown document type" };
145
+ }
146
+ ```
147
+
148
+ 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 near their content. 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.
149
+
150
+ **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 shopping list you invite your partner 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.
129
151
 
130
152
  **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 near their content, so no one wonders who's watching their unfinished work. Then, where sharing fits the app, give them a publish control — 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. **If the app brief is private-only/confidential — a journal, private notes, a health tracker — do not add publish/share controls.** Keep publish opt-in: for private-only apps, omit the publish UI entirely; for share-capable apps, make publish one tap for user-selected items.
131
153
 
@@ -167,12 +189,25 @@ export function habits(doc, oldDoc, user, ctx) {
167
189
  }
168
190
  ```
169
191
 
170
- The leaderboard is just the access model: read the public `track:` channels and sum them; each viewer additionally sees their own streaks and any buddy who granted them in. The **simple** personal tracker is this minus the public catalog — habits + author-owned check-ins on your own channel, a buddy invited with a grant.
192
+ The leaderboard is just the access model: read the public `track:` channels and sum them; each viewer additionally sees their own streaks and any buddy who granted them in. A **plain daily habit tracker** one framed as the user's own, with no catalog, leaderboard, or buddy asked for is instead the per-visitor shape shown above: every visitor's habits and check-ins on their single `user:${user.userHandle}` channel, self-granted and private to them.
171
193
 
172
194
  **"Invite", "join", "people can join", "collaborate", "share with", "together", "with my partner/team", or a board/canvas/room/whiteboard a group co-edits → per-object collaboration** (the second worked example above) — each shared thing is ONE object its members reach directly; it needs no owner. Use the per-object recipe: a channel per object (`board:<id>`/`list:<id>`); 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).
173
195
 
174
196
  **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`.
175
197
 
198
+ The personal blog's post branch is small — public read is the blog's resting state, declared inline on every post result (readers are the point of publishing):
199
+
200
+ ```js
201
+ if (doc.type === "post") {
202
+ if (!oldDoc) {
203
+ if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
204
+ } else if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "author is fixed" };
205
+ return { channels: ["posts"], grant: { public: ["posts"] } };
206
+ }
207
+ ```
208
+
209
+ When the prompt also asks for drafts, keep the same public resting state for posts and give drafts their own author-only channel (`draft:<handle>`, granted just to the author) — publishing moves the doc to the public `posts` channel.
210
+
176
211
  **Sharing objects: channels carry objects, roles carry types.** A channel is one shareable thing (`list:<id>`) — membership in it means "can reach this object." A role is a _kind_ of participant (`author`, `editor`), a small reusable vocabulary. To let people collaborate on _their own_ objects with no admin in the loop: the creator routes the object to its channel and grants themselves access; any member shares it by granting a peer into the same channel; child docs gate on `ctx.requireAccess("list:<id>")`. The reserved `owner` role is the only one auto-seeded — every _other_ role is populated by the app's own grant docs (a member writes a doc whose access-fn output adds another user to a role or channel). So to put someone in a role, write a grant; don't expect a role to be pre-filled. Give newcomers a way in too: a `request` doc a not-yet-member authors (taking **no** `requireRole`) lets them ask for a role, and an owner or member welcomes them by writing the grant — so a role-based workspace invites people in the same way a shared object does.
177
212
  ## Social: followers see your stuff (platform graph)
178
213
 
@@ -190,11 +225,57 @@ docs in your database, and never build follow UI state machines.
190
225
  follow again later (blocking is harsher and lives in Settings, not in your app).
191
226
  Following a private account sits at `state:"requested"` and grants no reads until
192
227
  approved — filter to `state === "active"` when deciding whose content to show.
193
- - Make a doc follower-visible from access.js by ADDING `audience` to a normal result:
194
- `return { channels: ["share-" + owner], audience: { followersOf: owner } };`
195
- (`mutualsOf` for both-directions-only). Resolution is live: new followers instantly see
196
- history; unfollow/removeFollower/block instantly revokes. The owner is always in their
197
- own audience no self-grant needed. Keep at least one real channel; keep PRIVATE data
228
+ - Make a doc follower-visible from access.js by ADDING `audience` to a normal result, keyed on the
229
+ writer's own handle `user.userHandle`. Write the access function for a follower-visible app as
230
+ `export default function (doc, oldDoc, user, ctx)` the platform reads the writer from that default
231
+ export's third positional parameter `user`, so `user.userHandle` names the live writer and the
232
+ audience resolves against that writer's own graph:
233
+
234
+ ```js
235
+ export default function (doc, oldDoc, user, ctx) {
236
+ if (!user) throw { forbidden: "sign in" };
237
+ const ch = "picks:" + user.userHandle; // the writer's own channel
238
+ if (doc._deleted) {
239
+ // A tombstone carries no fields — authorize AND route it off oldDoc.
240
+ if (oldDoc.authorHandle !== user.userHandle) throw { forbidden: "your own docs" };
241
+ if (oldDoc.type === "reaction" || oldDoc.type === "comment")
242
+ return { channels: ["picks:" + oldDoc.pickOwnerHandle] };
243
+ return { channels: [ch], audience: { followersOf: user.userHandle } };
244
+ }
245
+ if (doc.type === "reaction" || doc.type === "comment") {
246
+ // A reaction/comment on SOMEONE ELSE'S pick rides that pick's channel — the
247
+ // pick's own audience already carries it to the right readers, so it returns
248
+ // channels only. Audience belongs to the doc types the WRITER shares.
249
+ // Author and target are fixed at create.
250
+ if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
251
+ if (oldDoc) {
252
+ if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "author is fixed" };
253
+ if (doc.pickOwnerHandle !== oldDoc.pickOwnerHandle) throw { forbidden: "stays on its pick" };
254
+ }
255
+ return { channels: ["picks:" + doc.pickOwnerHandle] };
256
+ }
257
+ // A pick is the writer's own per-user doc: author fixed at create, and only
258
+ // the author updates it — so the channel and audience stay the author's own.
259
+ if (!oldDoc) {
260
+ if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
261
+ } else {
262
+ if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "author is fixed" };
263
+ if (oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
264
+ }
265
+ return { channels: [ch], audience: { followersOf: user.userHandle } };
266
+ }
267
+ ```
268
+
269
+ (`mutualsOf: user.userHandle` for both-directions-only). The subject is always
270
+ `user.userHandle` — the handle writing this doc — in EVERY branch that returns an audience: the
271
+ create, the update, and the delete tombstone alike (a delete runs as the author, so
272
+ `user.userHandle` is the right subject there too — reach for `user.userHandle`, the live writer,
273
+ rather than `oldDoc.authorHandle` or a stored field). The platform resolves that subject live
274
+ against the writer's own graph: new followers instantly see history; unfollow/removeFollower/block
275
+ instantly revokes. The writer is always in their own audience — no self-grant needed. When a
276
+ reaction or comment should appear to a DIFFERENT person's audience, route it to that item's shared
277
+ channel and let the item's own audience carry it, keeping each `followersOf`/`mutualsOf` subject
278
+ `user.userHandle`. Keep at least one real channel; keep PRIVATE data
198
279
  on channels+grant without `audience`; never write channel names starting with `~`.
199
280
  - Copy: "followers can see your picks" / "people you follow" — never "friends".
200
281
  Following someone is low-stakes (it reveals none of YOUR data) — no confirm dialogs.
@@ -225,23 +306,19 @@ docs in your database, and never build follow UI state machines.
225
306
 
226
307
  **The owner must never be locked out of their own app.** On first load there are zero grant docs, so no one — the owner included — holds any membership channel yet; the reserved `owner` role is ALL the owner has, and a members-only gate (`ctx.requireAccess(ch)`) denies the owner exactly like a stranger. Two rules keep the owner in. In `access.js`: when a grant branch is itself owner-gated (`ctx.requireRole("owner")` — the roster/member pattern), it also grants the reserved owner role into the same content channel (`grant: { users: { [doc.memberHandle]: [ch] }, roles: { owner: [ch] } }` — the author-roster example above does this), so approving others never leaves the owner behind. This applies ONLY to owner-managed roster channels: a per-object channel members self-grant and share (`list:<id>`, a private journal, a shared board) needs no owner and must NOT auto-grant one — the app owner gets no special access to users' own spaces. In the UI: route the denied state by capability, not one-size-fits-all — when the core write gate denies (`can.create({ type: "post", ... }).ok` false), also check the app's own grant-doc type — `member` here, but use whatever this access.js names it (`author`, `share`, `approve`): `can.create({ type: "member", userHandle: me?.userHandle }).ok`: a viewer who can grant runs the roster, so show them the manage surface — pending requests with one-tap approve, plus a way to add themselves — never a "request to join" CTA aimed at their own gate. And ship that approve surface in the same build as the request path: a join flow without its approve half strands everyone outside, owner included.
227
308
 
228
- **Author-equality gates `create` and ownership change — NOT every update.** A shared-visible doc (public read, a gallery/catalog others browse) that `<ImgGen>` appends onto is written by whoever is _looking at it_: a version append runs as the VIEWING user, so a blanket `if (oldDoc && doc.authorHandle !== user.userHandle) throw` denies every other viewer's generation — after it was already billed — arming an unbounded billed-retry loop (#3784/#3832). Fix the author at create, forbid re-authoring, and for a non-author update require every field EXCEPT the ImgGen-owned version fields to be unchanged an **allowlist**, so a field you didn't anticipate stays author-protected by default (a denylist of named content fields silently unprotects anything not listed — the exact miss to avoid):
309
+ **Author-equality gates `create` and ownership change — NOT every update.** A shared-visible doc (public read, a gallery/catalog others browse) that `<ImgGen>` appends onto is written by whoever is _looking at it_: a version append runs as the VIEWING user, so a blanket `if (oldDoc && doc.authorHandle !== user.userHandle) throw` denies every other viewer's generation — after it was already billed — arming an unbounded billed-retry loop (#3784/#3832). Fix the author at create, forbid re-authoring, and for a non-author update accept only a legitimate ImgGen version appendthe platform predicate `ctx.isImgGenVersionAppend(doc, oldDoc)` decides that (`oldDoc` is `null` on create):
229
310
 
230
311
  ```js
231
- // ImgGen owns exactly these keys on a host doc; everything else is author-owned.
232
- const IMG_FIELDS = ["versions", "currentVersion", "currentPromptKey", "prompts", "prompt", "_files"];
233
- if (!oldDoc) {
312
+ if (oldDoc === null) {
234
313
  if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
235
- } else {
236
- if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "cannot change author" };
237
- const changedAuthorField = Object.keys({ ...doc, ...oldDoc }).some(
238
- (k) => !IMG_FIELDS.includes(k) && JSON.stringify(doc[k]) !== JSON.stringify(oldDoc[k])
239
- );
240
- if (changedAuthorField && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
314
+ } else if (doc.authorHandle !== oldDoc.authorHandle) {
315
+ throw { forbidden: "cannot change author" };
316
+ } else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
317
+ throw { forbidden: "not author" };
241
318
  }
242
319
  ```
243
320
 
244
- Deletes may stay author-only. `IMG_FIELDS` is exactly what `buildImgGenDocUpdate` writes (`versions`/`currentVersion`/`currentPromptKey`/`prompts`/`prompt`/`_files` it preserves `type`/`created`), so a non-author's version append touches only these and everything else must match `oldDoc`. Note `_files` merges: a non-author append can also replace a named source file under this shape, so if the app stores author-owned uploads in `_files`, compare those entries too or accept it. (A private per-user channel that no other viewer can reach is exempt — this bites the shared/public-read shapes.)
321
+ The platform predicate `ctx.isImgGenVersionAppend` accepts exactly one legitimate ImgGen version append by a non-author prior versions/files and all other fields must be unchanged, so a field you didn't anticipate stays author-protected by default. Deletes stay author-only, and a non-author advancing `currentVersion` is the shared-generation display behavior (an app wanting author-only display control must guard `currentVersion` specifically; a private per-user channel no other viewer can reach is exempt).
245
322
 
246
323
  **Public vs private is the owner's ACL envelope, not your code.** Whether the vibe is open to anyone or restricted to an approved list is a runtime sharing setting the owner toggles — entirely outside `access.js`. Keep `access.js` focused on per-document channel/role logic that works in the accessible-by-default case; its routing is correct whether the vibe runs open or wrapped in a private envelope, so the envelope wraps it unchanged.
247
324
 
@@ -76,17 +76,14 @@ export function wall(doc, oldDoc, user, ctx) {
76
76
 
77
77
  if (doc.type === "post") {
78
78
  // Author fixed at create; ownership immutable. On update a non-author may
79
- // touch ONLY the ImgGen version fields every other key must match oldDoc
80
- // (an allowlist, so unlisted fields stay author-owned by default).
81
- const IMG = ["versions", "currentVersion", "currentPromptKey", "prompts", "prompt", "_files"];
82
- if (!oldDoc) {
79
+ // only append one legitimate ImgGen version — the platform predicate
80
+ // ctx.isImgGenVersionAppend decides that (oldDoc is null on create).
81
+ if (oldDoc === null) {
83
82
  if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
84
- } else {
85
- if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "cannot change author" };
86
- const editsAuthorField = Object.keys({ ...doc, ...oldDoc }).some(
87
- (k) => !IMG.includes(k) && JSON.stringify(doc[k]) !== JSON.stringify(oldDoc[k])
88
- );
89
- if (editsAuthorField && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
83
+ } else if (doc.authorHandle !== oldDoc.authorHandle) {
84
+ throw { forbidden: "cannot change author" };
85
+ } else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
86
+ throw { forbidden: "not author" };
90
87
  }
91
88
  return { channels: [doc.channelId] };
92
89
  }
@@ -122,11 +119,36 @@ export function board(doc, oldDoc, user, ctx) {
122
119
  }
123
120
  ```
124
121
 
125
- `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 of shared-visible docs, keep ownership immutable (`doc.authorHandle !== oldDoc.authorHandle` → throw) rather than requiring the updater to BE the author — image version appends run as the viewing user, and an author-only update gate turns every other viewer's generation into a billed deny-retry. Reserve author-only updates for genuinely private per-user docs; on shared docs, let a non-author change ONLY the ImgGen version fields and require every other field to match `oldDoc` (an allowlist, as `wall.post` above shows — never a denylist of a few named content fields).
122
+ `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 of shared-visible docs, keep ownership immutable (`doc.authorHandle !== oldDoc.authorHandle` → throw) rather than requiring the updater to BE the author — image version appends run as the viewing user, and an author-only update gate turns every other viewer's generation into a billed deny-retry. Reserve author-only updates for genuinely private per-user docs; on shared docs, let a non-author through only when `ctx.isImgGenVersionAppend(doc, oldDoc)` accepts the write (as `wall.post` above shows)it permits exactly one legitimate ImgGen version append and requires every other field unchanged.
126
123
 
127
124
  **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 todos, 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.
128
125
 
129
- **Most apps are collaborative even when they sound sologive each person their own, and let them invite others in.** A todo list, a habit tracker, a journal, a notes app, a workout log, or a budget belongs to its creator by default: route each list/log/board to its **own object channel** (`list:<id>`/`log:<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 owner invite a chosen friend in: a `share` doc the owner 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. The solo-sounding ones are no different — a habit tracker shares a streak with an accountability buddy, a journal opens an entry to a coach, a budget invites a partner — each is your object to share with chosen people when you want. 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.
126
+ **A personal list, tracker, journal, or notes app one the prompt frames as the user's own, with no sharing asked for gives every visitor their own private space on a single per-user channel.** A todo list, a daily habit tracker, a reading list, a diary, a notes app, a workout log, or a budget where the data is one person's own routes every doc that visitor creates to the one channel keyed on their handle `user:${user.userHandle}` self-granted so only they read it, with `authorHandle: user.userHandle` fixed at create and held immutable on update (`oldDoc.authorHandle === user.userHandle`). Their whole collection lives on that single private channel, reachable from first load, each visitor's space entirely their own:
127
+
128
+ access.js
129
+
130
+ ```js
131
+ export function notes(doc, oldDoc, user, ctx) {
132
+ if (!user) throw { forbidden: "sign in" };
133
+ const ch = `user:${user.userHandle}`; // this visitor's own private space
134
+
135
+ if (doc.type === "note") {
136
+ if (!oldDoc) {
137
+ if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
138
+ } else {
139
+ if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "cannot change author" };
140
+ if (oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
141
+ }
142
+ return { channels: [ch], grant: { users: { [user.userHandle]: [ch] } } };
143
+ }
144
+
145
+ throw { forbidden: "unknown document type" };
146
+ }
147
+ ```
148
+
149
+ 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 near their content. 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.
150
+
151
+ **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 shopping list you invite your partner 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.
130
152
 
131
153
  **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 near their content, so no one wonders who's watching their unfinished work. Then, where sharing fits the app, give them a publish control — 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. **If the app brief is private-only/confidential — a journal, private notes, a health tracker — do not add publish/share controls.** Keep publish opt-in: for private-only apps, omit the publish UI entirely; for share-capable apps, make publish one tap for user-selected items.
132
154
 
@@ -168,12 +190,25 @@ export function habits(doc, oldDoc, user, ctx) {
168
190
  }
169
191
  ```
170
192
 
171
- The leaderboard is just the access model: read the public `track:` channels and sum them; each viewer additionally sees their own streaks and any buddy who granted them in. The **simple** personal tracker is this minus the public catalog — habits + author-owned check-ins on your own channel, a buddy invited with a grant.
193
+ The leaderboard is just the access model: read the public `track:` channels and sum them; each viewer additionally sees their own streaks and any buddy who granted them in. A **plain daily habit tracker** one framed as the user's own, with no catalog, leaderboard, or buddy asked for is instead the per-visitor shape shown above: every visitor's habits and check-ins on their single `user:${user.userHandle}` channel, self-granted and private to them.
172
194
 
173
195
  **"Invite", "join", "people can join", "collaborate", "share with", "together", "with my partner/team", or a board/canvas/room/whiteboard a group co-edits → per-object collaboration** (the second worked example above) — each shared thing is ONE object its members reach directly; it needs no owner. Use the per-object recipe: a channel per object (`board:<id>`/`list:<id>`); 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).
174
196
 
175
197
  **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`.
176
198
 
199
+ The personal blog's post branch is small — public read is the blog's resting state, declared inline on every post result (readers are the point of publishing):
200
+
201
+ ```js
202
+ if (doc.type === "post") {
203
+ if (!oldDoc) {
204
+ if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
205
+ } else if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "author is fixed" };
206
+ return { channels: ["posts"], grant: { public: ["posts"] } };
207
+ }
208
+ ```
209
+
210
+ When the prompt also asks for drafts, keep the same public resting state for posts and give drafts their own author-only channel (`draft:<handle>`, granted just to the author) — publishing moves the doc to the public `posts` channel.
211
+
177
212
  **Sharing objects: channels carry objects, roles carry types.** A channel is one shareable thing (`list:<id>`) — membership in it means "can reach this object." A role is a _kind_ of participant (`author`, `editor`), a small reusable vocabulary. To let people collaborate on _their own_ objects with no admin in the loop: the creator routes the object to its channel and grants themselves access; any member shares it by granting a peer into the same channel; child docs gate on `ctx.requireAccess("list:<id>")`. The reserved `owner` role is the only one auto-seeded — every _other_ role is populated by the app's own grant docs (a member writes a doc whose access-fn output adds another user to a role or channel). So to put someone in a role, write a grant; don't expect a role to be pre-filled. Give newcomers a way in too: a `request` doc a not-yet-member authors (taking **no** `requireRole`) lets them ask for a role, and an owner or member welcomes them by writing the grant — so a role-based workspace invites people in the same way a shared object does.
178
213
  ## Social: followers see your stuff (platform graph)
179
214
 
@@ -191,11 +226,57 @@ docs in your database, and never build follow UI state machines.
191
226
  follow again later (blocking is harsher and lives in Settings, not in your app).
192
227
  Following a private account sits at `state:"requested"` and grants no reads until
193
228
  approved — filter to `state === "active"` when deciding whose content to show.
194
- - Make a doc follower-visible from access.js by ADDING `audience` to a normal result:
195
- `return { channels: ["share-" + owner], audience: { followersOf: owner } };`
196
- (`mutualsOf` for both-directions-only). Resolution is live: new followers instantly see
197
- history; unfollow/removeFollower/block instantly revokes. The owner is always in their
198
- own audience no self-grant needed. Keep at least one real channel; keep PRIVATE data
229
+ - Make a doc follower-visible from access.js by ADDING `audience` to a normal result, keyed on the
230
+ writer's own handle `user.userHandle`. Write the access function for a follower-visible app as
231
+ `export default function (doc, oldDoc, user, ctx)` the platform reads the writer from that default
232
+ export's third positional parameter `user`, so `user.userHandle` names the live writer and the
233
+ audience resolves against that writer's own graph:
234
+
235
+ ```js
236
+ export default function (doc, oldDoc, user, ctx) {
237
+ if (!user) throw { forbidden: "sign in" };
238
+ const ch = "picks:" + user.userHandle; // the writer's own channel
239
+ if (doc._deleted) {
240
+ // A tombstone carries no fields — authorize AND route it off oldDoc.
241
+ if (oldDoc.authorHandle !== user.userHandle) throw { forbidden: "your own docs" };
242
+ if (oldDoc.type === "reaction" || oldDoc.type === "comment")
243
+ return { channels: ["picks:" + oldDoc.pickOwnerHandle] };
244
+ return { channels: [ch], audience: { followersOf: user.userHandle } };
245
+ }
246
+ if (doc.type === "reaction" || doc.type === "comment") {
247
+ // A reaction/comment on SOMEONE ELSE'S pick rides that pick's channel — the
248
+ // pick's own audience already carries it to the right readers, so it returns
249
+ // channels only. Audience belongs to the doc types the WRITER shares.
250
+ // Author and target are fixed at create.
251
+ if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
252
+ if (oldDoc) {
253
+ if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "author is fixed" };
254
+ if (doc.pickOwnerHandle !== oldDoc.pickOwnerHandle) throw { forbidden: "stays on its pick" };
255
+ }
256
+ return { channels: ["picks:" + doc.pickOwnerHandle] };
257
+ }
258
+ // A pick is the writer's own per-user doc: author fixed at create, and only
259
+ // the author updates it — so the channel and audience stay the author's own.
260
+ if (!oldDoc) {
261
+ if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
262
+ } else {
263
+ if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "author is fixed" };
264
+ if (oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
265
+ }
266
+ return { channels: [ch], audience: { followersOf: user.userHandle } };
267
+ }
268
+ ```
269
+
270
+ (`mutualsOf: user.userHandle` for both-directions-only). The subject is always
271
+ `user.userHandle` — the handle writing this doc — in EVERY branch that returns an audience: the
272
+ create, the update, and the delete tombstone alike (a delete runs as the author, so
273
+ `user.userHandle` is the right subject there too — reach for `user.userHandle`, the live writer,
274
+ rather than `oldDoc.authorHandle` or a stored field). The platform resolves that subject live
275
+ against the writer's own graph: new followers instantly see history; unfollow/removeFollower/block
276
+ instantly revokes. The writer is always in their own audience — no self-grant needed. When a
277
+ reaction or comment should appear to a DIFFERENT person's audience, route it to that item's shared
278
+ channel and let the item's own audience carry it, keeping each `followersOf`/`mutualsOf` subject
279
+ `user.userHandle`. Keep at least one real channel; keep PRIVATE data
199
280
  on channels+grant without `audience`; never write channel names starting with `~`.
200
281
  - Copy: "followers can see your picks" / "people you follow" — never "friends".
201
282
  Following someone is low-stakes (it reveals none of YOUR data) — no confirm dialogs.
@@ -226,23 +307,19 @@ docs in your database, and never build follow UI state machines.
226
307
 
227
308
  **The owner must never be locked out of their own app.** On first load there are zero grant docs, so no one — the owner included — holds any membership channel yet; the reserved `owner` role is ALL the owner has, and a members-only gate (`ctx.requireAccess(ch)`) denies the owner exactly like a stranger. Two rules keep the owner in. In `access.js`: when a grant branch is itself owner-gated (`ctx.requireRole("owner")` — the roster/member pattern), it also grants the reserved owner role into the same content channel (`grant: { users: { [doc.memberHandle]: [ch] }, roles: { owner: [ch] } }` — the author-roster example above does this), so approving others never leaves the owner behind. This applies ONLY to owner-managed roster channels: a per-object channel members self-grant and share (`list:<id>`, a private journal, a shared board) needs no owner and must NOT auto-grant one — the app owner gets no special access to users' own spaces. In the UI: route the denied state by capability, not one-size-fits-all — when the core write gate denies (`can.create({ type: "post", ... }).ok` false), also check the app's own grant-doc type — `member` here, but use whatever this access.js names it (`author`, `share`, `approve`): `can.create({ type: "member", userHandle: me?.userHandle }).ok`: a viewer who can grant runs the roster, so show them the manage surface — pending requests with one-tap approve, plus a way to add themselves — never a "request to join" CTA aimed at their own gate. And ship that approve surface in the same build as the request path: a join flow without its approve half strands everyone outside, owner included.
228
309
 
229
- **Author-equality gates `create` and ownership change — NOT every update.** A shared-visible doc (public read, a gallery/catalog others browse) that `<ImgGen>` appends onto is written by whoever is _looking at it_: a version append runs as the VIEWING user, so a blanket `if (oldDoc && doc.authorHandle !== user.userHandle) throw` denies every other viewer's generation — after it was already billed — arming an unbounded billed-retry loop (#3784/#3832). Fix the author at create, forbid re-authoring, and for a non-author update require every field EXCEPT the ImgGen-owned version fields to be unchanged an **allowlist**, so a field you didn't anticipate stays author-protected by default (a denylist of named content fields silently unprotects anything not listed — the exact miss to avoid):
310
+ **Author-equality gates `create` and ownership change — NOT every update.** A shared-visible doc (public read, a gallery/catalog others browse) that `<ImgGen>` appends onto is written by whoever is _looking at it_: a version append runs as the VIEWING user, so a blanket `if (oldDoc && doc.authorHandle !== user.userHandle) throw` denies every other viewer's generation — after it was already billed — arming an unbounded billed-retry loop (#3784/#3832). Fix the author at create, forbid re-authoring, and for a non-author update accept only a legitimate ImgGen version appendthe platform predicate `ctx.isImgGenVersionAppend(doc, oldDoc)` decides that (`oldDoc` is `null` on create):
230
311
 
231
312
  ```js
232
- // ImgGen owns exactly these keys on a host doc; everything else is author-owned.
233
- const IMG_FIELDS = ["versions", "currentVersion", "currentPromptKey", "prompts", "prompt", "_files"];
234
- if (!oldDoc) {
313
+ if (oldDoc === null) {
235
314
  if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
236
- } else {
237
- if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "cannot change author" };
238
- const changedAuthorField = Object.keys({ ...doc, ...oldDoc }).some(
239
- (k) => !IMG_FIELDS.includes(k) && JSON.stringify(doc[k]) !== JSON.stringify(oldDoc[k])
240
- );
241
- if (changedAuthorField && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
315
+ } else if (doc.authorHandle !== oldDoc.authorHandle) {
316
+ throw { forbidden: "cannot change author" };
317
+ } else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
318
+ throw { forbidden: "not author" };
242
319
  }
243
320
  ```
244
321
 
245
- Deletes may stay author-only. `IMG_FIELDS` is exactly what `buildImgGenDocUpdate` writes (`versions`/`currentVersion`/`currentPromptKey`/`prompts`/`prompt`/`_files` it preserves `type`/`created`), so a non-author's version append touches only these and everything else must match `oldDoc`. Note `_files` merges: a non-author append can also replace a named source file under this shape, so if the app stores author-owned uploads in `_files`, compare those entries too or accept it. (A private per-user channel that no other viewer can reach is exempt — this bites the shared/public-read shapes.)
322
+ The platform predicate `ctx.isImgGenVersionAppend` accepts exactly one legitimate ImgGen version append by a non-author prior versions/files and all other fields must be unchanged, so a field you didn't anticipate stays author-protected by default. Deletes stay author-only, and a non-author advancing `currentVersion` is the shared-generation display behavior (an app wanting author-only display control must guard `currentVersion` specifically; a private per-user channel no other viewer can reach is exempt).
246
323
 
247
324
  **Public vs private is the owner's ACL envelope, not your code.** Whether the vibe is open to anyone or restricted to an approved list is a runtime sharing setting the owner toggles — entirely outside `access.js`. Keep `access.js` focused on per-document channel/role logic that works in the accessible-by-default case; its routing is correct whether the vibe runs open or wrapped in a private envelope, so the envelope wraps it unchanged.
248
325
 
package/system-prompt.md CHANGED
@@ -286,17 +286,14 @@ export function chat(doc, oldDoc, user, ctx) {
286
286
 
287
287
  if (doc.type === "message") {
288
288
  // Author fixed at create; ownership immutable. On update a non-author may
289
- // touch ONLY the ImgGen version fields every other key must match oldDoc
290
- // (an allowlist, so unlisted fields stay author-owned by default).
291
- const IMG = ["versions", "currentVersion", "currentPromptKey", "prompts", "prompt", "_files"];
292
- if (!oldDoc) {
289
+ // only append one legitimate ImgGen version — the platform predicate
290
+ // ctx.isImgGenVersionAppend decides that (oldDoc is null on create).
291
+ if (oldDoc === null) {
293
292
  if (doc.userHandle !== user.userHandle) throw { forbidden: "not author" };
294
- } else {
295
- if (doc.userHandle !== oldDoc.userHandle) throw { forbidden: "cannot change author" };
296
- const editsAuthorField = Object.keys({ ...doc, ...oldDoc }).some(
297
- (k) => !IMG.includes(k) && JSON.stringify(doc[k]) !== JSON.stringify(oldDoc[k])
298
- );
299
- if (editsAuthorField && oldDoc.userHandle !== user.userHandle) throw { forbidden: "not author" };
293
+ } else if (doc.userHandle !== oldDoc.userHandle) {
294
+ throw { forbidden: "cannot change author" };
295
+ } else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.userHandle !== user.userHandle) {
296
+ throw { forbidden: "not author" };
300
297
  }
301
298
  ctx.requireAccess(doc.channelId);
302
299
  return { channels: [doc.channelId] };
@@ -306,7 +303,7 @@ export function chat(doc, oldDoc, user, ctx) {
306
303
  }
307
304
  ```
308
305
 
309
- `ctx.requireAccess(channel)` gates on channel **membership** (a `grant.users`/`grant.roles` grant), NOT on `grant.public`, which is read-only — so a channel anyone signed-in should post to must **not** gate writes on `requireAccess` (it would block every non-owner); just check the author and route the doc. Reserve `requireAccess` for members-only channels whose writers you granted membership. For writes that need no sign-in at all ("anyone can sign/submit"), return `allowAnonymous: true` instead of throwing on `!user`. A grant/member/role doc must also return `channels` (route it to an owner-readable admin channel like `["admin:grants"]`) — a channel-less result is rejected as "unreadable write". On updates of shared-visible docs, keep ownership immutable (`if (oldDoc && doc.<authorField> !== oldDoc.<authorField>) throw`, where `<authorField>` is your doc's author field — `authorHandle`/`userHandle`/`senderHandle`) rather than requiring the updater to BE the author — image version appends run as the VIEWING user, and an author-only update gate turns every other viewer's generation into a billed deny-retry. Reserve author-only updates for genuinely private per-user docs; on shared docs, let a non-author change ONLY the ImgGen version fields and require every other field to match `oldDoc` (an allowlist, as `chat.message` above shows — never a denylist of a few named content fields). See the fireproof access docs.
306
+ `ctx.requireAccess(channel)` gates on channel **membership** (a `grant.users`/`grant.roles` grant), NOT on `grant.public`, which is read-only — so a channel anyone signed-in should post to must **not** gate writes on `requireAccess` (it would block every non-owner); just check the author and route the doc. Reserve `requireAccess` for members-only channels whose writers you granted membership. For writes that need no sign-in at all ("anyone can sign/submit"), return `allowAnonymous: true` instead of throwing on `!user`. A grant/member/role doc must also return `channels` (route it to an owner-readable admin channel like `["admin:grants"]`) — a channel-less result is rejected as "unreadable write". On updates of shared-visible docs, keep ownership immutable (`if (oldDoc && doc.<authorField> !== oldDoc.<authorField>) throw`, where `<authorField>` is your doc's author field — `authorHandle`/`userHandle`/`senderHandle`) rather than requiring the updater to BE the author — image version appends run as the VIEWING user, and an author-only update gate turns every other viewer's generation into a billed deny-retry. Reserve author-only updates for genuinely private per-user docs; on shared docs, let a non-author through only when `ctx.isImgGenVersionAppend(doc, oldDoc)` accepts the write (as `chat.message` above shows)it permits exactly one legitimate ImgGen version append and requires every other field unchanged. See the fireproof access docs.
310
307
 
311
308
  **A follow-up edit that adds a NEW doc type updates `access.js` FIRST.** The app runs live while your edits stream in, and writes are enforced against the access.js that was in force before this turn until the whole turn completes — so a `db.put` of a doc type the old function rejects fails immediately (`unknown document type`), even though your access.js update lands later in the same reply. Emit the access.js edit adding the new type's branch before the App.jsx edits that write it. And never fire-and-forget background writes of a newly added type: gate seed/auto-write effects on `useVibe(dbName)` — `if (!ready || !can.create(sampleDoc).ok) return;` with `ready`/`can` in the effect deps, checking a representative sample of **each** doc type the effect writes — so a not-yet-allowed write is skipped quietly instead of surfacing rejection errors the user didn't cause.
312
309
 
@@ -399,11 +396,57 @@ docs in your database, and never build follow UI state machines.
399
396
  follow again later (blocking is harsher and lives in Settings, not in your app).
400
397
  Following a private account sits at `state:"requested"` and grants no reads until
401
398
  approved — filter to `state === "active"` when deciding whose content to show.
402
- - Make a doc follower-visible from access.js by ADDING `audience` to a normal result:
403
- `return { channels: ["share-" + owner], audience: { followersOf: owner } };`
404
- (`mutualsOf` for both-directions-only). Resolution is live: new followers instantly see
405
- history; unfollow/removeFollower/block instantly revokes. The owner is always in their
406
- own audience no self-grant needed. Keep at least one real channel; keep PRIVATE data
399
+ - Make a doc follower-visible from access.js by ADDING `audience` to a normal result, keyed on the
400
+ writer's own handle `user.userHandle`. Write the access function for a follower-visible app as
401
+ `export default function (doc, oldDoc, user, ctx)` the platform reads the writer from that default
402
+ export's third positional parameter `user`, so `user.userHandle` names the live writer and the
403
+ audience resolves against that writer's own graph:
404
+
405
+ ```js
406
+ export default function (doc, oldDoc, user, ctx) {
407
+ if (!user) throw { forbidden: "sign in" };
408
+ const ch = "picks:" + user.userHandle; // the writer's own channel
409
+ if (doc._deleted) {
410
+ // A tombstone carries no fields — authorize AND route it off oldDoc.
411
+ if (oldDoc.authorHandle !== user.userHandle) throw { forbidden: "your own docs" };
412
+ if (oldDoc.type === "reaction" || oldDoc.type === "comment")
413
+ return { channels: ["picks:" + oldDoc.pickOwnerHandle] };
414
+ return { channels: [ch], audience: { followersOf: user.userHandle } };
415
+ }
416
+ if (doc.type === "reaction" || doc.type === "comment") {
417
+ // A reaction/comment on SOMEONE ELSE'S pick rides that pick's channel — the
418
+ // pick's own audience already carries it to the right readers, so it returns
419
+ // channels only. Audience belongs to the doc types the WRITER shares.
420
+ // Author and target are fixed at create.
421
+ if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
422
+ if (oldDoc) {
423
+ if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "author is fixed" };
424
+ if (doc.pickOwnerHandle !== oldDoc.pickOwnerHandle) throw { forbidden: "stays on its pick" };
425
+ }
426
+ return { channels: ["picks:" + doc.pickOwnerHandle] };
427
+ }
428
+ // A pick is the writer's own per-user doc: author fixed at create, and only
429
+ // the author updates it — so the channel and audience stay the author's own.
430
+ if (!oldDoc) {
431
+ if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
432
+ } else {
433
+ if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "author is fixed" };
434
+ if (oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
435
+ }
436
+ return { channels: [ch], audience: { followersOf: user.userHandle } };
437
+ }
438
+ ```
439
+
440
+ (`mutualsOf: user.userHandle` for both-directions-only). The subject is always
441
+ `user.userHandle` — the handle writing this doc — in EVERY branch that returns an audience: the
442
+ create, the update, and the delete tombstone alike (a delete runs as the author, so
443
+ `user.userHandle` is the right subject there too — reach for `user.userHandle`, the live writer,
444
+ rather than `oldDoc.authorHandle` or a stored field). The platform resolves that subject live
445
+ against the writer's own graph: new followers instantly see history; unfollow/removeFollower/block
446
+ instantly revokes. The writer is always in their own audience — no self-grant needed. When a
447
+ reaction or comment should appear to a DIFFERENT person's audience, route it to that item's shared
448
+ channel and let the item's own audience carry it, keeping each `followersOf`/`mutualsOf` subject
449
+ `user.userHandle`. Keep at least one real channel; keep PRIVATE data
407
450
  on channels+grant without `audience`; never write channel names starting with `~`.
408
451
  - Copy: "followers can see your picks" / "people you follow" — never "friends".
409
452
  Following someone is low-stakes (it reveals none of YOUR data) — no confirm dialogs.
@@ -434,23 +477,19 @@ docs in your database, and never build follow UI state machines.
434
477
 
435
478
  **The owner must never be locked out of their own app.** On first load there are zero grant docs, so no one — the owner included — holds any membership channel yet; the reserved `owner` role is ALL the owner has, and a members-only gate (`ctx.requireAccess(ch)`) denies the owner exactly like a stranger. Two rules keep the owner in. In `access.js`: when a grant branch is itself owner-gated (`ctx.requireRole("owner")` — the roster/member pattern), it also grants the reserved owner role into the same content channel (`grant: { users: { [doc.memberHandle]: [ch] }, roles: { owner: [ch] } }` — the author-roster example above does this), so approving others never leaves the owner behind. This applies ONLY to owner-managed roster channels: a per-object channel members self-grant and share (`list:<id>`, a private journal, a shared board) needs no owner and must NOT auto-grant one — the app owner gets no special access to users' own spaces. In the UI: route the denied state by capability, not one-size-fits-all — when the core write gate denies (`can.create({ type: "post", ... }).ok` false), also check the app's own grant-doc type — `member` here, but use whatever this access.js names it (`author`, `share`, `approve`): `can.create({ type: "member", userHandle: me?.userHandle }).ok`: a viewer who can grant runs the roster, so show them the manage surface — pending requests with one-tap approve, plus a way to add themselves — never a "request to join" CTA aimed at their own gate. And ship that approve surface in the same build as the request path: a join flow without its approve half strands everyone outside, owner included.
436
479
 
437
- **Author-equality gates `create` and ownership change — NOT every update.** A shared-visible doc (public read, a gallery/catalog others browse) that `<ImgGen>` appends onto is written by whoever is _looking at it_: a version append runs as the VIEWING user, so a blanket `if (oldDoc && doc.authorHandle !== user.userHandle) throw` denies every other viewer's generation — after it was already billed — arming an unbounded billed-retry loop (#3784/#3832). Fix the author at create, forbid re-authoring, and for a non-author update require every field EXCEPT the ImgGen-owned version fields to be unchanged an **allowlist**, so a field you didn't anticipate stays author-protected by default (a denylist of named content fields silently unprotects anything not listed — the exact miss to avoid):
480
+ **Author-equality gates `create` and ownership change — NOT every update.** A shared-visible doc (public read, a gallery/catalog others browse) that `<ImgGen>` appends onto is written by whoever is _looking at it_: a version append runs as the VIEWING user, so a blanket `if (oldDoc && doc.authorHandle !== user.userHandle) throw` denies every other viewer's generation — after it was already billed — arming an unbounded billed-retry loop (#3784/#3832). Fix the author at create, forbid re-authoring, and for a non-author update accept only a legitimate ImgGen version appendthe platform predicate `ctx.isImgGenVersionAppend(doc, oldDoc)` decides that (`oldDoc` is `null` on create):
438
481
 
439
482
  ```js
440
- // ImgGen owns exactly these keys on a host doc; everything else is author-owned.
441
- const IMG_FIELDS = ["versions", "currentVersion", "currentPromptKey", "prompts", "prompt", "_files"];
442
- if (!oldDoc) {
483
+ if (oldDoc === null) {
443
484
  if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
444
- } else {
445
- if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "cannot change author" };
446
- const changedAuthorField = Object.keys({ ...doc, ...oldDoc }).some(
447
- (k) => !IMG_FIELDS.includes(k) && JSON.stringify(doc[k]) !== JSON.stringify(oldDoc[k])
448
- );
449
- if (changedAuthorField && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
485
+ } else if (doc.authorHandle !== oldDoc.authorHandle) {
486
+ throw { forbidden: "cannot change author" };
487
+ } else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
488
+ throw { forbidden: "not author" };
450
489
  }
451
490
  ```
452
491
 
453
- Deletes may stay author-only. `IMG_FIELDS` is exactly what `buildImgGenDocUpdate` writes (`versions`/`currentVersion`/`currentPromptKey`/`prompts`/`prompt`/`_files` it preserves `type`/`created`), so a non-author's version append touches only these and everything else must match `oldDoc`. Note `_files` merges: a non-author append can also replace a named source file under this shape, so if the app stores author-owned uploads in `_files`, compare those entries too or accept it. (A private per-user channel that no other viewer can reach is exempt — this bites the shared/public-read shapes.)
492
+ The platform predicate `ctx.isImgGenVersionAppend` accepts exactly one legitimate ImgGen version append by a non-author prior versions/files and all other fields must be unchanged, so a field you didn't anticipate stays author-protected by default. Deletes stay author-only, and a non-author advancing `currentVersion` is the shared-generation display behavior (an app wanting author-only display control must guard `currentVersion` specifically; a private per-user channel no other viewer can reach is exempt).
454
493
 
455
494
  **Public vs private is the owner's ACL envelope, not your code.** Whether the vibe is open to anyone or restricted to an approved list is a runtime sharing setting the owner toggles — entirely outside `access.js`. Keep `access.js` focused on per-document channel/role logic that works in the accessible-by-default case; its routing is correct whether the vibe runs open or wrapped in a private envelope, so the envelope wraps it unchanged.
456
495
 
@@ -520,17 +559,14 @@ Example streamed output for a team board app:
520
559
  > }
521
560
  >
522
561
  > if (doc.type === "post") {
523
- > // Author fixed at create; on update a non-author may touch only the ImgGen
524
- > // version fields (every other key must match oldDoc).
525
- > const IMG = ["versions", "currentVersion", "currentPromptKey", "prompts", "prompt", "_files"];
526
- > if (!oldDoc) {
562
+ > // Author fixed at create; on update a non-author may only append one
563
+ > // legitimate ImgGen version the platform predicate ctx.isImgGenVersionAppend
564
+ > // decides that (oldDoc is null on create).
565
+ > if (oldDoc === null) {
527
566
  > if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
528
567
  > } else if (doc.authorHandle !== oldDoc.authorHandle) {
529
568
  > throw { forbidden: "cannot change author" };
530
- > } else if (
531
- > Object.keys({ ...doc, ...oldDoc }).some((k) => !IMG.includes(k) && JSON.stringify(doc[k]) !== JSON.stringify(oldDoc[k])) &&
532
- > oldDoc.authorHandle !== user.userHandle
533
- > ) {
569
+ > } else if (ctx.isImgGenVersionAppend(doc, oldDoc) === false && oldDoc.authorHandle !== user.userHandle) {
534
570
  > throw { forbidden: "not author" };
535
571
  > }
536
572
  > return { channels: [doc.channelId] };