The settled model for who sees and uses every persona — one predicate, two axes, five verbs, one invariant. Replaces the three overlapping flags (visibility · hidden · deleted_by_owner) enforced differently at every surface. Status: fully built 2026-07-13 — everything shipped same-day (v396: the predicate, the suggester + Vibes gates, the verb guards, Delete/Restore · v397: clone · v398: strict /api/config + room-grant cards + credit anonymize · v399: the seating door, from a live hole the owner found hand-testing). The delta section records what shipped.
1 · Why — the audit that started this
The rule "who may see this persona" is currently re-implemented per surface, and each surface got a different fraction of it: the picker filters private server-side (library_descriptor(viewer_uid)) but drops hidden in client JS; the persona browser has its own filter; and the smart panel suggester read the whole library unfiltered — _panel_catalog() could propose another user's private personas and hidden ones to anyone (closed in v396; a second instance of the same class turned up in the Vibes brew, which cast posters and party members from both roots unfiltered — also closed). That leak is just the first symptom: any future surface re-makes the same mistake, because no single place owns the answer.
The fix is structural, not a richer status field: every discovery surface asks the same function. Seated rooms deliberately bypass it — visibility is enforced at the door (picker / suggester), never at the floor. The console reads all states.
2 · The model
Each persona carries created_by (immutable uid; repo personas = house) plus two axes × two values — visibility:private | public · deletion:alive | deleted. One predicate: alive AND (public OR created_by == me). One invariant: an admin never raises the visibility of someone else's persona — promotion crosses ownership by copy.
field
values
meaning
created_by
a uid · house
Immutable provenance — who made it. Not the authority anchor (see decision box below). Repo personas and clones record lineage here; the original creator of a cloned persona stays recorded for credit.
visibility
private · public
private = only the creator discovers and seats it. public = everyone. Public implies house/admin-made (for new personas — see grandfathering).
deletion
alive · deleted
Deletion is always soft — nothing ever leaves disk. UI verbs: Delete / Restore. Deleting requires private (demote first), so public always means alive-and-listed.
authority follows visibility, not created_byprivate personas belong to their creator (only the creator deletes/restores). public personas belong to the admins. If authority followed created_by, the original creator of a cloned-to-house persona could delete the house's copy — so created_by stays provenance only.
the invariantAdmin actions on other people's personas may only ever reduce visibility, never raise it. Publishing a user's work happens by clone → test → promote — the user's original is never mutated, never turns public under their feet with their name on it. Admin still reads everything (console, disk — unavoidable for an operator); what the invariant guarantees is that your objects are never changed by someone else.
3 · The state graph
The tested clone and the shipped persona are the same identity — promote flips in place (same slug, same ULID), so an admin's private test rooms carry straight through to launch. "Delisted" doesn't exist: taking a public persona down = demote → it lands on the admin's shelf, invisible to users, alive in rooms.
4 · The five verbs
verb
who
effect
build
anyone
Born private · alive, created_by = builder. True for users and admins alike.
Delete ↔ Restore
creator, own private only
Soft, always — off the shelf and out of discovery; seated rooms unaffected; files stay. Confirm copy: "Delete this persona? Chats where it's seated keep working. You can restore it anytime." Failed/parked scaffolding (never-completed builds) still hard-deletes to free the slug — implementation, not contract; the user sees one "Delete".
clone
admin, any persona — alive or deleted
Fresh identity (new slug + new ULID — two personas must never share an id), lands private on the cloner's shelf, records source id + original creator. No room inheritance.
promote
admin, own-domain private
private → public, in place — identity kept from testing to launch.
demote
admin, any public
public → private, lands on the created_by shelf. The takedown and the "pull it back for rework" verb; a reduction, so invariant-legal on any public persona.
namingThe state is deleted, the verbs are Delete / Restore — the universal soft-delete idiom (zh: 删除 / 恢复 falls out of the i18n dict naturally). "Tombstone" never appears in UI, spec, or console — dev shorthand only. Rejected: Archive (wrong intent — implies keeping), Trash (promises an emptying that never happens), Retire (collides with demote, the actual retire-from-public verb).
5 · Who sees what
state
picker · browser · suggester · Vibes
seat into a room
already-seated rooms
admin console
public · alive
everyone
everyone
✓
✓
private · alive — yours
you only
you only
✓ (the grant)
read
private · alive — someone else's
—
—
✓ if seated
read
private · deleted
—
—
✓
read · clonable
seated = sharedSeating a private persona in a shared room is an act of sharing — everyone in that room sees it, talks to it, and may open its card. The grant survives every later flip (Delete, demote): un-granting would retroactively mutilate transcripts, and "degrade, never delete" wins. Consequence: visible ≠ usable — the picker gates adding; the floor considers whoever is seated, with no per-turn library checks.
the grant is per-room — the seating doorThe grant lets a member see a seated persona; it must never let them seat it elsewhere. Found live (v399): a member opened a granted private persona's profile page and used "Start a private chat" — a fresh solo room with someone else's private persona. The picker only filters what's offered; the API never enforced who may seat what. Now it does: every seating endpoint (create room · invite persona · moment join) requires each slug be discoverable by the seater (seat_refusal → 403 "‹name› is private"; moment-join silently drops instead — a legacy moment may reference a since-demoted persona). The granted persona's page keeps its door visible but locked — greyed, padlock, "This is a private persona" (v400) — the page teaches why there's no chat to start instead of hiding a dead end. Rule of thumb: filters belong in the UI, gates belong in the API — every visibility rule needs both.
moderation of private personasA misbehaving private persona reaches other humans only through a room where its creator seated it — so the lever is room-level cast ejection (cast changes are free, no model call) and ultimately the account, never object mutation. Public misbehaviour = demote.
no true deletion — and the erasure normNothing ever leaves disk in-system. The one carve-out: users feed personal material into builds (probe answers, supplied transcripts). An explicit erasure request beats preservation — the admin honours it manually, out-of-band (rm on the box), clones included. For the gray zone there's clone-and-strip: keep the craft, remove the personal material, publish the cleaned house edition, erase the original.
credit on published clonesThe public card of a cloned persona shows the original creator ("made by ‹name›") from the recorded lineage, with an anonymize option — which is also what dissociation requests need.
6 · Delta from the pre-model code — as shipped (v396)
The flags were close — the model was mostly a relabelling plus one structural fix. Users already couldn't flip visibility (builds are born private; only /api/admin/personas/{slug}/visibility flips it), and owner-delete was already a soft tombstone. What v396 changed:
suggester catalogue is viewer-scoped: public + own
✓ v396 — may_discover() scopes the catalogue AND the response validation (a hallucinated private slug doesn't validate either)
Vibes brew cast posters + party members from both roots unfiltered
Vibes is a discovery surface: public · alive only
✓ v396 — _all_persona_slugs() gated; --roster allowlist passed to the news caster + troupe director (stale voice cards of demoted/private personas never cast); the user-post random crew filtered client-side too
picker: server filters private, client JS drops hidden (hidden cards ship to every client)
all discovery filtered server-side by the one predicate
✓ v398 — /api/config is strictly may_discover-filtered (a non-admin payload carries NO hidden card; admins keep theirs flagged — the shelf). Seated personas outside the list render from the room's own /state cards (the grant made concrete): they join LIB flagged granted — every render site resolves them, every discovery pool (pickers · browse · suggest · Seen) excludes them
hidden — admin "take-off" list in DB settings
demote for repo personas (created_by = house)
✓ v396 — the endpoint now refuses app-built slugs (their demote is /visibility); same DB store (repo files stay untouched at runtime)
/api/admin/personas/{slug}/visibility flipped any persona
splits into promote (ownership-guarded) + demote
✓ v396 — promote requires created_by == the calling admin (publishing another user's work now answers "clone first"), and refuses deleted personas (restore first); demote stays open to any admin (a reduction)
deleted_by_owner tombstone; published-then-deleted stayed public; no undo
✓ v396 — Delete on a public persona → 409 demote-first; new POST /api/build/{slug}/restore; the Studio grows a Deleted section (dimmed, Restore in the ⋮) and the confirm copy tells the truth: "Chats where it's seated keep working. You can restore it anytime."
the persona page gate: private + not-owner → 403, always
the room grant extends viewing
✓ v396 — /api/persona/{slug}/about falls back to a seated-in-a-shared-room check before refusing
room create / invite / moment-join accepted any existing slug (only the picker filtered)
the seating door: seat only what you may discover
✓ v399 — seat_refusal() on all three endpoints (the grant found leaking into seating live: a member started a fresh chat with a granted private persona from its profile page); already-seated slugs exempt (re-invite in the same room = the grant); the fix also added discoverable(slug) — read_card never stamped hidden, so bare-slug predicate calls (incl. /about) silently passed taken-off personas. v400: the granted page's chat door greys out with "This is a private persona" instead of vanishing
no clone
clone — the only genuinely new code
✓ v397 — POST /api/admin/personas/{slug}/clone: any persona, alive or deleted, repo or built → fresh slug (<src>-2, -3…) + fresh ULID, born private on the cloner's shelf, lineage (cloned_from: source slug/id + original creator) on the record and surfaced on the card ("clone of ‹slug›" in the console); build artifacts and raw supplied/ uploads stay with the original. Console ⋮ grows Clone to my Studio, and Make Public now shows only on the admin's own builds — others' work offers Clone instead
user-built personas already published (public + user-owned)
public implies house-made
grandfathered as designed: stay public, created_by kept (credit); admin may demote; no migration ran, none needed
Build order
The predicate + the suggester fix — ✓ shipped v396 (may_discover() in run_room; browser, about, suggester, Vibes brew/news/troupe route through it; ~20 new smoketest checks pin the matrix).
Clone — ✓ shipped v397 (endpoint + console action + 8 smoketest checks: tombstoned-source clone, fresh slug/id, lineage credit, clone → promote round-trip, repo-source clone). The 403 on publishing another user's work now points at a verb that exists.
the launch batchPre-launch personas are not built-then-cloned. Path A (default): the repo pipeline — CC-authored in prototype/personas/, born house + public, rides git push to the box like any change. Path B (builder-shaped ones): admin builds in Studio → tests privately → promotes in place. Clone is for its real job later — lifting a user's creation into the library.
Persona visibility spec · settled 2026-07-13 in a design discussion (suggester-leak audit → 2×2 model) · FULLY BUILT same day — v396 (predicate + leak closures + verb guards + Delete/Restore) + v397 (clone) + v398 (strict /api/config · room-grant cards · credit anonymize) + v399 (the seating door — the grant found leaking into seating during the owner's hand-test) — §6 is the as-shipped record; nothing from this spec remains open. Related: Studio · Smart panel selector.