Compare commits

...
+482
View File
@@ -0,0 +1,482 @@
# Row 193 design: many humans per business, human roles, private and shared threads
Morgana (Opus 5.5), 2026-10-20. Revision 5: sage-1373 item 1 (mos-1023 item 2), the user sentence names every record the owner sees. Revision 4: duckworth-0011 B3 and N5. Revision 3: duckworth-0008 B1, B2 and N1-N4, sage-1368. Revision 2: sage-1354 (5.7 private from the owner, 193b split) and gizmoduck-0018 (coverage and the R4 predicate in FED r4.1). Gitea #1691, queue row 193, brief `docs/plans/2026-10-20_human-users.md`
(blob f7472d1e47b1). Reviewer Duckworth, gate owner Sage. Base: `refactor` at 53bb59462.
Read for this design: COMMON.md (53bb59462), the brief, Gizmoduck's federation design r4.4 (b67bc6de9 on
gizmoduck/federation-design, cited as FED; r3.1 a2840a6be was read first), Duckworth's federation reviews (b402197ff, 03a663cd7,
45cbe5896 on duckworth/reviews), Sage's Q175 SSO design (`agents/sage/work/q175-sso/DESIGN.md`), the row 192
and row 196 briefs, Darkwing's row 190 packet (uncommitted, sha256 5493c9df3ec0...), and the bus, business,
cli and webui code at the lines cited. Messages: morgana-0001 to 0003, sage-1338, gizmoduck-0009,
gander-0002 and gander-0005.
## 1. Summary
- Three human roles: **owner**, **collaborator**, **viewer**. The owner is the business file's one `human`,
as today. Collaborators and viewers are access.json users, the same store federation uses (FED 3.5).
Their role is the `level` of a grant, so a role is per project and instance, not per person.
- Every human principal is minted by one bus verifier, owned by row 196 (Gander). Row 193 states what it
needs from that verifier (section 6) and adds no second mechanism.
- Threads stay private to their owner by default. A thread owner may share a thread, read-only, with a
named user, with the owner, or with everyone granted on it. Shares live in a new append-only bus table,
`thread_shares`. This is a deviation from brief point 4, agreed by Sage (sage-1338), for Duckworth to rule
on (section 5.1).
- A share never widens a grant. It counts only while the reader's live grant covers the thread's project
and instance, and while the thread's owner still has coverage. Revocation, expiry and unsharing take
effect on the next request.
- Existing businesses keep working unchanged: every existing thread is already owned by the business
human, and an instance without access.json has exactly one human, the owner.
- The build splits so the part without a schema change can land first. `thread_shares` needs schema 3j and
a `--migrate` window with Jason.
## 2. What exists today (measured at 53bb59462)
- One `human` per business (`packages/business/src/business.mjs:160`). `bindHuman` refuses any other name
(`packages/bus/src/broker.mjs:532-538`).
- The owner's CLI proof is path-bound (#225): `verifyHuman` checks uid, start time, nonce
(`MOSAIC_BUS_CLI_NONCE`), `argv[1]` equal to the tree's `human-cli.mjs`, then walks parents and refuses
agent markers, launch records and agent binaries (`packages/bus/src/human.mjs:64-96`).
`authenticateHuman` binds the proof to `businesses[business].human` (`packages/bus/src/runtime.mjs:175-182`).
- Threads: `threads(seq,id,at,business,project,instance,owner,name)`, append-only
(`packages/bus/schema.sql:247`, triggers at :310-312). `#owner(s)` returns `tester` or `s.human`
(broker.mjs:364-366). `#thread` refuses a non-owner with `thread-not-owned` (:357-362).
- Thread verbs are gated `if (!s.tester) this.#human(s)`: `project.list` (:2318), `thread.create` (:2325),
`thread.list` (:2351, owner filter), `thread.open` (:2365), `thread.send` (:2374, writes `human.input`
for the human only), `thread.messages` (:2401).
- Board reads are business-wide and human or reader only: `inbox`, `agents`, `tasks`, `trail` (:2143-2167),
`decisions` (:2174), `credentials` (:2195), `messages` (:2217, `to_role='human'`), `replies` (:2422).
`trail` and `replies` return any message in the business by id (:2679-2700, :2422-2432).
- The Console reads threads by polling the same verbs (`packages/webui/src/chat.mjs:22-26`). It has no
stream, so a refusal on the next poll ends access.
- The launcher restarts a thread with pending messages as the business human
(`packages/cli/src/launcher.mjs:533-542`).
## 3. The human model (brief point 1)
### 3.1 Who the humans are
| Human | Where it is declared | How many | Identity |
|---|---|---|---|
| owner | business file `human` (unchanged) | exactly one per business | the business human's name |
| user (collaborator or viewer) | access.json `users[]` with `home: "local"` (or a peer id under federation) | any number | `user:<id>` |
| tester | not a human; row 177 test principal | one | `tester` |
The owner stays outside access.json (FED 9 Q2). A business has no second owner and no admin level. Adding
one is out of scope; section 9 lists it as a follow-up for Jason.
### 3.2 How a sign-in or a call becomes exactly one human
| Entry | Becomes | Checked by |
|---|---|---|
| Console SSO session whose `(provider, sub)` is in `console.sso.providers[].subjects` (row 192) | the owner | the Console after OIDC checks, then the bus verifier (row 196) |
| Console SSO session whose `(provider, sub)` is a `users[].subjects` entry in access.json with a live grant | that one user | same |
| Console SSO session matching neither | nobody: refused | the Console, and the bus refuses any later claim |
| CLI call through `human-cli.mjs` that passes the human proof | the owner | the bus (`verifyHuman`) |
| Any other process, including an agent | nobody | the bus |
Why each is exactly one:
- The R1 loader refuses a `(provider, sub)` pair that appears twice in access.json or also in
`console.sso.subjects` (FED 3.5). So one subject maps to at most one human.
- A user id can't be `human`, `tester` or any business's human (FED 3.5). So a user can't be named as the
owner.
- A terminal maps only to the owner. The only identity the bus can verify for a local process is the
instance's OS user, and that is the owner's. A user has no CLI path in row 193. This follows Mos's B1 rule
(mos-0985): the principal comes from what the bus verifies, never the process's own claim.
- The bus never reads a user name from a request body as identity. The verifier returns the principal
(section 6).
## 4. Human roles (brief point 2)
### 4.1 The three roles
- **owner**: everything the business human does today. Only the owner restructures the organization
(business file writer, project and role changes) and only the owner manages users and grants.
- **collaborator**: FED 3.5's level, unchanged. Creates threads in granted projects on granted instances,
sends into and opens its own threads, and in row 193 also shares its own threads.
- **viewer**: new. Reads threads shared with it, within its grant. It creates, opens and sends nothing, so
it never drives an instance. FED 3.5 notes that a collaborator's message drives the instance with the
owner's role authority (F10). A viewer has none of that.
The word "collaborator" stays, so FED's tests and help text need no rename. The brief's example "member" is
this level.
A role is the `level` on a grant, and a grant is per business, project set and instance set. A user may
hold a collaborator grant on one project and a viewer grant on another. Every check asks "does a live
grant of a level that allows this verb cover this project and instance". A user has no single role.
### 4.2 R1 widening
FED R1 refuses any level but `collaborator`, because the 10-27 slice ships only that (FED 9 Q2).
Row 193 widens R1's level check to `collaborator` or `viewer`, with its own test that each is accepted and
anything else refuses. Nothing else in the access file changes. If R1 has not landed when row 193 builds,
this one-line widening goes into the row 193a candidate chained on R1's candidate (Q177).
### 4.3 Bus verbs per role
Every verb not in a user's column fails `access-refused` before its own checks, as FED 3.5 requires for
collaborators. That covers every owner verb, every board read, `message.*`, `decision.*`, project files,
`spec.read`, `assistant.stop`, `launch.*`, `name.set`, `usage.*` and `access.reload`.
| Verb | owner | collaborator | viewer |
|---|---|---|---|
| `project.list` | all | projects covered by any live grant | projects covered by any live grant |
| `thread.create` | yes | collaborator grant covers project and instance | refused |
| `thread.list` | own threads | own threads still covered | own threads still covered (from an earlier collaborator grant) |
| `thread.list {shared: true}` (new) | threads shared with the owner | threads shared with me and readable (5.4) | same |
| `thread.open` | own | own, collaborator grant still covers | refused |
| `thread.send` | own | own, collaborator grant still covers, no `human.input` | refused |
| `thread.messages` | own, or shared with the owner | own (any level still covering), or shared and readable | same as collaborator |
| `thread.share`, `thread.unshare` (new) | own threads | own threads, collaborator grant still covers | refused |
| `thread.shares` (new) | own threads | own threads | refused |
| `names` | all | instances covered by any live grant | instances covered by any live grant |
| every other verb | as today | refused | refused |
Notes:
- `thread.list` keeps its response shape exactly. Shared threads come only from `thread.list {shared: true}`,
whose rows add `owner`. The Console and existing tests read the plain list unchanged.
- A viewer can still read its own threads when a collaborator grant is lowered to viewer, but can't send
or open them. This is the downgrade path.
- The tester keeps its row 177 verbs. It can't share or be shared with: `thread.share`, `thread.unshare`
and `thread.shares` fail `tester-refused`, and a share target is never `tester`. Agent testers (Q173) test
sharing through the bus suites, not the live 7333 listener.
- `reader` sessions (Console read caps, broker.mjs:1303) are the owner's board view and stay owner-only.
A user never gets a reader session.
### 4.4 Console per role
- owner: as today, plus a share control on each own thread and a "Shared with me" list.
- collaborator: the chat view only, for granted projects (FED R6's granted-user sign-in), plus share
controls on its own threads and "Shared with me". No board.
- viewer: "Shared with me" and its own old threads, read-only. No composer, no open, no share control.
- The Console hides controls a role can't use, but the bus check is authoritative. A Console bug can't
widen a grant (FED 3.6). Each role gets a webui test with the UI check removed (mutant) that the bus still
refuses.
### 4.5 Who manages humans and roles
Only the owner, through `mosaic access` (FED R5), which checks the human proof and refuses tester and agent
callers. Row 193 needs from R5:
- `mosaic access user add <id> --home local --subject <provider>:<sub>` and `user remove <id>`;
- `--level viewer` on `grant`;
- the receipt line in `<dataRoot>/state/access-log.jsonl` and the `access.reload` call, as FED R5 has.
If R5 has no `user` verbs when row 193 builds, row 193c adds them on R5's candidate. The Console gets no
user or grant writes in row 193: a write from the Console would need a new owner verb that writes
access.json from the bus, and access.json is a config file only the owner's CLI writes. A read-only users
page for the owner is a follow-up (section 9).
The owner keeps every restructuring power (TARGET Case 1). Users can't touch the business file, projects,
roles or access.json.
## 5. Private and shared threads (brief point 3)
### 5.1 Deviation from brief point 4, agreed by Sage (sage-1338)
Brief point 4 says access.json is the one store. Users, roles and grants live there. Per-thread shares do
not, for three reasons (morgana-0002):
- F1. Thread ids are bus-generated UUIDs. A hand-typed share with a typo would fail the loader, and the
loader refuses the whole file on any failure, which revokes every user.
- F2. Only the owner writes access.json. A collaborator could never share its own thread.
- F3. Threads are append-only bus rows, and a share is a runtime act by a principal that wants a trail.
So shares go in an append-only bus table, gated by the grant. Sage agreed (sage-1338). Gizmoduck had no
objection (gizmoduck-0009) and recorded it as FED 9 Q2. Duckworth rules on it in this review.
### 5.2 Schema 3j
```sql
CREATE TABLE thread_shares (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
op TEXT NOT NULL CHECK (op IN ('share','unshare')),
target TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_shares_thread ON thread_shares (thread, target, seq);
-- no_update, no_delete and no_replace triggers, as thread_opens has (schema.sql:319-321).
```
- `target` is an owner string: `user:<id>`, the business human's name, or `*`. A share whose target is a
name that is no longer the business's `human` (after a rename) is void: rule 1 in 5.4 compares against
the current owner string, so it matches nobody (Duckworth N4).
- `by` is the sharer's owner string, which is always the thread's owner.
- A share's state is its latest row per `(thread, target)` (Duckworth N12, REVIEW-r3.md:74-76). Shares
are not assumed to only grow.
- No existing row changes. Existing threads are already owned by the business human (`#owner` at
broker.mjs:364-366), so they are the owner's and private. The brief's "all become the current human's"
is already true.
The migration `3i-3j.sql` creates the table, index and triggers. `thread_shares` joins every table list that
holds timed append-only tables: `timedTables` in store.mjs (:60-74), the broker's clock list
(broker.mjs:223-235), the restore guard's counted tables (row 181) and `COUNTED` in
`scripts/stack-release.sh:315`. The schema digest for 3j is pinned as the other versions are.
### 5.3 Verbs
- `thread.share {thread, to}`: the caller must own the thread (`thread-not-owned`). For a user, a
collaborator grant must still cover the thread. `to` is `user:<id>`, the business human's name or `*`.
A `user:<id>` target must have a live grant, of any level, covering the thread's project and instance,
else `share-not-covered`. Sharing with yourself fails `invalid-request`. A share that is already in force
fails `share-exists`. Writes one `share` row.
- `thread.unshare {thread, to}`: the caller must own the thread. The share must be in force, else
`share-not-found`. Writes one `unshare` row. Unsharing the owner's or `*` shares works the same way.
- `thread.shares {thread}`: the caller must own the thread. Returns the shares in force, latest row each.
- Both writes go through `refuse()` on failure, so each refusal records `action.refused` with `by` set
(FED 3.6 item 6).
### 5.4 The read check, one function
`#readable(s, t)` decides whether a session may read thread `t` it doesn't own. True only when all hold:
1. a share for `t` is in force whose target is the caller's owner string, or `*`;
2. the caller is the owner, or the caller has a live grant of any level covering `t.project` and
`t.instance`;
3. if `t.owner` is a user, that user still has a live grant covering `t` (a revoked owner hides the
thread, sage-1338);
4. `t.owner` is not `tester`.
`#thread(s, id)` keeps its owner check for `thread.open`, `thread.send`, `thread.share`, `thread.unshare`
and `thread.shares`. A new `#threadRead(s, id)` admits the owner or `#readable`, and serves only
`thread.messages`. A sharee fails `thread-not-owned` on every write verb.
### 5.5 Every read path and its filter
| Read path | Today | After row 193 |
|---|---|---|
| `thread.list` | `owner=?` | unchanged for the owner; for users also each row's thread still covered |
| `thread.list {shared: true}` | none | rows where `#readable` holds |
| `thread.messages` | `#thread` | `#threadRead` |
| `thread.open`, `thread.send` | `#thread` | `#thread`, plus a collaborator grant for users |
| `project.list`, `names` | business-wide | per FED 3.5 for users, unchanged for the owner |
| `trail`, `replies` | human, reader or tester; any message by id | users refused (`access-refused`); for the others, messages in other users' unshared threads hidden (5.7) |
| `messages`, `inbox`, `agents`, `tasks`, `decisions`, `credentials` | human or reader | users refused (`access-refused`); owner unchanged. A user-thread run can't send to the human (`user-thread-human`, FED 3.6), so `messages` holds none of a user's thread |
| `message.receive` by a run | its own thread (:1933) | unchanged: runs read their own thread, never a share |
| `threadsState` (launcher) | all threads | adds `revoked` per 5.6 |
### 5.6 Revocation, expiry and unsharing
Gizmoduck asked that a grant loss end access to threads shared with that user (gizmoduck-0009 point 2).
- A sharee holds no claim and no run. Its access is only reads, and every read runs `#readable` against the
access state the broker already refreshes on each request (mtime, size, inode), on its 30 s timer, and on
`access.reload` (FED 3.7). So a removed, expired or invalid grant ends share reads on the next request,
with nothing to tear down. The Console polls, so a revoked sharee's next poll fails.
- An unshare row ends access on the next request in the same way.
- A user who loses coverage of their own thread also loses its shares: rule 3 in 5.4.
The downgrade case. With viewer, "no live grant covers" would be wrong for runs: a viewer grant covers the
thread but must not let a run continue. FED puts this in R4: `revoked`, claim end and the launch check call
`covers` with level `collaborator`, so a downgrade to viewer ends the run within one launcher tick. FED r4.4
(b67bc6de9, mos-1020) chains R4 after row 193's viewer level (193a) and keeps the level-loss test and its
mutant in R4. Row 193 carries neither (Duckworth N3), so the mutant has one owner.
### 5.7 What "private" means, stated plainly
Sage ruled (sage-1354): a user's thread is private from the owner on the bus too. Jason's words are "private
agent sessions per user", and in Case 2 the owner of Melanie's hub is not the user whose thread it is.
Duckworth's B1 asked where the line sits, since a user-thread run acts with the owner's role authority (F10).
I took Duckworth's option (a): **the user's messages are private, and the run's governance is not.** The
run spends the owner's authority, so its decisions, task writes and claims are the owner's to see and
answer. Filtering them too (option b) would need a thread link on `decisions`, more schema, and would hide
from the owner what is done in the owner's name.
Every read an owner or reader session has, and its side of the line:
| Read | Shows | Side |
|---|---|---|
| `thread.list`, `thread.messages`, `thread.open` | own threads only, plus shares to the owner from 193b2 | private: other users' threads never appear |
| `trail` | events, messages, decisions and deliveries by subject | messages filtered (below); decision rows and their events stay visible |
| `replies` | messages replying to an id | filtered (below) |
| `messages`, `message.receive`, `message.read` | messages `to_role='human'` (broker.mjs:1931-1936, :1953) | private by construction: a user-thread run can't send to the human (`user-thread-human`, FED 3.6 item 7) |
| `inbox` | open decisions routed to the human | governance, visible |
| `decisions {list: roles \| recent}` | decision rows with question, options and recommendation | governance, visible |
| `agents` | role claims and each held role's state | governance, visible |
| `tasks`, `state` | tracker tasks and launch state | governance, visible |
| `credentials`, `usage` | credential state, usage records | governance, visible; neither holds message text |
| `project.files`, `project.file` | project files, including records a run writes with `record.write` (broker.mjs:1395-1407, :1441-1450) | governance, visible |
| `project.list`, `names` | projects and instance names | business-wide for the owner, visible |
So a decision a user-thread run raises shows in the owner's `inbox` or `decisions`, with the free text the
run wrote. Its role claims show in `agents`, its task writes in `tasks`, and a record it writes in
`project.file`. Any of these can quote the user, and 5.7 doesn't call them private. The users' help text
(R5) and the Console's share and thread pages say so in one sentence, which FED R6 reuses (mos-1023 item 2,
duckworth-0012 N21):
> The business owner sees your thread's agent's decisions, inbox entries, claims, tasks and project files,
> but not your messages.
The gated-raise route. A user-thread run's uncited gated raise or hard wait routes to the human (broker.mjs:1677-1680).
FED r4.5 (acd5f1f42, B9) settled this with no guard: the tester guard at :1683 (`tester-thread-human`) is not
copied, and the decision reaches the owner's inbox and no other (sage-1368, mos-1021, mos-1022). FED R3 tests it.
Row 193 adds nothing here.
The message filter. A message is hidden from a session when it is in a thread (`message_threads`) whose owner
is a `user:<id>` other than the caller, and `#readable(s, t)` is false. Threads owned by the business human or
by `tester` are not affected, so row 177's behaviour is unchanged.
- `trail`: drops hidden messages, their `deliveries` rows, and `events` rows whose `subject` is a hidden
message id. A `subject` that is itself a hidden message returns `message-not-found`, as an unknown id does,
so the reply doesn't confirm the id exists.
- `replies {id}`: a hidden `id` fails `message-not-found`, and hidden messages are dropped from the result.
- It applies to every session that can call these verbs: the owner, reader sessions and the tester.
- It lands in 193b1, with no schema (Duckworth B2). Before 193b2 there are no shares, so `#readable` is false
for anyone but the thread's owner and b1 hides every message in another user's thread. 193b2 adds only the
share exception. So no build order can put a user thread live without the filter.
What it does not do. The owner holds the store as the instance's OS user and can read it with sqlite. That is
the same-uid limit in section 6, not a bus path. An owner audit verb, explicit and logged, is a follow-up row
only if Jason asks for one (sage-1354).
## 6. The bus human boundary (brief point 5)
Row 196 (Gander, #1694, brief `docs/plans/2026-10-20_human-boundary.md` at 96f243faf) owns P19 333 and the
verifier. Row 193 cites it and designs no boundary of its own (sage-1338: one mechanism).
Agreed with Gander (gander-0002, morgana-0003, gander-0005):
- One bus verifier, `verifyPrincipal(request, connection) -> {kind, business, id}`, kind `owner`, `user` or
`tester`. It mints one transient principal per request. It returns no role. The broker reads the level
from the live grant on every request, so a grant change acts without a new principal.
- Gander recommends FED 3.6's shape for users (path-bound `user-cli.mjs`, direct parent the bus tree's
`packages/webui/src/cli.mjs serve`) plus kernel peer PID binding, and weighs bus-side ID-token
verification as the larger alternative.
- Correction from Gander, accepted: the tester proof doesn't run the full agent walk, because agents bind
tester by design (Q173). Managed runs still fail it. The tester listener moves from TCP 7333 to a private
Unix socket, with `bus.tester` unchanged (agreed by Sage with Gander).
What row 193 needs from row 196:
- H1. Owner on the CLI keeps today's proof. Owner in the Console is a `console.sso.subjects` entry.
- H2. A user principal comes only from an OIDC sign-in, through a path the bus verifies. No CLI path for
users.
- H3. The user binding fails closed on no live grant, an unknown subject, a subject also in
`console.sso.subjects`, or a missing or invalid access.json. A broken access.json never stops the owner.
- H4. Agent processes never get an owner or user principal.
- H5. Revocation ends a bound user within 30 s and at once on `access.reload`, from the same access state
row 193's share check reads.
The same-uid limit. Within one instance, a non-agent process on the instance's uid can claim the owner, and
can start its own Console copy and claim any granted user (FED 3.6). An unsandboxed same-uid agent that
detaches from its ancestry can do the same. The walk doesn't stop that, and Gander's design won't claim it
does (gander-0005). Several humans don't make this worse: a user principal is no stronger than the owner
principal that the same process could already take. Users themselves are not on the uid. They reach the
bus only through the Console, so the limit is about local processes, not other humans.
Does row 190 narrow it? Row 190 runs several instances under one uid, so no. Row 190b puts an instance (the
hub) under its own Linux user (mos-0986). That narrows reach across instances: another instance's uid fails
the uid check (human.mjs:67) and can't read the hub's store or access.json. It does not narrow reach within
one instance. Private threads are private from other humans using the bus, not from processes running as
the instance's uid.
## 7. Migration and rollback (brief point 6)
- No access.json: the business has exactly its owner, every verb behaves as today, and the share verbs
work for the owner only (target `*` or none). A broken access.json revokes users and leaves the owner.
- Existing threads: already owned by the business human. Nothing is rewritten.
- Schema: 3i to 3j adds `thread_shares` only. A re-pin past a schema bump needs a `--migrate` window with
Jason, and row 185's backup before activate covers it.
- Rollback before any share is written: activate the previous copy with the store restored from the
pre-activate backup. A 3i copy refuses a 3j store, as it does for every bump.
- Rollback after shares are written: the same restore loses shares made since the window. Grants are in
access.json and survive. Users re-share. Row 181's restore guard counts `thread_shares`, so the loss is
shown, not silent.
- To keep that window small, the build splits (section 9): 193a, 193b1, 193c and 193d have no schema
change and can go live in any window. 193b2 carries 3j.
## 8. Tests and mutants
Bus, each in `packages/bus/tests/` (193b1: viewer, mixed grants, board reads, the 5.7 message filter and governance pin; 193b2: shares, the share exception to the filter, migration):
- Viewer: each allowed verb works, every other verb refuses `access-refused`, including `thread.create`,
`thread.open`, `thread.send` and `thread.share`.
- Mixed grants: collaborator on project A and viewer on B. Create in A works, create in B refuses.
- Downgrade: not here. FED R4 owns the level-loss test and its mutant (r4.4, mos-1020, N3).
- Shares: owner shares with `user:<id>`, the user reads `thread.messages` and gets `thread-not-owned` on
`thread.send` and `thread.open`. Share with `*` reaches every covered user and the owner. Share with a user
whose grant doesn't cover the project or instance refuses `share-not-covered`.
- A share never widens: the target's grant drops the project, the next read refuses.
- Revoke, expiry and an invalid file each end share reads on the next request. The 30 s timer and
`access.reload` paths each have a case.
- A revoked thread owner hides the thread from sharees.
- Unshare ends access. Share, unshare, share again leaves it shared (latest row wins).
- Non-owner `thread.share`, `thread.unshare`, `thread.shares` refuse. Tester refuses on all three.
- `thread.list` plain shape unchanged. `thread.list {shared: true}` returns only readable threads.
- Board reads refuse users and viewers.
- Governance pin (B1, sage-1368): a user-thread run raises a decision; it shows in the owner's
`decisions recent` with its question text, and `inbox` lists it when routed to the human; the run's
role claim shows in the owner's `agents`, its task write in `tasks`, and its `record.write` in
`project.file` (B3, sage-1373); the user's message bodies stay hidden from the owner's `trail` and
`replies`. The test names each item of the 5.7 user sentence, so the sentence and the pin match.
- `trail`, for the owner, a reader session and the tester (193b1): a user's thread message as `subject`
fails `message-not-found`; a decision trail that reaches such a message omits it, its deliveries and its
events. Once the thread is shared with the owner, the owner's trail shows it.
- `replies`, same three sessions (193b1): a hidden `id` fails `message-not-found`; hidden replies are dropped. The
owner's own and the tester's threads are unchanged.
- Migration: a 3i store with threads migrates to 3j, row counts unchanged, `thread_shares` empty and
append-only (update, delete, replace each abort). Restore-guard and `COUNTED` include it.
Business (193a): R1 accepts `viewer`, refuses any other new level.
CLI (193c): `mosaic access user add/remove` and `grant --level viewer` refuse tester and agent callers,
write a receipt line and call `access.reload`.
Webui (193d): a viewer has no composer or share control. With each UI check removed, the bus still refuses.
Mutants, each must be killed:
1. `#readable` rule 1 dropped (any covered user reads any thread).
2. Rule 2 dropped (a share widens the grant).
3. Rule 3 dropped (a revoked owner's thread stays visible).
4. Latest-row lookup replaced by "any share row" (unshare ignored).
5. (Moved to FED R4, N3: the level-loss mutant is R4's.)
6. Viewer allowed `thread.send`.
7. `#threadRead` used for `thread.send` (sharee writes).
8. `share-not-covered` check removed.
9. `thread.share` owner check removed (sharee re-shares).
10. A user allowed `trail`.
11. The `trail` filter removed (the owner reads a user's unshared message by id).
12. The `replies` filter removed (the owner reads replies in a user's unshared thread).
## 9. Build rows and order
| Row | Work | Chains on | Schema |
|---|---|---|---|
| 193a | R1 level check widened to viewer | FED R1 candidate | none |
| 193b1 | Bus: viewer verb list, per-grant level checks, users refused on board reads, the 5.7 `trail` and `replies` filter with `#readable` as owner-only | FED R3, R4 candidates, row 196 verifier | none |
| 193b2 | Bus: `thread_shares`, share verbs, the share rules of `#readable`, `#threadRead`, the 5.5 share filters | 193b1 candidate | 3j |
| 193c | `mosaic access` user verbs and `--level viewer`, if R5 lacks them | FED R5 candidate | none |
| 193d | Console: shared list, share controls, viewer read-only | FED R6, row 192 candidates, 193b2 for share controls | none |
Sage ruled the split (sage-1354): 193b1 has no schema change and can go live first. 193b2 carries 3j, and
Sage takes its migrate window to Mos when 193b2 is near. The 5.7 message filter is in 193b1 (Duckworth B2,
sage-1368), so it is live before any user thread can be, whatever order FED R8 and 193b2 land in. 193b2 adds
only the share exception. FED R4 chains on 193a (viewer level), and 193b1 chains on R4.
Coverage, settled in FED r4.1 (73677b87d, gizmoduck-0018, still under Duckworth's review). A grant's
`projects` is `["*"]` or bus project UUIDs (`projects.id`, broker.mjs:2306; `threads.project` references it).
R1 exports one pure `covers(grant, {business, project, instance}, now, {level})`. Row 193 uses only that
function and adds no second one (Sage confirmed, sage-1354):
- share reads (5.4 rules 2 and 3) and `share-not-covered`: `covers(g, thread, now, {})`, any level;
- `thread.create`, `thread.open`, `thread.send`, `thread.share`: `covers(g, thread, now, {level: 'collaborator'})`;
- `project.list` and `names` for users: any level, as FED 3.5.
`covers` needs no further argument for row 193. If r4.1 changes in review, 193b1 follows it.
Follow-ups, not in this row:
- A read-only users and grants page for the owner in the Console.
- A second owner or admin level, if Jason wants one.
- An explicit, logged owner audit verb for user threads, only if Jason asks for one (sage-1354).
## 10. Gate
Per the brief: Duckworth approves this design, then the manifest. The bus, business, cli and webui suites and
every `scripts/test-*.sh` pass on Sage's gate, and the section 8 mutants are killed. No test touches the live
directories. No product code before Duckworth approves.