Files

1803 lines
146 KiB
Markdown

# Case 2 design: the creative writing hub (r11)
Gosalyn, 2026-10-20 (from `date -u`). Track T2 in jason-master-restart `projects/mosaic-mvp/PLAN.md`,
Vikunja #416, DECISIONS Q176-Q178. The requirement is Jason's text in TARGET.md (addendum 2026-10-20).
This is a design only. No product code changes here.
Base: `refactor` at 9ae8c73ac. r1 cited 5d019d876. `git diff --stat 5d019d876 9ae8c73ac -- packages
adapters roles scripts` is empty (Mos in mos-0980, Webby in webby-0001), so every `file:line` holds at
9ae8c73ac. Paths are relative to the repository root.
## Ruling after r11: embeds and attachments (row 212 N3)
Webby's row 212 review (webby-0041, N3) found that the C2-01 reader reads `![[...]]` embeds as links, so
an embedded image, which is unmapped and so never indexed, counts as R4. Mos asked for a ruling from me
or Sage (mos-1110). Ruling: a wikilink or embed whose target ends in an extension other than `.md`, in
any case, refers to an attachment. The reader doesn't follow it, raises no problem for it, and the
survey counts it as an attachment. An embed with no extension or with `.md` embeds a note and resolves
like any link, R4 included. Section 1.3 says so. Row 212 lands as approved, and the change is C2-05a's,
the first row that reads the R4 count.
## Ruling after r11: the Console starts at boot (Q189)
Jason answered Q11 with "yes - automatic" (DECISIONS Q189, mos-1123). The Console on .54 starts at boot,
and the install starts it. This reverses mos-1097 item 1, so B1 under "Changes from r10" no longer
holds. The Console unit renders with `WantedBy=multi-user.target`, the same as the bus unit. Step 2c
enables both and starts the bus and then the Console. Step 9 no longer has Jason start the Console, and
a reboot brings it back with no human step. Row list C2-13a, section 9.3 and step 9 say so. Mos's
forgery question (mos-1097 item 2, #430) is unchanged, and so is the Console port below 1024
(mos-1110). With a boot start the port squat window is shorter, a crash's `RestartSec=15` and each
re-pin, but it is still there. The C2-13a brief carries this when Sage re-briefs it into the installer
row (I2, Vikunja #432).
## Ruling after r11: the sync's askpass helper (row 222)
Before building C2-03a, Megavolt compared 7.2's `GIT_ASKPASS` helper with row 199's credential helper
(megavolt-0060, asked by sage-1487). As written, 7.2 left three gaps. git's default
`http.followRedirects=initial` follows a 302 to another host. A helper that prints the token answers any
prompt, with no host check. And git asks askpass for the username too, so the token went out as the
username. Dewey (dewey-0765) and I (gosalyn-0068) accepted the fix, and Sage ruled it into the C2-03a brief
(a304dad94).
The argv is `SAFE`, then `-c protocol.https.allow=always`, `-c http.followRedirects=false` and
`-c credential.helper=`, then `-C <mirror>`. The env adds `LC_ALL=C`, because the helper matches git's
prompt text and git translates that text. The helper answers only the exact Username and Password
prompts for the configured host and user, and exits 1 on anything else. The URL carries no userinfo. The
helper reads the slot path, so the bus never holds the value, and that is why it doesn't use row 199's
fd 3. Section 7.2 says so.
## Ruling after r11: playgrounds at three levels (mos-1233)
TARGET.md:92-95 asks for a playground "where new universes can be created", a playground "for books in a
universe", and says "universe-level and book-level playgrounds must be provided". 1.1 put every
playground prefix inside a book folder (B1 under "Changes from r2"), so a playground with no book, and one
with no universe yet, had no place. Mos asked for this ruling (mos-1233, Vikunja 464). Ruling: a
playground has one of three levels, set by where its prefix sits.
- Book level. A prefix in `universes.<u>.playground` that lies inside one of `<u>`'s book folders, as
today. Its files have universe `<u>` and that book.
- Universe level. A prefix in `universes.<u>.playground` that lies outside every book folder of every
universe. Its files have universe `<u>` and book `null`.
- New universe. A draft universe is a `universes` entry with `"kind": "draft"`, a `name` and one or more
`playground` prefixes, and no `books`. Its prefixes lie outside every book folder. Every file under them
is playground, with universe `<draft id>` and book `null`. She starts a new universe there. When it is
real, she edits the business file to turn it into a plain universe with books and moves the files in
Obsidian. M9 keeps agents off that edit, as for every mapping change.
The validator replaces `library-playground-outside-book` with two refusals. A prefix that lies inside
another universe's book folder refuses `library-playground-other-universe`. A draft with `books`, or
with no `playground` prefix, refuses `library-mapping-invalid`. The rule that no prefix holds another, and
no prefix is listed twice, covers all three levels. A prefix equal to a book folder is still refused.
A prefix that holds a book folder refuses too, since prefixes are checked first and the book would turn
into playground: `library-playground-other-universe` when the book belongs to another entry, and
`library-mapping-invalid` when it is the entry's own. A hybrid's `sources` can't name a draft, since a
draft has no canon.
`kind` is absent for a plain universe, `"hybrid"` or `"draft"`, and any other value refuses
`library-mapping-invalid`. A draft takes the keys `kind`, `name` and `playground` only, and `playground`
must name at least one prefix. A plain universe still takes `name`, `books` and `playground`, and a
hybrid keeps its key list.
REQ-LIB-2's scope rule still reads from the path alone, and a playground at any level is never canon.
The rule checks prefixes first. A `.md` file under any universe's or draft's playground prefix is
`playground`, with that entry's universe and the book folder that holds the prefix, or `null`. Otherwise
a `.md` file in a book folder is `canon`. Everything else is `unmapped`. Prefixes never overlap, so one
prefix at most matches. The lookups' canon default, `playground-cited-as-canon` and the Console's scope
badge apply at every level unchanged.
REQ-LIB-3's one-universe rule holds as written, because a draft is a universe entry. Every lookup still
names one universe, and its results never leave it. A lookup in a draft with the canon default returns
nothing, so a lookup there passes `scope: "playground"`. A universe-level playground file comes back from
a lookup with `book: null`, and a lookup that names a `book` leaves it out. `library_manuscript` needs a
book, so it doesn't read universe-level or draft files. `citation-cross-universe` treats a draft as its
own universe. R5 covers a canon file that refers into any playground, and R6 covers one that refers into
a draft. `library_universe` on a draft returns its kind, name and prefixes.
The build is a new row, C2-01c, in `packages/library` `mapping.mjs` and `scope.mjs`, with a test per
level, per new refusal and per lookup rule above. It goes before C2-05a and C2-06 read `classify`, or into
their candidates if they've started. C2-12's setup sheet gets one line per level. Sections 1.1, 1.2 and 8
say so.
C2-01c's brief names every reader of a universe's `books` and of `classify(...).book`, so a draft with no
books and a null book reach no code that assumes them. At refactor cca206cdc these are:
- `mapping.mjs:73-78`, which builds `books` and `bookOwner`, and `scope.mjs:11-18`, which C2-01c
rewrites;
- `reader.mjs:182` and `:194`, which copy `book` into every file record, and `:208`, which reads
`sources` and works for a draft as it is;
- `survey.mjs:66` and `mirror.mjs:246`, which read only `scope`.
The rows not landed are C2-05a (row 216), with its book filter, `library_manuscript` and the
`library_universe` reply, and C2-06 (row 217), with the guard's universe check. Each takes a null book
through C2-01c's tests, or C2-01c goes into its candidate. The tests at `mapping.test.mjs:16-20` pin
`library-playground-outside-book` and change with this row.
Webby's round d1 on this ruling (webby-0088, REVIEW-d1 at webby/reviews 35130c26b) found the missing
refusal for a prefix that holds a book folder (B1), and asked for the `unmapped` row, the `kind` values,
the readers and the section 8 line (N1-N4). All five are in the text above, in 1.2 and in 8.
## Ruling after r11: idea items capped at 3 of 120 characters (Q219)
Jason answered Q26 with "yes" (DECISIONS Q219, mos-1299, Vikunja 465, ludwig-0014 Q4). An `idea` item
is at most 120 characters, at most 3 per reply. The rest of M5 is unchanged: 10 items and 1,200
characters of item text per reply, 300 for `fact`, `finding`, `correction` and `question`, and the
correction edit distance. The residual in 5.1 is now 360 characters of free suggestion text per reply,
and 10 step 8 is closed by Jason. C2-06 hasn't landed, so it builds with the new numbers, and its tests
refuse one past each new boundary and accept at it.
## Ruling after r11: reply limits are settings (Q222)
Jason ruled Q222 (DECISIONS Q222, mos-1302): "Things should NOT be hardcoded." Q219 stands for now, and its
numbers become the defaults. Every M5 limit is a setting in a new top-level `replyLimits` object of the
business file:
| Key | Default | Valid value |
|---|---|---|
| `itemsPerReply` | 10 | an integer from 1 to 1,000 |
| `itemTextPerReply` | 1,200 | an integer from 1 to 100,000 |
| `ideasPerReply` | 3 | an integer from 0 to 1,000 |
| `ideaLength` | 120 | an integer from 1 to 100,000 |
| `itemLength` | 300, for `fact`, `finding`, `correction` and `question` | an integer from 1 to 100,000 |
| `correctionMinDistance` | 12 | an integer from 0 to 100,000 |
| `correctionPercent` | 25 | an integer from 0 to 100 |
A `correction`'s `to` is within edit distance max(`correctionMinDistance`, `correctionPercent`% of
`from`) of its `from`.
Where. The object sits beside `projects`, not inside the library entry, because M9 keeps `projects` to
hand edits and Mos asked for a CLI verb. `TOP_KEYS` (`packages/business/src/business.mjs:24`) gains `replyLimits`, and
`validateBusinessDocument` accepts any JSON value under it. A bad hand edit then never stops her business
from loading.
Who changes them (M11). Only the business file's `human`, through a new human verb `library.limits.set`
with `revision` and `replyLimits`. She runs it as `mosaic limits replies --file <json>`, a new verb in
`packages/cli/src/limits.mjs` beside `mosaic limits set`, and later from the Console. The verb replaces the whole object, and a key left out means its default. It sits outside
`ORGANIZATION_KEYS` and `TESTER_ORGANIZATION_VERBS`, as `instances.set` does (`broker.mjs:219`, `:139`,
sage-0918). An agent session gets `human-required` and the tester gets `tester-refused`. A value outside the
table refuses `invalid-request`, and so does a key not in the table. Nothing is written.
The fence that covers every other path sits in `organizationWriter.write`
(`packages/business/src/organization.mjs:180`), next to M9. It compares against the file on disk, `old`
in `write`, never against the incoming document. A write whose `replyLimits` differs from
`old.replyLimits`, compared in canonical JSON, refuses `organization-reply-limits-human-only` unless both
hold:
- `actor` equals `old.human`. A write that sets `human` to its own actor in the same document still
compares against the old `human`, so it refuses.
- `actor` isn't `tester` and isn't `<definition>.<role>` for any role in `old.roles`, the two shapes of
an agent actor (`broker.mjs:4201`, `:4206`, `:4306`). `ID_PATTERN` (`vocabulary.mjs:199`) allows a dotted
`human` id, and the broker refuses only `tester` (`broker.mjs:328`), so without this a human id such as
`assistant.bean` would let that agent through.
As M9 does (`organization.mjs:191-193`, `:199-200`), the fence checks `document` and then `written`, the
JSON round trip, so a value with a `toJSON` can't differ between the check and the disk. That stops the
chief of staff's `organization.set`, any actor's `limits.set`, `instances.set`, `routes.set` and
`plugins.set`, the tester, and a direct call to `write`. Every setter builds its document with
`structuredClone` of the file, so none of them changes `replyLimits` today and the fence refuses no
ordinary write.
Read at check time. The guard reads `replyLimits` from the business file on each check, through the
organization reader, not from the broker's copy loaded at start (`broker.mjs:419`, read by `#business`
at `:570`). A change applies to the next reply with no restart.
A failed read never skips the check. If the business file is missing, can't be read, isn't JSON or isn't
an object at check time, the guard checks the reply at the defaults, except `requireCitation`, which
resolves to `true` (webby-0151 R2, ruling after r11), and `library.status` shows
`reply-limits-unreadable`. The reply is neither refused for that alone nor let through unchecked.
Fallback. Each key resolves on its own. The guard reads a key only if `Object.hasOwn(replyLimits, key)`,
and uses it only if `Number.isInteger(value)` is true and the value is within the table's range. Anything
else resolves to its default: a missing key, `null`, a boolean, a string such as `"5"`, an array such as
`[7]`, an object, a fraction, `NaN`, a negative number, a value below the minimum or above the ceiling.
Every key resolves to its default when `replyLimits` is missing or isn't a plain object, an array
included. The guard never compares a value it hasn't checked with `Number.isInteger`, so JavaScript's
coercion of `true` or `[7]` to a number can't reach a range check. Unknown keys are ignored when read. The
guard has no path without a number, so no value reads as "no limit". `library.status` shows the limits in
force and names each key that fell back.
The ceilings are validity bounds, not policy: they keep a value from reading as unlimited. 100,000
characters is far past any reply, and Mos can change them. At the ceilings the human can make the guard
loose. The idea residual is `ideasPerReply` times `ideaLength`, up to 100,000,000 characters at both
ceilings, and `correctionPercent` 100 lets a correction replace its whole `from`. Cites stay required at
every value. `library.status` shows the residual, and the setup sheet says so in one line.
What agents see. The contracts don't state numbers. A refusal reason names the key and its value in
force, so the runner's retry (C2-07) tells the agent the limit it broke.
Hold, now lifted. Q29 is answered (Q223, Q226). The next ruling replaces this table's seven keys with
`requireCitation` and `citationsPerReply`, and C2-06 builds that.
## Ruling after r11: agents never write story (Q223, Q226)
Jason answered Q29, and Mos's reading of it, Q30, is confirmed (DECISIONS Q223 and Q226, mos-1305,
mos-1309). F40 to F43 come with it, and mos-1308 sets the tool set. This ruling follows my impact list,
gosalyn-0148, with Mos's eight points, Mos's rulings on my flags in mos-1320, and Webby's round,
webby-0151 R1 to R3 and N1 to N5. It replaces D3, D5, M5, most of 5.2, the Q222 hold and 7.3's
"after the slice". Sections 0, 1.1, 1.2, 2.1, 3, 4, 5, 7, 8, 9.2 and 10 say so.
Story and supporting. Her chapters and story files stay read-only to agents. A human marks folders as
supporting through a new `supporting` list in each universe entry, beside `playground` (1.1). An agent
may create, edit and delete `.md` files under a supporting prefix, and nothing else in her repo. A folder
she hasn't marked is story. A folder in both `playground` and `supporting` is writable. A playground
folder alone isn't (Mos point 4). The prefix rules are the playground's: a prefix inside a book folder is
book level, one outside every book folder is universe level, no prefix holds a book folder or another
supporting prefix, and one inside or holding another universe's book folder refuses the business file
with `library-supporting-other-universe`. M9 already keeps the list to hand edits, since it sits in
`projects`.
Chat. A reply is free text of any length, within the broker's message limits as they are today. The item
list goes, and with it the item kinds, `CREATIVE_KINDS`, the M5 caps, the three notices,
`cited-reply-required`, `reply-kind-not-allowed` and `correction-unbound`. A reply may carry citations
(5.2). Jason's "NEVER" now reads as story in her files, and M12 enforces it in code.
Citations are tunable (F43). `replyLimits` keeps the Q222 mechanism: the object, `library.limits.set`, M11
and the read at check time. The seven Q222 keys go, since their subject is gone. The keys now are:
| Key | Default | Valid value |
|---|---|---|
| `requireCitation` | `false` | `true` or `false` |
| `citationsPerReply` | 20 | an integer from 1 to 1,000 |
The fallback rule holds. A key is read only if `Object.hasOwn` finds it, `requireCitation` only when
`typeof` gives `boolean`, and `citationsPerReply` only when `Number.isInteger` is true and the value is in
range. Anything else resolves to the default, and `library.status` names the key. So a bad
`requireCitation` hand edit reads as off, which is Jason's default and not a weaker guard than he chose.
The verb refuses it with `invalid-request`. One case differs from the defaults. When the business file
can't be read at check time (the Q222 rule), `requireCitation` resolves to `true`, so a setting she turned
on doesn't fail open, and `citationsPerReply` takes its default. `library.status` shows
`reply-limits-unreadable`, and the runner's fixed texts still pass (webby-0151 R2). When
`requireCitation` is true, a reply with no citation refuses `citation-missing`. When a reply carries
citations, the four checks in 5.2 run whatever the setting.
Links. Wiki links already in her files stay. The hub never rewrites a file it wasn't asked to write, and
agents can't write a chapter at all, so they add no link inside one. Their cross-links go in supporting
docs, and Obsidian's graph shows those. The `[CH]` alternative, items 42 to 46 of my impact list, is
dropped. A Console brain map is a later option and has no row.
The write path. Two agent verbs, each with a typed tool:
- `library.doc.write {universe, path, base, content}`, tool `library_doc_write`. `base` is the blob id the
agent read, from `library_get`, or `null` to create.
- `library.doc.delete {universe, path, base}`, tool `library_doc_delete`.
Only a session of a `CREATIVE_DEFINITIONS` instance holding the new action `library.write` and a live
Gitea grant on the library project may call them (`library-write-refused`, `library-write-no-grant`).
The tester gets `tester-refused`. M12 checks the path before anything else. `content` is UTF-8 with no
NUL, at most 1 MiB, the reader's R1 limit. An agent's call passes the runner's tool socket and the bus
socket, and both cap a request at 64 KiB today (`runner.mjs:294`, `server.mjs:62` at aca16404c). So an
agent write over that refuses `request-too-large` before the broker sees it, and the 1 MiB limit binds
only her saves. `library.file.save` joins the `project.file.save` exception at `client.mjs:19` and
`server.mjs:9` and `:62`, with the same `PROJECT_SAVE_REQUEST_MAX`.
Each write is a plumbing commit on the bare mirror, with no working tree. Under the library lock the bus
fetches, reads the path at the remote tip, and compares it with `base`:
1. Equal, or both absent for a create: it writes the blob, builds the tree from the tip's tree with
`GIT_INDEX_FILE` pointing at a temporary index outside the mirror, and runs `commit-tree` with the tip
as the one parent. The author and committer are the seat's `identity`. The message is a fixed line,
`Mosaic: write <path>` or `Mosaic: delete <path>`, and a trailer `Mosaic-Agent: <business>/<instance>`.
The agent supplies no message text.
2. Not equal: it refuses `stale-base` and writes nothing.
3. It pushes `<commit>:refs/heads/<branch>` with no `+`, so the push is fast-forward only (D6). A
rejection for a moved tip fetches once more and goes back to step 1 one time. A blob still equal to
`base` re-applies on the new tip. Otherwise the write refuses `stale-base`.
4. Any other push failure refuses `library-push-failed`, and the commit is left unreferenced. Nothing is
kept to push later, so there is no `unpushed` state and C2-17 drops.
5. After the push, the bus moves `refs/remotes/library/<branch>` and the served ref to the new commit,
which is a fast-forward of the served commit, rebuilds the index, records `lastPush` in the sync state
and stores a notice.
The notice is a message from the writing instance to `human` in that instance's thread. The broker
builds its body, `{"notice": "library-write", "op", "path", "commit"}`, and no agent text reaches it.
The Console shows it as a fixed line with a Revert button (7.4).
`library.revert {commit}` is the human's. It refuses `library-revert-not-agent` unless `commit` carries
this business's `Mosaic-Agent` trailer and is reachable from the served commit. For the one path that
commit touched, it puts back the parent's blob, or deletes the path if the parent had none, as a new
commit by the same steps, with `base` the commit's own blob. A later change to that path makes it refuse
`stale-base`. A revert is her act, so its author and committer are her `editor`, and it pushes with slot
`library-human`, as her saves do. It still works after the agent's grant is revoked or the seat is fired.
A cite to a file whose last commit at the cited commit has a `Mosaic-Agent` trailer is labelled
agent-written (Mos point 3). The broker reads the trailer with `git log -1 --format=%(trailers:key=Mosaic-Agent,valueonly) <commit> -- <path>`,
and the label rides in the stored reply's check result, so no agent can set it.
M12 is the fence. A new pure function in `packages/library`, `supportingPath(mapping, universe, path,
tree)`, and the broker calls it on every agent write and delete. It refuses `library-write-story-path`
for a path that isn't a `.md` file under a supporting prefix of `universe`. That covers an unmapped path,
a `.canvas` or `.base` file, an attachment, any story or canon path, a playground path that isn't also
supporting, and a path under another universe's prefix. It also refuses an absolute path, a `..`, `.` or
empty segment, a backslash, a control character, a path whose any prefix in the tip's tree is a symlink
or a gitlink, a path that differs only in case from a tree entry it doesn't equal, and a path over 1,024
bytes. A delete also needs the path to exist at the tip as a `100644` blob.
Who writes, with what (F40). The shared bot account goes. Each writing seat writes with its own Gitea
account and token from the per-seat grant path (Q190 O1, #436b, row 236). The token stays in the bus's
credential store, out of every sandbox (#436b section 3c), and no agent session holds it. The askpass
helper from 7.2 reads the seat's store file for that one push, with the seat's Gitea user as the only
username it answers.
The chief of staff of her business is enrolled at install and grants the other seats through the
existing step: the chief raises a `credential.mint` decision with a gitea grant for `books`, and the
executor runs on its existing approve (436b section 4, step 0). Whether each such grant also needs
Jason's Approve is Q31, asked of him. This design adds no step for it, and a yes adds none here.
The executor today forks each project and refuses any other reach (436b steps 4 and 5). A library
project can't work that way, since the writes must land in her repo. C2-21 changes the two steps for a
project of `kind: "library"` only:
- The raise check. `#giteaRole` (`broker.mjs:2122-2135` at aca16404c) refuses `gitea-not-declared` for
any creative role, whatever its definition declares (mos-1163). F40 narrows that ruling. A creative
role passes only when every project the raise names is `kind: "library"`. A raise for a creative role
that names any other project still refuses `gitea-not-declared`, so a creative seat never gets a fork.
The other checks stay: the definition's gitea credential, `identity.gitea` and a non-empty `publish`,
which C2-08b supplies. Mos ruled the narrowing in mos-1320 item 2: mos-1163 still covers every gitea
project except `kind: "library"`, and covers host grants whole. The host grant writer, the executor's
step 1 recheck and `host.run` keep refusing a creative role with `host-creative-role`. The comment at
`broker.mjs:2120` cites mos-1320.
- Step 4 adds the seat's user as a collaborator on the project's own repository with the admin token,
`PUT /repos/{owner}/{repo}/collaborators/{user}`, in place of a fork. The permission is `write` for a
`CREATIVE_DEFINITIONS` instance and `read` for any other, the chief included. The definition decides
it, and the raise can't name one.
- Step 5 accepts that repository in the reach list when its `permissions` show exactly that level, with
`admin` false. Anything wider still refuses `gitea-reach-wider`.
- The rendered block names the repository and the level instead of a fork URL, and adds the line "The
token can write every path of this repository. Only the bus's path check limits it to supporting
folders."
- A revoke deletes the token as today, which ends the write. The collaborator entry stays, as forks do.
A fire's delete sets `prohibit_login` as today.
Two facts aren't proven yet, and C2-21's first test proves them on a scratch Gitea 1.27.1 before any code,
as F1 to F11 were: that the admin token's scopes (436b 3b) can add a collaborator to a repository the
admin account doesn't own, and that `/user/repos` with the seat token shows that repository with the
granted `permissions`. If either fails, C2-21 stops and goes to Mos.
The mirror's fetch uses the chief's token, whose grant is `read`, and the chief's user in the askpass
helper. That replaces the bot slot `library-gitea` from C2-03a. C2-03b makes the change. The fetch falls
back to slot `library-gitea` only while the bus has never recorded a gitea grant event for the chief.
The check reads the append-only events table (`packages/bus/schema.sql:131-132` at aca16404c) for any
`GRANT_EVIDENCE` event (`broker.mjs:246`) with `method` `gitea` and `agent` the chief. It never reads
`gitea_grants`, whose row a revoke and a grant-lost delete (`broker.mjs:2785`, `:2609`), and which has
no state column (`schema.sql:469-480`). It names no project, since `grant.applied` and `grant.revoked`
carry none (`:2469`, `:2787`), so a chief grant on any project ends the fallback, which fails safe
(webby-0156 R4). An install that runs before C2-21 still syncs. Once such an event exists, a revoked
or `held` grant, or a fired chief, refuses the fetch with `library-fetch-no-grant`, and `library.status`
shows it. A write, a revert and a save never fall back to the slot. When `library.status` shows the fetch
on the chief's grant, Jason deletes slot `library-gitea` (10 step 4b, webby-0151 R3).
Her edits (F41). She edits in the Console. Each save is one commit by the same steps, through the human
verb `library.file.save {path, base, content}`. The author is a new `editor` object in the library entry,
`{name, email}`, which the setup sheet asks her for, and the push uses her own Gitea token from a new slot
`library-human` (mos-1320 item 3). A save or a revert with no `editor` refuses `library-mapping-invalid`.
One with slot `library-human` missing or failing the helper's file checks refuses `library-human-missing`
before any git call. Her saves reach any mapped `.md` file. M12's other path rules hold for them, and only
the supporting rule doesn't, since the files are hers. They still refuse
`stale-base` when the file moved, because an agent write or an Obsidian push can race her save. She sees
an agent's write at once in the Console, and Obsidian sees it on its next pull. Agents see her save at
once too, since the bus moves the served ref after its own push. Her Obsidian pushes reach them through
the 60-second sync. The editor is a Q195 add-on, so C2-11a and C2-11b are slice rows that don't gate
10-27.
Rows (F42). C2-02, C2-03b and C2-11b join the slice, and C2-11a with them, since C2-11b needs it. Sage
queues them. C2-07 folds into C2-06. C2-17 drops. Five new rows carry what landed or built rows can't:
C2-01d, the `supporting` list and scope, C2-05c, the `supporting` scope and `blob` in the lookups,
C2-08b, the role changes, C2-12b, the setup sheet rewrite, and C2-21, the grant change. C2-05b is row
224, built to its earlier brief, and it lands as built (sage-2833). Its tools already match M3 below.
Section 8 lists them. Mos asked for a PRD row too: REQ-LIB-6 rewritten to Q223 and Q226, before C2-01d
and C2-02 (mos-1320 item 8).
M3 (mos-1308, Mos point 8). A creative session's typed tools are exactly the five lookups,
`send_message`, `library_doc_write` and `library_doc_delete`. `companion` and `research` also get
`web_search` and `web_fetch` through the existing web grant. So the per-role snapshot holds eight names,
or ten for those two. mos-1308 said six or eight, before Q226 added the two write tools, and Mos
accepted eight and ten in mos-1320 item 4. None of `read_spec`, `list_agents`, `inbox`, `trail`,
`write_record`, `raise_decision` or a tracker read reaches a creative session.
`packages/harness/src/tools.mjs` at aca16404c adds all but `raise_decision` to every session, and
already skips `raise_decision` for a creative definition (`:241`, webby-0151 N3).
Residual. A supporting doc can hold any text, a drafted scene included, and code can't tell a scene from
a note. Every agent write is visible as a notice and a trailer, and she can revert it. Nothing an agent
writes lands in a story path (M12). At Gitea the seat token can write any path of her repo, and the bus's
check is the only path limit. The token never leaves the bus, so the limit holds while the bus code does.
C2-21's scratch Gitea run also records two facts that don't gate it: whether a protected file pattern
refuses a seat user's push to a story path while her push still passes (mos-1320 item 5), and whether
branch protection refuses a seat user's force-push while her normal push passes (webby-0151 N4). If the
first works, Mos files a task for it as a second fence after the slice. The second would back D6 at the
remote and wouldn't narrow the paths.
## Changes from r10
Webby returned r10 with B1-B3 and N1-N5 (webby-0036, `agents/webby/work/case2-r10/REVIEW.md` at
2beb49dae). Mos ruled on B1 in mos-1097.
- B1. The Console unit renders with no `[Install]` section. Jason starts it at step 9 and after each
boot. Decisions 94 and 102 keep the start with Jason, and Mos reads his grant to Mos in 102 as covering
dragon-lin only. The bus unit stays boot-enabled, since it holds no human capability. Step 9 states
the tradeoff. The r10 paragraph below, which let a boot start stand in for the human's start, no longer
holds.
- Mos's forgery question (mos-1097 item 2). New section 9.3 answers it from the code: which processes
run as `mosaic`, what a session can reach from inside bwrap, the narrowest form by 10-27, and what
closes the rest. It also adds one finding of mine, a port squat while the Console is down, and the
fix the C2-13a brief now carries, a privileged port.
- B2. The render has a rule for every token, `@PATH@` included, and refuses any token left over
(C2-13a brief).
- B3. The brief lists the exact hardening lines, none of which restricts namespaces or system calls.
Step 9 checks that one session starts under the bus unit.
- Split (sage-1480). C2-13 is now C2-13a, the unit render and the host precheck with no library
dependency, which row 195 chains on, and C2-13b, the install and the precheck's library checks. The
precheck file is shared: C2-13a creates it and C2-13b adds to it. C2-04's owner is Herb, not Rocko
(sage-1482).
- N1-N5 are in the C2-13a brief: the bus unit's `WantedBy=` line is replaced, not added. There is no
`After=`, with the reason. The serve checks are copied into `case2-unit.sh`, and the serve flags are
named. There is no `--checkout`, so the queue panel is out. Two units naming different copy ids refuse.
## Changes from r9
Darkwing's row 195 question (darkwing to gosalyn, after Duckworth's B1) found a gap. r9 named one
system unit, `mosaic-stack.service`, and never said what it runs. The stack is two units today: the bus
host (`packages/cli/systemd/mosaic-bus.service.in`) and the Console
(`packages/webui/systemd/mosaic-webui.service.in`). The slice needs both, because C2-10b is Console
chat. Now 2c renders both from those templates as system units, `mosaic-bus@<business>.service` and
`mosaic-webui.service`, with the same copy-id rule, and no new `mosaic-stack.service.in` exists. The
bus unit carries the `EnvironmentFile=` line. The Console unit sets no credential variable, as its
template says. The node path and the Console's serve flags are install inputs, fixed across re-pins.
The copy id is the only value a re-pin changes.
One thing this makes explicit. The Console template says whoever starts it holds the human's capability,
so Jason starts it, or Mos under lead decision 102. A system unit that systemd starts at boot holds that
capability with no human starting it. On her host the human is Melanie, signing in through step 5. So
the step 9 go is the human's start, and a reboot restarts the Console under that same go. If Mos or
Webby reads decisions 62 and 94 as needing a human at every start, the Console unit gets no `[Install]`
section and a human starts it after each boot.
## Changes from r8
Jason's Q7 answer, "Q7: yes" (DECISIONS Q186, mos-1090, sage-1469). O4 is the model path.
- Model path (9). For 10-27, Bean and Chet both run harness pi with provider `zai` on `glm-5.3`. Pi
reads the key from `ZAI_API_KEY`, and row A puts that name in `ENV_ALLOW`
(`packages/seat/src/session.mjs:83-88`). Chet moves to his ChatGPT/Codex login after 10-27, in row B
(Vikunja #428), which I design. Running a pi role still waits on #383.
- 10 step 2. 2b no longer leaves "which model accounts" open. New 2d: Jason places the hub's own key on
the existing Z.ai account, with a spend cap if the account offers one. Whose account it is stays his
input. `melanie`'s sudo can read the key until #423 step 5.
- Two consequences in C2-13. A pi launch has no credential check, so the precheck checks the key file
by name and mode and never prints the value. The claude marker prompt now runs only when some
creative instance's harness is claude-code, because with the model path above the host may have no
Claude login. The unit template's `EnvironmentFile=` line is one of the fixed lines `case2-unit.sh`
checks.
- C2-20 (9.1). The dry run also counts lines naming a Hermes tool or channel, counts only (gosalyn-0030,
mos-1087).
## Changes from r7
Webby's review (webby-0025, CHANGES, notes at agents/webby/work/case2-r7/REVIEW.md, 19bb3ae36).
- B1. r7 had root install a unit file that `mosaic` wrote. By 2c, package code has run as `mosaic` in
2b and could rewrite that file to `User=root` or another `ExecStart`, so the user without sudo had a
path to root, and again on every re-install. Now `mosaic` writes no unit. In 2c, root works only from
a root-owned checkout of the reviewed commit outside `/srv/mosaic`. It renders the unit from that
checkout's template, with the user and home as inputs, and checks `User=`, `Group=`, `ExecStart=` and
the hardening lines against fixed values. It then writes `/etc/systemd/system/mosaic-stack.service`
as `root:root` 0644, and it never uses `systemctl link`. Webby suggested the template in the release.
The release sits under `/srv/mosaic`, where `mosaic` can write it, so I used a root-owned checkout
instead. 2c refuses an installed unit that differs from the render, and a symlink at the unit path.
C2-13 tests a unit edited to `User=root`, and 2c refuses it.
- N1. `mosaic` has no keys and no login shell, so 2b runs as `sudo -u mosaic -H` from `localadmin`.
Nobody adds a key to `mosaic` to make 2b work.
Mos lifted the mos-1050 hold on step 1 after Webby passed it (mos-1074). Section 10 still goes to
Jason one step at a time.
## Changes from r6
Webby's review (webby-0023, CHANGES, notes at agents/webby/work/case2-r6/REVIEW.md, 216302d65).
- B1. r6 said the install creates `mosaic`, but step 2 and C2-13 run the install as `<user>`. A user
can't create itself, and `mosaic` without sudo can't install a system unit. The #421 plan that owned
those root steps stopped. They are now their own items in section 10: 2a creates `mosaic` and
`/srv/mosaic`, and 2c installs and enables the unit after the install. ops-01 owns them (mos-1064)
and runs them under Jason's go for step 2, over ssh as `[email protected]` with NOPASSWD sudo.
#423 step 1 creates that account. They use neither `melanie`'s sudo nor pve root. So the install
depends on #423 step 1 and on nothing else in #423. C2-13 writes the unit file and doesn't install
it. r8 replaces that, because `mosaic` must write no unit (webby-0025). Section 9 cites Q183 for the layout, not the stopped plan.
- Mos, mos-1064 item 3. r6 said Jason's acceptance covers `melanie`'s sudo. It covers the factory's
paths to .54 (Q182, Q183): pve root, ssh as `melanie`, and the root her sudo gives through those
paths. It doesn't cover the Hermes processes, which run as `melanie` and could use her sudo to read
`/srv/mosaic`. That part is Mos's residual. Q181 keeps Hermes running, and #423 step 5 removes her
sudo once Bean and Chet are live. Section 9 and the r5 notes now say so.
- N1. A system user always has its primary group. Section 9, the C2-13 row and the precheck now say no
supplementary groups, and the precheck checks that `id -G` prints exactly one entry.
The mos-1050 hold on step 1 stays until Webby approves r7 (mos-1064).
## Changes from r5
Mos, mos-1057, after Jason confirmed Q4 (DECISIONS Q183, read at `DECISIONS.md:1533-1545`). The #421
plan stopped and #421 is closed, so the install on .54 no longer waits on it. Only step 1 of section 10
and the isolation paragraph of section 9 change. The hold from mos-1050 lifts for step 1 once Webby
approves this diff. Steps 2 to 9 still go to Jason.
- Step 1 now says the install creates `mosaic` without sudo, FED R8 records Jason's acceptance, and the
in-VM tightening on .54 is Vikunja #423, which doesn't gate the install.
- Section 9 no longer counts on ops-01's step 2b to remove `melanie`'s sudo. Jason's acceptance in FED
R8 covers the factory's paths to .54. The Hermes processes that could use her sudo are Mos's
residual until #423 step 5 (r7, mos-1064).
## Changes from r4
Webby's review (webby-0020, CHANGES, notes at agents/webby/work/case2-r4/REVIEW.md, 0597cc198). I
re-read each cited line at 9ae8c73ac, and each one holds.
- B1. r4 said no verb writes `launch.models` for the human, and that Jason edits it by hand. That was
wrong, and I told Mos the same wrong thing in gosalyn-0014. `limits.set` is a human verb
(`broker.mjs:1248`) with the same `ORGANIZATION_KEYS`, `models` included (`:145`). `organization.set`
is `limits.set` for a role (`:1249-1251`), and both reach the same `set()` (`:1284`,
`launch-settings.mjs:150-169`). The actor rule was already right, because it keys on the actor and
not the verb. Section 4 now names both verbs on the `assistants`, `instances` and `models` paths. M10
and C2-08 add the case that shows the check keys on the actor: the human's `limits.set` change to
Bean's key works, next to the chief of staff's `organization.set` refusal of the same change.
- N1. Pi also uses the path as the prompt when the file doesn't exist (`existsSync` false,
`resource-loader.js:21`, `:30`), with no warning. The adapter's check is `[ -r ]`, which covers both
cases, and C2-08 tests a missing file as well as an unreadable one.
- N2. C2-13 runs one marker prompt through the host's claude with `--system-prompt-file` and checks
that the default prompt is replaced, not appended to. It doesn't rely on `--help` alone.
## Changes from r3
Webby's review (webby-0017, CHANGES, notes at agents/webby/work/case2-r3/REVIEW.md, fcac66f7b), with
Mos's rulings (mos-1051). I re-read each cited line at 9ae8c73ac, and each one holds.
- B1. `routes.set` was a fourth path. The tester may call it (`broker.mjs:103`), and `setRoutes`
(`launch-settings.mjs:240-277`) checks no definition before its write, so the tester could change a
creative instance's harness, model and thinking. r3's "could change no creative instance" was false
with `bus.tester` on. All creative-instance checks are now one rule: the write must come from the
business file's `human`. Role sessions write as `<definition>.<instance>` (`broker.mjs:1278`) and the
tester as `tester` (`:1272`), so neither passes. The path list in 4 names all five paths, and M10 has a
test for each (4, 5.1).
- N1, ruled refuse (mos-1051 item 2). `organization.set`'s `models` key (`launch-settings.mjs:169`)
could change the model behind a `launch.models` key that Bean or Chet names. A change or removal of
such a key now refuses, with a test and a mutant. r4 said the human never reaches this write. She
does, through `limits.set`, and r5 corrects it. Ending a run stays with
the chief of staff (mos-1038 item 2).
- N2 (mos-1051 item 3). Before Jason's go, C2-20 makes a dry run over her persona files that reports
only counts: files, lines, and lines starting `# ` or `## `. If the heading rule would refuse a real
persona, the rule changes before the import. Any rewrite of her text needs Melanie's say (9.1).
- N3 (mos-1051 item 4). The persona text sits in two more places, and 4 names both. `prompt.md` is
written 0600 into each run's bundle and kept until prune (`bundle.mjs:241-244`, `:265`). Both adapters
pass it as an argument (`adapters/claude/adapter.sh:79`, `:82`, `adapters/pi/adapter.sh:106`, `:121`),
so any local user on the host can read it in `/proc/<pid>/cmdline`. The adapters now pass the file,
not its text. Pi 0.85.1 reads `--system-prompt` as a file when the value is an existing path. Claude's
help names `--system-prompt-file`. C2-08 does this, and C2-13 checks the run root's mode.
- Webby's answers. Keying on the actor is enough, and C2-08 runs the tester cases through the broker
with a bound tester session, so a change at `broker.mjs:1272` fails a test. The heading refusal fails
closed, and N2 covers its risk.
Mos, mos-1038 items 2 and 3. The chief of staff keeps ending runs, which matches TARGET.md:66, and a
per-agent forbid comes through Jason (4). ops-01 makes the 16 GiB, 6-core change at
2026-10-21T09:00Z (9).
Section 10 step 1 is on hold (mos-1050). Jason isn't pursuing #421 and accepts the risk, and Mos is
confirming that reading with him. The step keeps its r3 wording until he does. If he confirms, step 1
becomes "Jason's written acceptance of the factory paths to .54 is recorded", and section 9's isolation
paragraph changes with it.
## Changes from r2
Mos, mos-1031 (Jason's answer to Q2, DECISIONS Q181). The host is now fixed (9, 10):
- `<host>` is 10.1.1.54, W-Melanie-OC, VM 1012. `<user>` is `mosaic`, a system user with no sudo, no
groups and no keys. Its home is `/srv/mosaic`, mode 0700. It runs as the system unit
`mosaic-stack.service` with `ProtectHome`, so the config directory and the token slot live under
`/srv/mosaic`. ops-01's plan, section 3, Vikunja #421.
- ops-01 sets VM 1012 to 16 GiB fixed with 6 cores, which covers my 4 GB estimate. The precheck still
prints free memory.
- The B7 residual from `melanie`'s sudo is no longer Jason's to accept. Step 2b of the plan removes her
sudo and lxd. Section 10 step 1 becomes "Jason approves the tightening plan (#421)".
Webby's review (webby-0009, CHANGES, notes at agents/webby/work/case2-r2/REVIEW.md, 162ce46db). I re-read
each cited line at 9ae8c73ac, and each one holds.
- B1. A playground prefix now sits inside a universe entry and must lie inside one of that universe's
book folders, so a playground file has a universe and a book. A prefix outside one refuses the
business file. Playground, like canon, is `.md` only, so a `.canvas` under a playground prefix is
unmapped (1.1, 1.2).
- B2. The persona text no longer goes in the business file. It lives in a 0600 file per instance under
`<configDir>/personas/`, and the business file holds only `persona.name` and `persona.sha256`. So the
tester's `instances` read, the Console's and `changes.jsonl` carry the digest, never the text. The
modes of the business file, `.history` and `changes.jsonl` are stated from code and tested in C2-08,
and the C2-13 precheck checks them and refuses `bus.tester` on her business (4).
- B3. `launch-settings.mjs:140`, `:186` and `:191` stay `THREAD_DEFINITION`, so the chief of staff's
`assistants` key never reaches Bean or Chet, with a C2-08 refusal test. `organization.set`'s
`instances` key refuses a creative instance for any actor. `instances.set` gets one branch, so the
human can add or change a creative instance without a hand edit, and the tester can't. That is how a
model change, a persona change or a later specialist happens (4, 10 step 7). This also moves C2-08 from
Haiku to Opus, because it now changes who may write the business file (M10 in 5.1).
- B4. The persona block goes between the contract and "## This session", fenced like skills, and the
bundle refuses text that holds the end marker or a level 1 or 2 heading (4).
- N1. `library_get`'s `links_out` and `links_in` drop other-universe and unmapped targets (3).
- N2. The setup sheet says moving a note into a book folder makes it canon (7.1).
- N3. M9 sits in `organizationWriter.write` (`organization.mjs:177`), compares all of `projects`, hybrid
entries included, and its test calls `write` directly (1.1, 5.1).
- N4. Obsidian resolves a shortest-path link across her whole vault, `~/src`, and the reader sees one
repo, so the two can resolve one link to different files. Stated in 1.3 and the setup sheet.
- Webby's answers to my three questions: reporting is enough for 1.3, M9 holds today, and the persona
order is B4. I take all three as written.
Mos, mos-1033. B2 and B3 are required, and the chief of staff gets no path to rewrite Bean's or Chet's
persona, because the personas are Melanie's. With B3 above, only the human changes a persona or a
creative instance, and no organization read or history line holds persona text. C2-20 reads the same
things as in r2. Only its output changes, to the persona file and its digest (9.1).
A correction to r2. r2 said that with `END_PROTECTED` set to `THREAD_DEFINITIONS`, only the human could
end Melanie's agents. That's wrong. An `END_PROTECTED` instance is ended by the chief of staff or by a
decision the human resolved (`broker.mjs:148-150`, Q169). Ending a run changes no persona (4).
## Changes from r1
Jason's rulings (DECISIONS Q180, relayed in mos-1014) come first, because they set the slice:
- She talks to her agents inside Mosaic Stack. The 10-27 slice has no Discord dependency. Its chat
channel is the Console (C2-10b), which renders cited items and shows sync status, so B1 and B2 are
solved there. The M5 guard and cite check cover it through D8. The Discord relay (C2-18) moves after
the slice as an option, and M8 stays in the slice so no Discord path can skip the guard (5.1, 7.6).
- Bean and Chet become Mosaic agents with the same names and personalities. They are two instances of
a new creative definition, `companion`, and each carries its persona in a new agent-layer var (4).
New row C2-20 reads only the persona definitions of the two Hermes profiles on 10.1.1.54 (default and
chet) and confirms which profile is which name. Her Hermes memory store (188 facts) and chat history
stay where they are unless she chooses otherwise through Jason.
- Hermes keeps running until Bean and Chet are live in Mosaic. Nothing in this design stops, changes or
shares anything with Hermes, its bots or their tokens (9).
- The host is a variable, `<host>`, with the instance under its own user `<user>` without sudo. Mos's
reading, which he is confirming with Jason, is her VM 10.1.1.54 with RAM added (9, 10).
Mos's host check (mos-1000, jason-master-restart `projects/mosaic-mvp/MELANIE-HOST-RECON.md`) changed
the data model:
- Her library already exists. Her books repo on git.mosaicstack.dev has 203 Markdown files and 3,486
commits. obsidian-git syncs it, and each book folder has its own subfolders. r1 invented a new
`universes/` tree and a `library.json`. r2 drops both and reads her repo as it is. A mapping in the
business file names her book folders, her type folders and her playground folders (1.1). Her repo
gets no new file.
- Canon and playground map onto her layout by path prefix. A file under a configured book folder is
canon. A file under a configured playground prefix is playground. Anything else is unmapped, and
agents can't see or cite it (1.2).
- Her rules aren't mine to impose on 3,486 commits of her work. The validator now reports problems and
never holds back her latest commit. The scope guard in code is the path rule plus the cite check, and
neither depends on her files passing a rule (1.3).
- In the slice the bus never writes to her repo. Agents can't write anyway (D3), and the Console editor
comes after the slice. So the slice reads a bare mirror through a read-only token, and pushing moves
to later rows (C2-02, C2-03b).
- Her vault root is `~/src`, one level above the books repo, so Obsidian's absolute links may start with
the repo's folder name. The link resolver strips a configured `vaultPrefix` and also resolves
Obsidian's shortest-path form. A link into another of her repos never resolves. That keeps bean-brain
out by construction, and the bus token reaches only the books repo (mos-1000 item 3).
- The instance has its own user and no shared state with anything else on its host. Row 190 doesn't
block it. C2-13 delivers an install script and a precheck that Jason or his delegate runs on `<host>`
(9, 10).
- Mos, mos-0992 item 3. Any Discord output must go only through the broker's stored, guarded messages.
Today's connector posts its own pi engine's text straight to Discord
(`packages/discord/src/connector.mjs:381`, `:386-387`), so it would skip every guard. M8, in the slice,
refuses that engine on a library business. The later relay (C2-18) posts only stored messages, and its
tests prove it (5.1, 7.6). It would need a new Discord application, never her two Hermes bots.
- Mos, mos-0992 item 4 and mos-1000 item 5. The B5 caps have numbers, and the residual is stated in
numbers in 5.1.
- Mos, mos-0980 item 4. Section 10 names the Gitea host and lists Jason's steps. C2-13, and anything that
creates an account, contacts Melanie or touches her repo's access list, waits for Jason.
Webby's review (webby-0001, CHANGES, notes at agents/webby/work/case2-r1/REVIEW.md, f9d218ab1). I
re-read every line Webby cites, and each one holds. Where her existing repo changed what an item means,
the line says so.
- B1. The slice now holds C2-09a: `#threaded`, the launcher and `thread.create` read
`THREAD_DEFINITIONS`, and the chat channel picks the agent. `room.*` is C2-09b, outside the slice
(6, 8, 9).
- B2. The slice holds the Console renderer and status line (C2-10b, 7.4). It renders cited items and
reads the new human verb `library.status` (7.2).
- B3. `decision.raise` is refused for creative definitions, and their typed tool set drops
`raise_decision`. An agent asks with a `question` item, which the guard checks (5.1 M6).
- B4. On a library business the conversation reader leaves creative sessions out of `catalogue()` and
refuses to open one by id (5.1 M7, C2-10a in the slice).
- B5. At most 10 items and 1,200 characters of item text per reply. An `idea` item is at most 200
characters, at most 5 per reply. Other kinds are at most 300 (5.1). Q219 later set ideas to at most 3
of 120 characters (ruling after r11), and Q222 made every limit a setting with those defaults.
- B6. `correction.from` must equal one cite's `quote`, inside that cite's lines (5.2).
- B7. The broker keeps fetched page text per session, bounded and in memory, not in a table. The bundle
refuses the `harness-builtin` web source for creative definitions (5.2, 5.3).
- B8. Split by path. The slice reads blobs from a bare mirror and never checks out a working tree, so
symlinks, gitlinks, `.gitattributes` filters and `.gitmodules` can't act. The reader reads only
`100644` blobs up to 1 MiB and reports the rest (rule R1). r1's S0 rule moves to the bus write path
(C2-02), where it guards the working tree. Whether her repo holds any such entry is a C2-13 survey
fact. If it does, C2-02 needs a ruling before it can write.
- B9. Moves to C2-03b with the push path. The slice never writes, so there is no write to refuse.
- B10. One function, `syncCommand`, builds the production argv and env. Tests assert both exactly. The
file-protocol switch is a module parameter that no business file field or bus verb can reach (7.2).
- B11. C2-04 lists every consumer of `root`, and each one skips a library project with its own test
(2.1).
- B12. Path fields are now her keys `appears_in` and `relationships`, plus the business file mapping.
They are indexed like links, and scope comes from the target's path. `library_manuscript` takes
`scope` and labels its result. A hybrid is a business file entry (1.4). Agents can't write the
business file (M9), so `sanctioned` and the hand-edit question go away.
- N1. Blob reads are cited at `read.mjs:19-26`.
- N2. `END_PROTECTED` becomes `THREAD_DEFINITIONS`, so no organization write can end Melanie's agents (4).
- N3. The runner's two failure texts become the fixed notices `no-answer` and `turn-failed`. The guard
accepts exactly three notice bodies (5.2).
- N4. Promotion moves to the write rows (C2-19), as a multi-path commit.
- N5. Her file names can have capitals and spaces, and they aren't mine to rename. Two paths that differ
only in case are reported (R7) and both are left out of lookups.
- N6. A creative role must have a `messaging` key (4).
- N7. Row order is redone for the read-only slice (8).
- N8. The C2-06 gate runs the guard through both send paths and with a non-creative sender (8).
- N9. `room.open` names its refusal `room-thread-name-taken` (6).
## 0. Summary
Melanie's writing already lives in a git repository, her books repo on git.mosaicstack.dev, and her
Obsidian syncs it with obsidian-git. The hub reads that repo. The bus keeps a bare mirror of it, fetched
with the chief of staff's read token, and the business file maps her folders onto books, types, canon,
playground and supporting folders. Agents never write her story. They read it through typed lookup
tools, and they may create, edit and delete `.md` files only in folders she marked as supporting. Each
such write is a commit she sees as a notice and can revert. An agent's reply is free text. Any citation
it carries must be real, and the broker checks each one before it stores the message. Each creative
agent holds its own thread.
Bean and Chet, her two agents from Hermes, keep their names and personalities as Mosaic agents, and the
specialist roles sit beside them. She talks to them in the Console and sees only stored messages that
passed the guard. Hermes keeps running until she has moved over.
Decisions (D-codes are this document's own):
- D1. Her repo stays as it is. The design adapts to her layout through a mapping in the business file,
which agents can't write.
- D2. Scope comes from the path. No file content can change a file's scope.
- D3. Agents never write story. An agent writes only `.md` files under a supporting prefix, and M12
enforces that in code (ruling after r11, Q223, Q226). Canon, playground that isn't also supporting, and
everything else stay read-only to agents.
- D4. One chat is a project. Each creative agent holds its own thread in it. The `threads` table and its
triggers don't change (section 6).
- D5. The bus writes to her repo only as plumbing commits on the bare mirror, pushed fast-forward only:
an agent's supporting doc write, the human's revert and her own Console save (ruling after r11).
- D6. The Gitea remote is where the bus and Obsidian meet. The bus only fast-forwards and never merges.
- D7. Anything fetched from the remote is untrusted input. The bus reads it as blobs, never checks it out
and never runs anything from it.
- D8. Every channel to Melanie shows only messages the broker stored after the guard. A channel that
shows a model's raw output is refused on a library business.
## 1. Data model
### 1.1 Her repo and the mapping
From mos-1000: each book folder has subfolders for characters, chapters, chapter summaries, locations,
research and a tracker. Front matter keys seen are `type`, `name`, `appears_in` and `relationships`. The
repo also has `.base` and `.canvas` files. I haven't seen the exact folder names, and I shouldn't read
her text to find them. The survey command from C2-01 prints names and counts only (10 step 7).
The mapping is a new form of business file project entry. Business files live in
`<configDir>/businesses/<id>.json` (`packages/business/src/business.mjs:1-3`, `:30-32`):
```json
"projects": {
"books": {
"kind": "library",
"remote": "https://git.mosaicstack.dev/<owner>/<repo>.git",
"branch": "main",
"vaultPrefix": "<repo folder name under ~/src>",
"universes": {
"<u>": {"name": "<display name>", "books": ["<book folder>", "<book folder>"],
"playground": ["<book folder>/<playground folder>/"],
"supporting": ["<book folder>/<notes folder>/"]}
},
"editor": {"name": "<her name>", "email": "<her email>"},
"types": {"<characters folder>": "character", "<locations folder>": "place",
"<research folder>": "research", "<tracker folder>": "tracker",
"<chapter summaries folder>": "summary"},
"manuscript": ["<chapters folder>"]
}
}
```
- Every value in `<>` comes from the survey and Melanie's say, through Jason (10 step 7).
- `universes` groups her book folders. If she has one world, it is one universe holding every book
folder. Jason's `universe.book.{character,place,...}` then reads as universe, book folder, type folder.
- `playground` is a list of path prefixes inside each universe entry (B1). A prefix inside one of that
universe's book folders is book level. A prefix outside every book folder is universe level. A
`"kind": "draft"` entry has prefixes and no books, and is where a new universe starts. No prefix may
hold another, or hold a book folder. A prefix inside, or holding, another universe's book folder
refuses the business file with `library-playground-other-universe`, so every playground file has one
universe, and the one-universe
cite check (5.2) has something to compare (ruling after r11, three levels). Her repo has no playground
today as far as I know. The setup sheet suggests one folder per book, for example
`<book>/playground/`. Until she makes one, everything mapped is canon.
- `supporting` is a second list of path prefixes in each universe entry (ruling after r11, Q223). An
agent may write `.md` files under a supporting prefix and nowhere else (M12). The prefix rules are the
playground's, and the refusal for another universe is `library-supporting-other-universe`. A prefix
may sit in both lists, and that folder is writable. A playground prefix alone isn't. Her repo has no
supporting folder until she names one, and until then agents write nothing.
- `editor` is the name and email her Console saves commit under (F41). The setup sheet asks her for it.
- `types` maps a type folder name to a type. A file's front matter `type`, if present, is shown next to
it but doesn't change scope.
Agents can't change this mapping. No organization verb writes `projects` today: `organization.mjs` has no
write of it, and `instance-settings.mjs:141` only reads `proposed.projects`. An organization write sends
a whole proposed document, though, so C2-04 adds a refusal, `organization-projects-protected`, for any
organization write whose `projects` differ from the file's (M9). Every setter builds its document from
the file and changes only its own keys (`broker.mjs:1261-1271`), so the rule holds today. It goes in
`organizationWriter.write` (`organization.mjs:177`), the one place every setter's write passes
(`instance-settings.mjs:154`, `launch-settings.mjs:207`, `:277`, `plugin-settings.mjs:60`). It compares all
of `projects` in canonical JSON, hybrid entries included, and its test calls `write` directly (N3). The
mapping changes only by a hand edit of the file.
### 1.2 Scope
Each path in her repo, relative to the repo root, gets one scope:
| Scope | Rule |
|---|---|
| `supporting` | A `.md` file that starts with a supporting prefix of any universe entry, drafts included. This comes first, so a folder in both lists is supporting. Agents may write it (M12) |
| `playground` | Otherwise, a `.md` file that starts with a playground prefix of any universe entry, drafts included |
| `canon` | Otherwise, a `.md` file inside a configured book folder |
| `unmapped` | Everything else: top-level notes, `.base`, `.canvas` (under a playground or supporting prefix too), attachments, anything outside a book folder and outside every playground and supporting prefix |
Lookups default to canon and return playground or supporting files only on request. Unmapped files are
never returned or citable. The universe of a canon file is the universe whose `books` list holds its book
folder. The universe of a playground or supporting file is the entry whose prefix holds it, and its book
is the book folder that holds the prefix, or `null` at universe level and in a draft (ruling after r11,
three levels).
### 1.3 Reading and reporting
The reader works on a commit, not a working tree. It lists the tree with `git ls-tree -r -z` and reads
blobs with `cat-file`, as `blob()` does in `packages/project/src/read.mjs:19-26`. That function accepts
`100644` or `100755` (`:21`). The library reader is stricter.
None of these rules holds back her latest commit. Each produces a problem in `library.status`, with path
and line where there is one, and the index leaves the affected file or link out.
| Code | Rule | Effect |
|---|---|---|
| R1 | Only `100644` blobs up to 1 MiB are read. Symlinks, gitlinks, executables and larger blobs aren't | not indexed |
| R2 | A path is unmapped (1.2) | not indexed |
| R3 | Front matter the reader can't parse fully. It reads scalars, quoted strings, flow lists and block lists of scalars or wikilinks. Anything else, nested maps for example, stays raw text | indexed by path, file name and body |
| R4 | A link or path field that resolves to no file, or to more than one | link not followed |
| R5 | A canon file refers into the playground | reported only. Lookups and the guard still go by the target's path |
| R6 | A canon file refers into another universe | reported only. Lookups never cross universes (3) |
| R7 | Two paths differ only in case | both not indexed |
Links are `[[target]]`, `[[target|label]]` and `[[target#heading]]`. The resolver tries, in order, a
path from the repo root, the same path with `vaultPrefix/` stripped, a path relative to the linking file,
and Obsidian's shortest-path form, a base name that matches exactly one indexed file. A link whose target
is in another of her repos under `~/src` resolves to nothing (R4). `appears_in` and `relationships`
values are read as links when they are wikilinks, and as names otherwise. A `![[target]]` embed of a
note resolves like a link. A link or embed whose target ends in an extension other than `.md` refers to
an attachment: it isn't followed and raises no problem (ruling after r11).
One known difference (N4). Obsidian resolves a shortest-path link across her whole vault, `~/src`, and
the reader sees only the books repo. A base name that is unique in the repo but also exists in another
of her repos can resolve to different files in Obsidian and in the hub. The reader can't detect that,
because it never sees the other repos. A link written as a full path resolves the same in both, and the
setup sheet says so (C2-12).
Why reporting is enough for separation. The guarantee Jason asked for is that agents keep playground and
canon apart. The code that does that is the scope rule, the lookups' default and the cite check in 5.2,
which refuses a playground or supporting cite marked as canon. None of those needs her files to
pass a rule. Refusing her latest commit would only stop the agents seeing her newest work.
### 1.4 Hybrid crossover (after the slice)
A hybrid is a business file universe entry with `kind: "hybrid"`, `sources` (two or more non-hybrid
universes), `books` (its own folders), `constraints` and `notes`. Agents can't change `projects` (M9), so
a hybrid exists only by a human edit of the business file. In a hybrid's scope, lookups return its own
files plus the sources' canon, each labelled with its source universe. Constraint kinds checked in code:
- `{"exclude": "<source path or source/type>"}`. Lookups in the hybrid never return it.
- `{"pin": {"entity": path, "field": name, "value": v}}`. Reported (R-class) when a hybrid file changes it.
Plain-word constraints go in `notes`. The continuity role reads them and cites them in its findings.
C2-15 builds this.
### 1.5 Promotion (after the slice)
Promotion moves files from a playground prefix into a book folder. Only Melanie does it, in Obsidian
today. A Console verb `library.promote {from, to}` comes later (C2-19). It is human only and moves every
file in one plumbing commit, by the steps of the ruling after r11 with several paths, and pushes.
## 2. What exists and how Case 2 uses it
### 2.1 The business file and project record repos
- The business file accepts only `root` and `repo` per project (`packages/business/src/business.mjs:186`)
and requires `root` (`:187`). C2-04 adds the `kind: "library"` entry from 1.1, which has no `root`.
Several places assume every project has a root. Each must skip a library project, with its own test
(B11):
- `ensureCheckouts` clones every entry's root (`packages/seat/src/checkout.mjs:310`, `:319`). The
launcher passes it every project (`packages/cli/src/launcher.mjs:353`).
- `projectFilePath(entry.root)` (`packages/business/src/project.mjs:9-10`) is called at
`packages/business/src/cli.mjs:72`, `:78`, `:118`, `packages/business/src/resolve.mjs:44` and
`packages/business/src/instance-settings.mjs:142`.
- `projectRepo`'s overlap loop calls `absolute(project.root)` (`packages/project/src/paths.mjs:41-44`).
With those skips, agents get no clone of her repo. C2-04's launcher test proves it.
- Mirror location: `projectRepo` (`paths.mjs:34-48`) puts a project repo in
`<dataRoot>/projects/<business>/<project>` and refuses symlinked ancestors. The bare mirror uses the
same path rule.
- Git: `git()` passes only `gitEnv()`, the ceiling directory and the identity (`git.mjs:10-12`). `SAFE`
sets `protocol.allow=never` (`git.mjs:8`), and `inspectGit` refuses any `remote.*` key (`git.mjs:44`).
The mirror keeps both rules. The URL goes on the command line from the business file, and fetch uses
its own command builder (7.2).
- Bus writes (C2-02) don't use `commitFile` (`index.mjs:195-234`), which commits in a working tree. The
mirror has none (D7). C2-02 writes plumbing commits on the bare mirror by the steps of the ruling after
r11, under the library lock and after M12. `commitFile` and `writeHumanFile` stay as they are.
### 2.2 ProseMirror editor (`packages/webui/editor`)
- It edits Markdown (`model.mjs:17-18`). Blocks it can't draw, front matter included, are kept byte for
byte (`model.mjs:3-11`, `:220-231`).
- The Console opens the visual editor only if `roundTrip(text) === text`, otherwise Source
(`packages/webui/src/public/bus.js:919-920`). The commonmark rules (`model.mjs:119-120`) have no
wikilink rule. I didn't measure whether the serializer escapes `[[`. C2-11a's first test does.
- Load and save: `GET/POST /api/project-file` (`bus.js:907`, `:974`), then
`packages/webui/src/serve.mjs:545-547` and `:591-593`, then `project.file` and `project.file.save`
(`packages/webui/src/cli.mjs:75-76`), then `packages/bus/src/broker.mjs:1434-1458`. The allowlist is in
`documentPath` (`paths.mjs:14-15`) and `DOC_PATH` (`bus.js:885`). Case 2 adds library verbs next to
these and leaves `documentPath` as it is.
### 2.3 Roles (`roles/*.json`, `packages/business/src/role.mjs`)
- `validateRoleDocument` (`role.mjs:138`) checks a role file. Its keys are closed (`:19-20`), `tools`
must be non-empty and drawn from `TOOLS` (`:23-28`, `packages/business/src/vocabulary.mjs:92-97`), and
`messaging` limits who it talks to (`:55-66`). A role with no `messaging` key may message anyone
(`:50-51`).
- Tools reach the harness through `packages/business/src/resolve.mjs:60-64`, then
`packages/harness/src/bundle.mjs:247-256` (`policy.json`), then `gate.mjs:130-138`, which refuses any
built-in not in the policy.
- The harness's own web tools are on only when the organization's search source is `harness-builtin`
and the role grants `web` (`bundle.mjs:28-35`, `gate.mjs:28-33`). Those fetches never reach the broker
(5.2).
### 2.4 Tool gate and typed tools (`packages/harness/src`)
- `decide()` (`gate.mjs:130-167`) allows a typed tool by name (`:134`), allows a built-in only if the
policy holds it (`:137-138`), and keeps file paths inside the workspace (`:146-156`). The Claude Code
hook turns a refusal into exit 2 (`claude-gate.mjs:13`).
- `typedTools` (`tools.mjs:91-261`) builds the typed tools per session from held actions. `write_record`
exists only for `record.write` holders (`:211-220`), and `send_message` for `message.send` holders
(`:95-102`). `raise_decision` goes to every agent (`:221`), with free-text `question` and option `text`.
- Case 2 adds the lookup tools (section 3) when the business has a library project, adds the two write
tools for a creative session holding `library.write`, and drops `raise_decision` and the other
non-creative tools for creative definitions (5.1 M3, M6).
### 2.5 Threads, messages, conversations and Discord
- `thread.create` picks the single instance whose definition is `assistant` and refuses more than one
(`broker.mjs:2331-2334`). It stores that instance on the thread (`:2336-2347`). The same rule is in
`#threaded` (`:290-291`), `vocabulary.mjs:131`, and a second copy in `launcher.mjs:135` and `:236-238`.
This is code, not schema.
- `thread.send` stores one message to `t.instance` and routes it (`broker.mjs:2374-2399`). Triggers
require every threaded message to be to or from the thread's instance
(`packages/bus/schema.sql:296-299`) and deliver only to the thread's holder (`:300-306`). `threads`
can't be updated (`:310`). Thread names are unique per project (`:249`).
- The runner sends the model's final text as a `message.send` RESULT to the sender
(`packages/harness/src/runner.mjs:571-574` at aca16404c). It sends `(the session gave no answer)` for
an empty turn and `The turn failed: <reason>` for a failed one (`:572`). A refused send is logged and
`message.read` is skipped (`:577-583`), so the message stays unread and the answer never reaches her
(webby-0151 N2).
- Sessions are per thread: `threadStore(dataRoot, business, thread)` (`packages/seat/src/session.mjs:59-64`)
and the Claude session id file under it (`adapters/claude/adapter.sh:47-53`). One thread per agent
means one session per agent.
- The Console's conversation view proxies `/api/conversations` and `/api/conversation` to the control
board (`packages/webui/src/serve.mjs:559-569`), which answers from `reader.catalogue()` and
`reader.open()` (`packages/control-board/src/serve.mjs:210-219`). The reader lists session files
(`packages/conversation/src/reader.mjs:225-240`) and emits the model's raw text blocks
(`packages/conversation/src/events.mjs:58`, `:82`). A refused answer stays in the session file, so this
view would show it (5.1 M7).
- The Discord connector (`packages/discord`) binds one seat's conversation to listed channels. It builds
its own engine in `packages/discord/src/cli.mjs:210-212` from `engine-pi.mjs`. Each turn calls
`engine.prompt` (`connector.mjs:381`) and posts the result through `deliver` (`:386-387`,
`rest.createMessage` at `:106`). The broker never sees that text. The README names the engine as the
module to swap (`packages/discord/README.md:13-14`). Authorization is the binding's `users` list
(`connector.mjs:84`, `:443`).
## 3. Agent lookup tools
Broker verbs with typed tools. Every agent session in a business with a library project gets them. Each
reads the mirror at its current served commit (7.2) and returns that commit.
| Tool | Arguments | Returns |
|---|---|---|
| `library_find` | `universe` (required), `query`, `type?`, `book?`, `scope?` (`canon` default, `playground` or `supporting`) | up to 50 `{path, type, name, appears_in, scope, universe, book}` |
| `library_get` | `path` | `{path, commit, blob, scope, frontmatter, lines: [[n, text]], links_out, links_in}` |
| `library_search` | `universe`, `text`, `book?`, `scope?` | up to 50 `{path, line, excerpt, scope}` |
| `library_manuscript` | `universe`, `book`, `scope?` (`canon` default), `chapter?`, `from?`, `to?` | `{path, commit, scope, lines}`, one chapter file, at most 400 lines per call |
| `library_universe` | `universe` | its books, type folders, and for a hybrid the sources, constraints and notes |
- Universe isolation is in code. `universe` is required, and results never leave it, except for a
hybrid's declared sources minus exclusions.
- Every result carries `scope` from the path (D2). Playground is opt-in. Unmapped files never appear.
- `library_get`'s `links_out` and `links_in` drop targets in another universe and unmapped targets, as
the other tools do (N1). A hybrid keeps links into its declared sources.
- Replies stay under `REPLY_CAP` (`tools.mjs:271-280`) by paging. They are never cut.
- The index (types, names, links and backlinks) is built per commit and cached in memory by commit id.
Her repo has 203 Markdown files today. I estimate a rebuild well under a second, and C2-05a's gate
measures it on a 3,000-file fixture.
Writes. `library_doc_write {universe, path, base, content}` and `library_doc_delete {universe, path,
base}` call the agent verbs of the ruling after r11. Only a creative session holding `library.write` gets
them (M3). `base` is the `blob` that `library_get` returned, or `null` to create. Each returns `{commit,
path}` or a refusal code, and a successful write also stores the notice.
## 4. Creative roles, Bean and Chet
Six new role definitions. Each is a version 2 role file plus a `.md` contract.
| Role | Purpose | `tools` |
|---|---|---|
| `companion` | Her general writing partner. Backs Bean and Chet | `read`, `web` |
| `ideas` | Brainstorming, mostly in the playground | `read` |
| `research` | Real-world facts from the web | `read`, `web` |
| `proofreader` | Spelling, grammar, punctuation | `read` |
| `fact-checker` | Text against canon and her research notes | `read` |
| `continuity` | Consistency across books, timeline and her tracker | `read` |
All six have `messaging: {sendTo: ["human"], receiveFrom: ["human"]}`, so they can't relay through each
other. `network` is `none`, except `api-only` for `companion` and `research`. C2-08 landed them with
`authority: {withinRole: ["message.send"], crossRole: []}` and `credentials: []`. C2-08b changes both,
which is a role change Mos authorized in mos-1309 item 5: `withinRole` becomes `["message.send",
"library.write"]`, and `credentials` becomes `[{"service": "gitea"}]`, the definition credential the
grant path needs (`business.mjs:132-167`). Each creative instance also gets the `identity` and `publish`
fields that path reads. `roles/chief-of-staff.json` gets the same credential and no `library.write`, so
the chief can hold the read grant the mirror fetches with.
Bean and Chet. Jason's ruling (Q180): "She will want to keep those agent names and personalities." They
are two business role instances, `bean` and `chet`, both with definition `companion`. The personas are
Melanie's (mos-1033), so only the human changes one, and no organization read or log holds the text.
Where the persona lives (B2). Instance entries take inline `vars` only
(`packages/business/src/business.mjs:89`). Inline vars go wherever the business file goes. The
`instances` read returns every instance's vars whole (`packages/business/src/instance-settings.mjs:58-63`),
and the tester may call it (`TESTER_ORGANIZATION_VERBS`, `broker.mjs:103`). Every organization write logs
old and new values to `businesses/.history/<id>/changes.jsonl` (`organization.mjs:70-80`, `:204`). So the
text stays out of the business file. C2-08 declares two agent-layer keys in `REGISTRY`
(`packages/business/src/vars.mjs:102`):
- `persona.name`, a string of at most 40 characters, the name she sees ("Bean", "Chet").
- `persona.sha256`, 64 lowercase hex characters, the digest of the instance's persona file.
The persona file is `<configDir>/personas/<business>/<instance>.md`, outside `businesses/`. It is UTF-8,
at most 8,000 characters, mode 0600, in 0700 directories owned by `<user>`. The bundle reads it at launch
with `O_NOFOLLOW`. It refuses a file that isn't regular, isn't owned by the bus's uid or has any group or
other bit, the same check `organization.mjs:141` makes on `changes.jsonl`. It refuses `persona-mismatch`
when the file's sha256 isn't the var's. So the `instances` read, the Console's view of it and
`changes.jsonl` show `persona.sha256` and never the text. No verb returns the file, and agent file tools
stay inside the workspace (`packages/harness/src/gate.mjs:146-156`). A persona var or file is refused on
a non-creative definition.
Where else the text sits (N3). The persona's own session gets it in its prompt, which is the point, and
two copies come with that:
- `prompt.md` in each run's bundle, mode 0600 in a 0700 directory (`bundle.mjs:241-244`, `:265`), kept
until the run is pruned. Only `<user>` can read it. The C2-13 precheck checks the run root's mode.
- The harness's argv. Both adapters read `prompt.md` into a variable and pass the text as
`--system-prompt` (`adapters/claude/adapter.sh:79`, `:82`, `adapters/pi/adapter.sh:106`, `:121`). Any
local user on the host can read that in `/proc/<pid>/cmdline`. C2-08 makes both adapters pass the
file instead. Pi 0.85.1, the pinned version (`package.json:8`), reads `--system-prompt` as a file when
the value is an existing path (`dist/core/resource-loader.js:17-30`). It uses the path string itself
as the prompt when the file doesn't exist (`:21`, `:30`, no warning) or can't be read (`:24-27`). So
the adapter checks `[ -r ]` on the file first, which covers both, and refuses otherwise. Claude Code 2.1.304's `--help` names `--system-prompt-file`. I read the help
but didn't run it, and the repo doesn't pin the claude version. C2-08's adapter tests check that argv
holds the path and not a marker from the prompt. When some creative instance's harness is claude-code,
C2-13 runs one marker prompt through the host's claude with `--system-prompt-file` and checks that it
replaces the default prompt rather than adding to it. With the r9 model path, Bean and Chet run pi. If the
host's claude lacks the flag, the argv copy is a residual for Jason, stated with that version.
Modes, from code. The writer creates the business file's candidate and `changes.jsonl` with mode 0600
(`organization.mjs:61`, `:139`) and its `.history` directories with 0700 (`:39`). It refuses a log with any
group or other bit (`:141`). C2-08 tests those modes after an `instances.set` write, and the C2-13
precheck checks the business file, `.history` and the persona files on `<host>`.
Who can change a persona or a creative instance (B3, and r3's B1). The rule is one check, used on every
path below: a write that touches a `CREATIVE_DEFINITIONS` instance is accepted only when its actor is
the business file's `human`. The broker sets the actor to the human's id for a human session, to
`tester` for the tester (`broker.mjs:1272`), and to `<definition>.<instance>` for a role session such as
the chief of staff (`:1278`). Otherwise it refuses `organization-creative-human-only`. The five paths
today, and what C2-08 does to each:
`limits.set` is the human's verb and `organization.set` is the same write for a role (`broker.mjs:1248-1251`).
Both take `ORGANIZATION_KEYS` (`:145`) and reach the same `set()` (`:1284`, `launch-settings.mjs:150-169`),
so the first three paths below are reached by both verbs, and the check keys on the actor, never the verb.
- The `assistants` key, which the chief of staff holds (`broker.mjs:145`, `:1266-1271`),
targets only definition `assistant` (`packages/business/src/launch-settings.mjs:140`, `:186`, `:191`).
Those three lines stay `THREAD_DEFINITION`, not `THREAD_DEFINITIONS`. Test: `assistants: {bean: ...}`
from the chief of staff refuses `organization-not-assistant`, and the file is unchanged.
- The `instances` key sets the `model` var of any instance (`launch-settings.mjs:170-177`).
It now refuses a creative instance with `organization-creative-instance`, for every actor. Test: the
chief of staff's `organization.set` and the human's `limits.set` with `instances: {bean: ...}` both
refuse. She changes Bean's model with `instances.set` or `routes.set`.
- The `models` key replaces `launch.models` whole (`launch-settings.mjs:169`), which can
change the model behind a key Bean or Chet names without touching the instance. A write that changes
or removes a `launch.models` entry named by a creative instance's `model` or `fallback.model` now
refuses `organization-creative-human-only` unless the actor is the human (N1, mos-1051 item 2). Test:
the chief of staff's `organization.set` change to Bean's key refuses with the file unchanged, its change
to an unnamed key works, and the human's `limits.set` change to Bean's key works. The last case shows
the check keys on the actor, not the verb. Mutant: dropping the check makes the first case fail.
- `routes.set` replaces an instance's harness, model and thinking vars (`launch-settings.mjs:240-277`).
It is human only, but the tester may call it too (`broker.mjs:103`, `:1258`). `setRoutes` checks no
definition before its write at `:277`. It now refuses a creative instance unless the human calls it.
Test: the tester's `routes.set` on `bean` refuses with the file unchanged, and the human's works.
- `instances.set` is human only, and the tester may call it too (`broker.mjs:103`, `:1263-1265`). Today it
refuses any `END_PROTECTED` definition (`instance-settings.mjs:23`, `:109`), which would make every
creative add or change a hand edit. C2-08 adds one branch. An add or change of a
`CREATIVE_DEFINITIONS` instance is accepted from the human and refused from the tester with
`organization-creative-human-only`. That is how her agents' models change, how a new persona digest is
set after the file is replaced, and how a specialist is added later (10 step 7). Tests: the human adds
`ideas` and changes `bean`'s model, the tester's add and change refuse, and `instances.set` still
refuses an `assistant` instance.
- The tester is off on her business. `bindTester` refuses unless `bus.tester` is exactly true
(`broker.mjs:542-545`), and the C2-13 precheck refuses a library business with it on. If it were on,
the tester would see digests only, and each of the paths above would refuse it.
C2-08 runs every tester case through the broker with a bound tester session, and every chief of staff
case with a role session, so a change to how the broker sets the actor (`:1272`, `:1278`) fails a test.
The human id can't be `tester` (`broker.mjs:245`), and no instance may be named `tester`
(`packages/business/src/instance-settings.mjs:86`).
The persona block (B4). `prompt()` puts the contract first (`packages/harness/src/bundle.mjs:177`), then
"## This session" (`:179`) and the session lines. The persona block goes between the two:
```
<contract>
## Persona
Your name here is <persona.name>. The persona below sets your voice and manner. The role contract above
wins on any conflict, and the broker checks every reply whatever the persona says.
<persona-file instance="bean" sha256="<64 hex>">
<persona text>
</persona-file>
## This session
```
It is fenced the way skills are (`SKILL_END`, `bundle.mjs:107`, `:170`). The bundle refuses
`persona-invalid` for text that holds `</persona-file>` or has a line starting `# ` or `## `, so the text
can't close its fence or open a section of its own. C2-08's snapshot pins contract, persona, session, in
that order. The order is a hint to the model. The enforcement is in code, M12 on writes and the
citation guard in 5.2, and neither reads the persona, so no persona text can lift a guard.
What changes for her. In Hermes, Bean and Chet answer in free text and may write story text into her
files when she asks. In Mosaic they still answer in free text, of any length, and they never write into
her story files. Whatever they write lands in a supporting folder she marked, as a commit she sees and
can revert. That follows from Jason's "NEVER" as Q223 reads it. She should hear it before the move, not
discover it (10 step 7).
The five specialists stay available as further instances. Whether the slice starts with Bean and Chet
only or with all seven is her choice through Jason (10 step 7). Each running agent session measured 300
to 390 MB on dragon-lin (mos-1000), and a thread's session stops after 900 idle seconds
(`THREAD_IDLE_SECONDS`, `business.mjs:20`), so memory depends on how many she talks to at once.
`read` is there because `role.mjs:24` requires a non-empty tool list. The workspace has no clone of her
repo (2.1), so `read` reaches nothing of hers. All library reads go through the lookup tools.
New constants in `vocabulary.mjs`, each changed only by a reviewed commit:
- `THREAD_DEFINITIONS = ["assistant", "companion", "ideas", "research", "proofreader", "fact-checker", "continuity"]`.
This replaces the single `THREAD_DEFINITION` wherever it means "may hold a thread". The definition the
chief of staff may set stays `assistant` (`tools.mjs:186`), and so do the three `assistants` lines in
`launch-settings.mjs` (B3 above).
- `END_PROTECTED` (`vocabulary.mjs:135`) becomes `THREAD_DEFINITIONS` (N2). An instance of one is then
ended only by the chief of staff or a decision the human resolved (`broker.mjs:148-150`, Q169), not by
another agent. r2 said only the human, which was wrong. Ending a run changes no persona, and a thread's
session stops after 900 idle seconds anyway. This matches TARGET.md:66 ("hire/fire authority for agents
as needed; unless user forbids per agent"). If Melanie wants it forbidden for an agent, the request
comes through Jason (mos-1038).
- `CREATIVE_DEFINITIONS`, the six above.
- `CREATIVE_TOOLS = ["read", "grep", "find", "ls", "web"]`. `validateRoleDocument` refuses a creative
definition whose `tools` include anything else, whose authority holds anything but `message.send` and,
after C2-08b, `library.write`, that has no `messaging` key, or whose `messaging` names anything but
`human` (N6).
- C2-08 also landed `CREATIVE_KINDS`. C2-08b removes it, since replies have no item kinds now.
## 5. Guardrails enforced in code
### 5.1 Agents never write story
| Mechanism | What it refuses | Test |
|---|---|---|
| M1 role validator (`role.mjs`, `CREATIVE_TOOLS`) | A creative role file with `write`, `edit` or `bash`, any authority but `message.send` and `library.write`, or a missing or wider `messaging` | C2-08: each rejected variant refuses with its message |
| M2 tool gate (`gate.mjs:137-138`, unchanged) | `Write`, `Edit` or `Bash` from a creative session | C2-08: `decide()` with each creative policy refuses all three, on pi and claude-code |
| M3 typed tool set (`packages/harness/src/tools.mjs`) | The tool list is exact: the five lookups, `send_message`, `library_doc_write` and `library_doc_delete`, plus `web_search` and `web_fetch` for `companion` and `research` (mos-1308, ruling after r11). No `raise_decision`, `read_spec`, `list_agents`, `inbox`, `trail`, `write_record` or tracker read | C2-05b: a snapshot of tool names per creative role (row 224, six or eight). C2-02 adds the write tools to it: eight names, or ten for `companion` and `research` |
| M4 human verbs and agent verbs | `library.file`, `library.file.save`, `library.sync`, `library.status`, `library.limits.set` and `library.revert` from an agent session refuse `human-required`, as at `broker.mjs:1434-1435`. `library.doc.write` and `library.doc.delete` refuse every caller but a creative session holding `library.write` and a live grant | C2-04, C2-03a, C2-06 and C2-02: each human verb from an agent session refuses, and each agent verb from the human, the tester, the chief of staff and a creative session without a grant refuses |
| M5 citation guard (5.2) | A cite that fails a check in 5.2, whenever a reply carries one. More than `citationsPerReply` citations. A reply with none when `requireCitation` is true. Both keys are `replyLimits` settings read at check time (Q222, Q226), and a missing or invalid one uses its default. An unreadable business file resolves `requireCitation` to true | C2-06: each check refused once and passed once. `citationsPerReply` refused one past the limit and accepted at it. `requireCitation` true refuses an uncited reply and false accepts it. A changed value takes effect on the next reply. Each invalid value uses the default, `"true"`, `1`, `null`, `[true]`, `{}`, `0`, `1001`, `2.5`, `true` for the count and `"5"` included. With the business file unreadable, a reply with 21 citations refuses, an uncited reply refuses `citation-missing`, and `messages` is unchanged in both |
| M6 no free-text decision channel | `decision.raise` from a creative definition refuses `decision-not-allowed` in the broker. The typed tool is also absent (M3) | C2-08: a creative session's raise refuses and no decision row appears. An assistant's raise still works |
| M7 no raw session view | On a business with a library project, the conversation reader leaves creative sessions out of `catalogue()`, and `open()` refuses them with `conversation-not-listed` | C2-10a: a creative session file under the root isn't listed and won't open by id. An assistant session still lists |
| M8 no raw Discord engine | On a business with a library project, `discord check` and `discord run` refuse a binding whose engine isn't `mosaic-bus` (`discord-engine-not-allowed`), and a `mosaic-bus` binding with `tools` | C2-10a: both refuse on a library business. A pi binding on a business without a library still checks clean |
| M9 no agent write to the mapping | An organization write whose `projects` differ from the file's refuses `organization-projects-protected`, in `organizationWriter.write` | C2-04: `write` called directly with a changed playground prefix, and again with a changed hybrid entry, refuses, and the file and `changes.jsonl` are unchanged |
| M10 personas and creative instances are the human's | Any write by an actor other than the business file's `human` that touches a creative instance: the chief of staff's `assistants`, `instances` and `models` keys through `organization.set`, and the tester's `instances.set` and `routes.set`. A persona file that fails its digest, mode or fence check. A harness argv that holds the prompt text | C2-08: each path refuses with its code (4) and the file is unchanged. The human's `instances.set` and `routes.set` on `bean` work, and so does her `limits.set` change to Bean's `launch.models` key. Tester cases through a bound tester session. A mutant without the `models` check fails. No `instances` read, `changes.jsonl` line or adapter argv holds a marker string from a fixture persona |
| M11 reply settings are the human's | `library.limits.set` from an agent session (`human-required`) or the tester (`tester-refused`). Any organization write whose `replyLimits` differs from the on-disk file's refuses `organization-reply-limits-human-only` in `organizationWriter.write`, on `document` and on `written`, unless the actor is the on-disk `human` and not an agent actor shape | C2-06: the human's `library.limits.set` works. The chief of staff's `organization.set`, the tester's `instances.set` and a direct `write` with a changed `replyLimits` refuse. A direct `write` that changes both `human`, to its own actor, and `replyLimits` refuses. With a dotted human id equal to a role's actor, that role's write refuses. `library.limits.set` with an unknown key refuses `invalid-request`. In each refusal the file and `changes.jsonl` are unchanged. A mutant without the writer check fails, and so does one that compares `document.human` |
| M12 agents write supporting docs only | `supportingPath` refuses `library-write-story-path` on `library.doc.write` and `library.doc.delete` for every path class the ruling after r11 lists, before any git call. A write commits only by the plumbing steps, pushes fast-forward only and carries the `Mosaic-Agent` trailer | C2-01d: one refusal per path class against a fixture tree with a symlink, a gitlink and a case variant. C2-02: each refusal through the broker leaves the remote ref, the served ref and the mirror's refs unchanged. One write and one delete land, each with one parent, the seat's identity and the trailer. A moved tip with an unchanged blob re-applies and a changed blob refuses `stale-base`. A mutant without the supporting check fails |
Residual. The ruling after r11 states it. A supporting doc can hold any text, and the seat token's reach
at Gitea is wider than M12. Chat has no length or kind limit now (Q223), so the 360-character residual of
Q219 is gone with the caps. What lands in her story files stays hers alone. M2, M4 and M12 make an agent
write there impossible through the hub.
### 5.2 Agents always refer to the source material
The citation guard runs in the broker. It covers every message from an agent session to `human` in a
business with a library project, from the runner's RESULT and from `send_message` alike. It doesn't look
at the sender's definition, so a misconfigured role can't skip it. Citation checks need git, so they run
before the insert, outside the SQL transaction, as `#requestProject` does (`broker.mjs:1378-1430`).
Every channel to Melanie shows stored messages only (D8), so this one check covers the Console, Discord
and any later channel.
The reply body is free text, or a JSON object when the reply carries citations:
```json
{"text": "Mara's eyes are grey in chapter 3 but green on her character page.",
"citations": [{"path": "<book>/<chapters>/03.md", "commit": "<40 hex>", "lines": [41, 41], "quote": "her grey eyes", "as": "canon"},
{"path": "<book>/<characters>/Mara.md", "commit": "<40 hex>", "lines": [9, 9], "quote": "green eyes", "as": "canon"}]}
```
A body that parses as a JSON object must fit this schema: `text` a string, `citations` a list of library
cites `{path, commit, lines, quote, as?}` or web cites `{url, quote}`, and no other keys. Any other body
is a reply whose text is the whole body and which carries no citation. `as` is optional and its one value
is `"canon"`, which says the agent states the cited line as fact about her world.
| Check | Refusal code |
|---|---|
| A JSON object body fits the schema | `reply-invalid` |
| `requireCitation` is true and the reply carries no citation | `citation-missing` |
| The reply carries at most `citationsPerReply` citations | `citations-over-limit` |
| The path is canon, playground or supporting (not unmapped) and indexed, it exists at `commit`, `commit` is reachable from the served commit, the lines are in range, and `quote` occurs in those lines after whitespace normalization | `citation-invalid` |
| A cite with `"as": "canon"` names a canon path | `playground-cited-as-canon` |
| All library cites in a reply are in one universe, or a hybrid plus its sources | `citation-cross-universe` |
| A `url` cite is allowed only for `research` and `companion`, only for a URL this session fetched through the broker, and its quote must occur in the kept text of that fetch | `citation-unfetched` |
The guard doesn't read what the text says, only its citations (Q223). The check result stored with the
message names each library cite's scope and whether the file's last commit at `commit` carries a
`Mosaic-Agent` trailer, so the Console can label it agent-written (7.4).
Fetched text (B7). The web trail keeps the URL (`broker.mjs:1521`) and a summary of status, bytes and
redirects (`packages/bus/src/web.mjs:46`), not the page. Case 2 adds a page store in the broker's memory,
keyed by session: the text of the last 20 broker fetches, each capped at 200 KB, dropped when the session
ends. It doesn't survive a bus restart. A cite made before a restart then fails `citation-unfetched`, the
runner retries, and the agent fetches again. Fetches through the harness's own web tools never reach the
broker, so `webPlan` (`bundle.mjs:28-35`) refuses the `harness-builtin` source for a creative definition
(`web-source-not-allowed`).
Runner texts. The runner's two fixed texts at `runner.mjs:572` (aca16404c) are plain text, which the
guard accepts while `requireCitation` is false. In a library business the runner sends `(the session
gave no answer)` and `(the turn failed)`, with the reason only in the run log, and the guard accepts
exactly those two bodies, and the two retry texts below, when `requireCitation` is true. An agent that
sends one of them gains nothing it couldn't say anyway.
Runner retry (C2-06, which takes in C2-07). When `message.send` refuses with a code from the table, the
runner starts one new turn in the same session with the code and reason. If that reply also refuses, it
sends `(no answer passed the citation check)`. Today a refused send is logged and `message.read` is
skipped, so she gets no answer (`runner.mjs:577-583` at aca16404c). A send that refuses
`request-too-large` at the client (`packages/bus/src/client.mjs:19-20`), before the broker, gets the same
one retry, and the new turn names the 64 KiB limit. If that reply is still too long, the runner sends
`(the answer was too long to send)`. If it refuses with a code from the table, the runner sends the
citation text. Only one retry runs per answer (webby-0151 R1).
The unslop note and the clip. The runner appends `unslop.note()` to every RESULT body, and `clip` cuts a
body over 32,000 characters (`runner.mjs:130-131`, `:572-573` at aca16404c). Either one turns a JSON body
into plain text and drops its citations, and either one breaks the fixed texts. In a library business
C2-06 changes both. A body that parses as a JSON object gets the note added to the end of its `text` and
is serialized again, and is never clipped. A body too long for the 64 KiB request limit gets the retry
above. A fixed text goes out with no note, and the note waits for the next
reply, as it does after a failed send. A plain body keeps today's clip and note.
Tests (C2-06): one passing and one refused case per row of the table, through both the runner's RESULT
path and `send_message`, and once with a non-creative sender in a library business (N8). Plain text
passes with `requireCitation` false and refuses with it true. The fixture is a repo the test builds in
her layout's shape, with spaces and capitals in folder names, a `.canvas` file, a supporting folder and
an unmapped top-level note. Each refusal must leave the `messages` count unchanged. A runner test with a
fake harness that answers with a bad cite and then a valid reply stores one message. One that refuses
twice stores the retry's fixed text. With `requireCitation` true, an empty and a failed turn store their
fixed texts. With a flagged unslop event, a cited reply is stored with its citations whole and the note
at the end of `text`, a fixed text is stored with no note, and the next reply carries it. A cited reply
over 32,000 characters isn't cut. A cited reply over 64 KiB, then a valid one, stores the second. Two
replies over 64 KiB store `(the answer was too long to send)`, and with `requireCitation` true the guard
accepts it. With the business file unreadable, an uncited reply refuses `citation-missing` and
`messages` is unchanged.
### 5.3 Tool use is guarded
- Built-ins: M1 and M2. The gate is unchanged. The test proves creative policies hit it.
- Harness web tools: `webPlan` refuses `harness-builtin` for creative definitions. C2-08 tests it.
- Typed tools: M3 and M6. The broker re-checks every call (`tools.mjs:6-7`). The lookup verbs refuse a
session in a business without a library (`library-not-configured`) and a missing `universe`.
- Messaging: `#checkMessaging` on `message.send` (`broker.mjs:1867`) enforces `sendTo: ["human"]`. C2-08
tests that a creative instance sending to another instance is refused.
- `research` web use goes through the broker's web tools (`tools.mjs:197-206`), which check the grant on
every call (`broker.mjs:1516`).
- Discord: the relay has no tools of its own, and M8 refuses one that does.
- Writes: M12 and the grant check on `library.doc.write` and `library.doc.delete`. The broker re-checks
every call, so a session whose grant was revoked gets `library-write-no-grant` from a tool it still
holds.
## 6. One chat with N agents and @tag addressing
D4: the chat is a project. Each creative instance holds one thread in it. Each thread still has exactly
one instance, so `schema.sql:296-306` hold as written and no migration is needed. Webby checked this
against the schema in webby-0001.
C2-09a, in the slice:
- `#threaded` (`broker.mjs:290-291`) and the launcher (`launcher.mjs:135`, `:236-238`) read
`THREAD_DEFINITIONS`. A run starts per thread when that thread has waiting messages, as the
assistant's does now.
- `thread.create` takes `instance`, which must be a `THREAD_DEFINITIONS` instance. With no `instance` and
exactly one such instance, today's behaviour holds (`broker.mjs:2331-2334`). With more than one, it
still refuses `thread-instance-ambiguous`.
- The Console's new-thread control lists the thread instances by `persona.name` or instance name and
passes the one she picks. A thread with Bean and a thread with Chet sit side by side, much as her
Hermes channels do today (mos-1000).
C2-09b, after the slice:
- `room.open {project}`. Human only. It creates a thread for each thread instance in the project, named
after the instance. A same-name thread that belongs to the same instance is reused. One that belongs to
another instance makes `room.open` refuse `room-thread-name-taken` and create nothing, because names
are unique per project (`schema.sql:249`).
- `room.send {project, body}`. Human only. Leading `@name` tokens, before the first other word, pick the
recipients. Names resolve through `#named` (`broker.mjs:642-650`) or instance keys. With no tag, every
member gets the message. An unknown tag refuses `unknown-addressee` and inserts nothing. The broker
inserts one message per target thread in one transaction, as `thread.send` does (`:2383-2397`).
- `room.read {project, after?}` returns the merged timeline of the project's threads.
- A Discord channel bound to a room uses `room.send`, so @tags work in Discord too.
- An agent sees only its own thread. Showing agents each other's replies is C2-16.
Tests. C2-09a: a creative instance's thread starts a run, `thread.create` with `instance` binds it, and
two thread instances with no `instance` still refuse. C2-09b: `@continuity x` makes exactly one message,
in continuity's thread. No tag makes N. `@nobody x` refuses with the message count unchanged. A tag after
the first word is plain text. `room.open` against a same-name thread on another instance refuses.
## 7. Obsidian, Console, Discord and sync
### 7.1 Obsidian
She has it working already. `~/src` is her vault, the books repo is a folder in it, and obsidian-git
pushes it to git.mosaicstack.dev (mos-1000). The hub changes nothing on her machine. It commits to her
repo only an agent's supporting doc write, her revert and her own Console save, and obsidian-git pulls
each on its next pull. The setup sheet (C2-12, rewritten by C2-12b) tells her which folders the agents
read, that a folder named in `playground` is her playground, that a folder named in `supporting` is where
agents may write, and how to add a graph colour group for those paths in her own vault settings if she
wants one. The graph view and backlinks stay Obsidian's own. The sheet also says two
things she might not expect. Moving a note into a book folder makes it canon, so agents may cite it as
fact (N2). And a short link to a name that also exists in another of her repos can point somewhere else
for the agents than in Obsidian. A full-path link avoids that (N4, 1.3).
### 7.2 Sync (bus side)
One function, `syncCommand(kind, url)`, builds every fetch and push. Its argv is `SAFE`
(`git.mjs:7-9`), then `-c protocol.https.allow=always`, `-c http.followRedirects=false` and
`-c credential.helper=`, then `-C <mirror>`, then the command with the URL as an argument. Its env is
`gitEnv()` plus `LC_ALL=C` and `GIT_ASKPASS` naming a helper script outside the mirror, which reads the
slot file. The helper answers only the exact Username and Password prompts for the configured host and
user and exits 1 on anything else (ruling after r11). The URL carries no userinfo. The token is never in the URL, the mirror's
config, argv, logs or replies (CONSTITUTION gate 13). The URL must be `https://`. Tests reach a local bare
repo only through a module parameter that replaces `syncCommand`. No business file field or bus verb can
reach that parameter.
The slice (C2-03a) is fetch only. Under the library lock, `library.sync` runs:
1. Fetch the configured branch into `refs/remotes/library/<branch>`.
2. If it fast-forwards from the served commit, move the served ref `refs/heads/served` to it and rebuild
the index and the R-rule problems.
3. If it doesn't fast-forward, keep serving the old commit and record `library-diverged`. A force-push to
her repo would be unusual, so the bus waits for a human `library.sync --accept <commit>` instead of
guessing.
4. If the fetch fails, keep serving and record the error and its time.
The sync state (served commit, problems, last fetch, last error) is a JSON file inside the mirror,
written atomically, so it survives a bus restart. The human verb `library.status` reads it.
`library.sync` runs on a 60-second timer in the bus, and before any lookup that finds the last fetch
more than 60 seconds old.
C2-03b adds the push for C2-02's commits, the one race retry, `library-push-failed`, and
`library-remote-invalid` refusals of writes (B9). It also changes whose token the helper answers with: the
chief of staff's grant token and Gitea user for the fetch, in place of slot `library-gitea`, the writing
seat's own grant token for its write, and slot `library-human` for her save and revert (ruling after
r11). The fetch falls back to slot `library-gitea` only while the events table holds no gitea grant
event for the chief, and nothing else ever reads that slot. There is no `unpushed` state.
Tests (C2-03a): `syncCommand` argv equals `SAFE` plus the fixed sequence above, with no other
`protocol.*` key. Its env holds `LC_ALL=C` and `GIT_ASKPASS` pointing outside the mirror. The helper
prints the user for the username prompt and the slot file's content for the password prompt, and
refuses a wrong host, a wrong port and another user. A 302 to another host fails the fetch. A search of argv, the mirror's config and the run log finds no token. Against
a bare repo: fast-forward, R-problems recorded without holding the head, `library-diverged` kept until
`--accept`, a failed fetch kept serving. A mutant that sets `protocol.allow=always` fails the argv test.
### 7.3 Console WYSIWYG (slice rows that don't gate 10-27)
- Library pages (C2-11b) show a tree of universes, books, type folders, manuscript, playground and
supporting folders, the last two each in their own section and colour. They open files in the existing
editor through `library.file`. Each save is one commit through `library.file.save {path, base,
content}` (ruling after r11, F41). A file that moved refuses `stale-base`, and the page keeps her text
and offers a reload.
- Wikilinks render as links in the editor (C2-11a). Clicking one opens the target.
- Promote (C2-19) and crossover (C2-15) buttons come later.
### 7.4 Console chat (C2-10b)
- The chat view renders a reply as its text, then its citations as path, lines and quote, each with a
scope badge (canon, playground, supporting or web). A cite whose file's last commit carries a
`Mosaic-Agent` trailer gets an agent-written label (Mos point 3). It marks a cite "changed since" when
the file's last commit is newer than the cited one.
- A `library-write` notice shows a fixed line, "<name> wrote <path>" or "<name> deleted <path>", with the
commit and a Revert button that calls `library.revert`.
- A status line reads `library.status`: the served commit and its age, `library-diverged`, a fetch
error, and the count of R-problems with a link to the list.
- `renderReply(body)` is one pure function in `packages/library`, shared with the Discord relay. It
replaces C2-01's `renderItems`.
### 7.5 User and agent sync
- Agents read the served commit through the broker. They never have a private copy (2.1).
- Every reply cites a commit, and the Console marks stale cites.
- Her push is visible to agents within about 60 seconds.
- Agents write only supporting docs, each a commit with a notice she can revert. Her Console saves are
commits too. Obsidian sees both on its next pull. A race with her Obsidian push refuses `stale-base`
and never merges (D6).
### 7.6 Discord relay (C2-18, after the slice, optional)
Jason's Q180 ruling takes Discord out of the slice. Hermes keeps her Discord bots running until Bean
and Chet are live in Mosaic. If she later wants Bean and Chet reachable from Discord, the connector
stays and its engine changes, as below.
- A new engine module, `engine-bus.mjs`, next to `engine-pi.mjs`. Binding field `engine.provider:
"mosaic-bus"`, plus a map from each channel id to one thread id. `prompt(text)` calls `thread.send` on
the bound thread with her message as the body. It then waits for the next message in that thread from
the instance to `human`, up to the binding's turn timeout, and returns `renderReply(body)` as `text`.
The connector's loop, journal, delivery and authorization stay as they are (`connector.mjs:381-387`).
- The relay posts only bodies the broker stored, and those passed the guard (D8). It never sees a model's
output. A timeout posts the fixed line "No answer yet. It will appear here when it arrives", and a later
poll posts the stored reply.
- A rendered reply can pass Discord's 2,000-character limit, since replies have no length cap now. The
relay splits at paragraph boundaries, and a paragraph over the limit at line and then character
boundaries, each part with its own nonce.
- On each change of `library.status`, the relay posts one line to her channels: diverged, a fetch error,
a new R-problem count, or "in sync again".
- M8 refuses any other engine on a library business, and a `mosaic-bus` binding that sets `tools`.
- It is a new Discord application and bot. It never uses Hermes's two bots or their token slots.
- Human authority. `thread.send` is human only, and the broker accepts the human through the human CLI's
path-bound proof today. A Discord message from a listed user becoming human input is a new way through a
security boundary. It needs its own Jason ruling before C2-18 is built.
Tests (C2-18), against a fake bus and fake REST, with no Discord server:
- A posted Discord message equals `renderReply` of the stored body, byte for byte.
- A refused agent reply (nothing stored) makes the relay post nothing but the fixed notice the runner
then stores.
- A pi-engine binding and a relay binding with `tools` both refuse on a library business.
- A long reply splits at paragraph boundaries, and no part goes over 2,000 characters.
- Each `library.status` change posts exactly one line.
## 8. Row list
Owners follow Q177 and Q178. Opus 5.5 takes hard design and builds. Haiku 5.5 at high effort takes mid
work as Sage-dispatched subagents (lead decision 129). Codex gpt-6.1-sol (Rocko) takes parallel builds.
gpt-6-luna takes low-level work. An independent reviewer (Filbert, Dewey or Webby) reviews every row.
Each gate is its package's changed test files run singly plus the full gate before landing. No test may
touch `~/.mosaic-dev`, `~/.config/mosaic-dev`, live units, slot files, her repo or any Discord server.
Chains follow Q177 merge-chaining. S marks a slice row. C2-11a and C2-11b are slice rows that don't gate
10-27 (Q195).
| Row | S | Scope | After | Owner | Test gate |
|---|---|---|---|---|---|
| C2-01 | S | `packages/library`: mapping schema, scope rule, front matter reader, link resolver (`vaultPrefix`, relative, shortest path), R1-R7 reporting, `renderItems`, and `library survey` | none | Opus (Gosalyn) | one case per R-rule and per resolver step, a link into another repo unresolved, `appears_in` wikilinks indexed. `renderItems` snapshot per kind and notice. The survey prints names, counts, key sets and link-form counts, and a marker string in a fixture body never appears in its output |
| C2-01c | S | Playgrounds at three levels (ruling after r11, mos-1233): `kind` values, draft entries, the prefix refusals, `classify` checking prefixes first, `book: null` | C2-01 | Opus | one test per level and per new refusal, including a prefix that holds its own book and one that holds another entry's book, and the readers named in the ruling given a draft and a null book. `mapping.test.mjs:16-20` moves to the new codes |
| C2-04 | S | Business file `kind: "library"` entry, skips at each consumer in 2.1, M9, mirror creation at the `projectRepo` path, human verbs `library.file` (read) and `library.status` | C2-01 | Sol (Herb, sage-1482) | business file tests. One test per consumer skip. A launcher test that the workspace has no clone. M9 test. Each verb from an agent session refuses (M4) |
| C2-03a | S | Fetch-only sync (7.2): `syncCommand`, askpass helper, served ref, `library-diverged`, `--accept`, sync state file, timer, `library.sync` | C2-04 | Opus | the C2-03a tests in 7.2 |
| C2-05a | S | Lookup index and queries: find, get, search, manuscript with scope, universe, isolation | C2-01 | Haiku high | isolation, scope labels on every tool, unmapped files never returned, paging under `REPLY_CAP`, rebuild time on a 3,000-file fixture |
| C2-01d | S | `supporting` lists and their prefix rules, `library-supporting-other-universe`, the `supporting` scope ahead of playground and canon, the `editor` field, and `supportingPath` (M12) | C2-01c | Opus | one test per prefix rule and refusal, a folder in both lists, scope order, every M12 path class, `editor` refused when not two non-empty strings |
| C2-05b | S | Broker lookup verbs and typed tools. Sync before a read older than 60 s. Row 224, built to the earlier brief and landing as built (sage-2833) | C2-05a, C2-03a | Sol (Rocko) | broker verb tests. Typed tool name snapshot per creative role without the write tools (M3) |
| C2-05c | S | `scope` with `supporting` in the lookup verbs and tools, `blob` in `library_get` (sage-2833) | C2-05b, C2-01d | Sage picks | a `supporting` lookup in find, search and get, canon by default leaves supporting files out, `blob` equals the served blob id, the M3 snapshot unchanged |
| C2-06 | S | Citation guard (5.2) in `packages/library`, the broker's pre-check on agent-to-human `message.send`, the page store, `replyLimits` with `requireCitation` and `citationsPerReply` and M11 (Q222, Q226), the runner texts, and the runner retry that was C2-07 | C2-05a, C2-04 | Opus | one pass and one refusal per check in M5, M11 and 5.2, through the RESULT path and `send_message`, and with a non-creative sender. `messages` count unchanged on each refusal. The runner tests in 5.2 |
| C2-07 | | Folded into C2-06 (ruling after r11) | | | |
| C2-08 | S | Six role files and contracts, the new constants, M1, M6, the `webPlan` refusal, gate tests (M2), messaging test. `persona.name` and `persona.sha256` in `REGISTRY`, the persona file read and checks, and the fenced persona block between the contract and "## This session" in `prompt()`. M10: the `organization.set` refusals, the `models` check, the `routes.set` and `instances.set` creative branches (4). Both adapters pass the prompt as a file, not its text (4) | none | Opus | role tests, gate tests, a creative raise refused, `harness-builtin` refused for `research` and `companion`, `scripts/test-config.sh`. Persona vars refused at other layers, malformed and on a non-creative definition. The persona file refused when missing, a symlink, another uid's, group or other readable, over 8,000 characters, off its digest, holding `</persona-file>` or a `# `/`## ` line. A prompt snapshot pinning contract, persona, session. The M10 tests, tester cases through a bound tester session, the `models` mutant. Adapter tests: argv holds the prompt path and no marker from the prompt, and pi's adapter refuses a missing and an unreadable prompt file. M10's actor case: the human's `limits.set` change to Bean's `launch.models` key works next to the chief's refusal. Business file, `.history` and `changes.jsonl` modes after a write |
| C2-09a | S | `#threaded` and launcher read `THREAD_DEFINITIONS`. `thread.create` takes `instance`. The Console new-thread control picks it | C2-08 | Sol (Rocko) | the C2-09a tests in section 6 |
| C2-10a | S | M7 in the conversation reader. M8 in `discord check` and `discord run` | C2-08 | Haiku high | the M7 reader test, the M8 tests |
| C2-10b | S | Console chat: the reply renderer through `renderReply`, which replaces `renderItems`, the agent-written label, the notice line with Revert, the status line | C2-06, C2-09a, C2-03a, C2-02 | Haiku high | webui tests for text and citations, scope badges, the agent-written label, the notice line and its Revert call, "changed since" and the status line |
| C2-12 | S | The setup sheet for Melanie (7.1, including N2 and N4, and what changes for Bean and Chet in 4) and the survey run sheet for `<host>` | C2-01 | Luna | doc only, reviewed against the survey's real flags |
| C2-12b | S | Setup sheet rewrite for Q223 and Q226: free-text chat, supporting folders and how to mark one, agent writes as commits with notices and Revert, her Console saves, the `editor` name and email, the settings that replace the caps, and the residual | C2-12 | Luna | doc only, reviewed against the ruling after r11 |
| C2-20 | S | Persona import (9.1): a script that reads only the persona definitions of the two Hermes profiles, confirms profile to name, and writes each persona file (4) with its `persona.name` and `persona.sha256` for the business file. Prints names, byte counts and sha256 only. A `--dry-run` mode, run before Jason's go, prints only counts of files, lines and lines starting `# ` or `## ` | C2-08 | Opus (Gosalyn), run under Jason's go | fixture Hermes profile trees: the right fields are taken, memory and session files are never opened (a read trace on the fixture), a profile with no persona field refuses, a persona that would fail C2-08's fence check refuses, output holds no persona text, each written file is 0600 and its digest matches, `--dry-run` writes nothing and prints the right counts for a fixture with two heading lines and no marker string from it |
| C2-13a | S | `scripts/case2-unit.sh`, which renders the bus and Console system units from their existing templates and which root runs from a root-owned checkout in 10 step 2c, and `scripts/case2-precheck.sh` with the host checks. No library dependency, so row 195 chains on it (sage-1480) | none | Opus (Gosalyn) | the C2-13a brief's render and precheck cases: token rules, `@PATH@` from `--node`, the four hardening lines, the Console's capability lines and privileged port, one `WantedBy=multi-user.target` on each unit (Q189), copy-id pairing, the serve flag set, the key file and the unprivileged port start. Scratch unit directory and scratch data root |
| C2-13b | S | Install script for `<host>` as `<user>`: bootstrap, the stack copy, business file with the mapping, `bean` and `chet` with their persona vars and files, any specialists she picks, her human id, token slot path. The install writes no unit. Adds the library checks to the precheck. Jason or his delegate runs it (10) | C2-13a, C2-04, C2-03a, C2-20 | Opus (Gosalyn), Jason's go | precheck library checks: the business file, `.history`, `changes.jsonl` and persona files have the modes in 4 and each persona digest matches, no Discord binding exists, and after step 4 `--library-status` reads the bus's recorded sync state and lists R-problems (10 step 4a). Tested against a scratch data root and a local bare repo |
| C2-08b | S | Role changes, authorized in mos-1309 item 5: `library.write` in the creative authority and in `checkCreative`, the gitea credential on the six creative definitions and the chief of staff, instance `identity` and `publish`, `CREATIVE_KINDS` removed | C2-08 | Opus | role tests: a creative role with `library.write` validates, one with any other added action refuses, the chief holds the credential and not `library.write`. `scripts/test-config.sh` |
| C2-21 | S | Grant executor for `kind: "library"` (ruling after r11, F40): the creative raise check narrowed to library projects (mos-1163), collaborator in step 4, the level check in step 5, the rendered block, revoke. The install enrolls the chief of staff | C2-08b, C2-13b | Opus | first, the two Gitea facts proven on a scratch Gitea 1.27.1. Then executor tests: `write` for a creative definition, `read` for the chief and any other, a wider permission or `admin` refuses `gitea-reach-wider`, revoke deletes the token, a non-library project still forks, a creative raise naming a non-library project refuses `gitea-not-declared` |
| C2-03b | S | Push and credentials (7.2): the push form of `syncCommand`, the race retry once, `library-push-failed`, `library-remote-invalid` on writes, the helper answering with a seat's grant token, the chief's for the fetch, and slot `library-human` | C2-03a | Opus | push tests against a bare repo. The helper refuses another seat's user, and no token reaches argv, config or a log |
| C2-02 | S | Writes (ruling after r11): `library.doc.write`, `library.doc.delete`, their typed tools, the plumbing commit and trailer, the notice, `library.revert`, the trailer label in the check result, and `library.file.save` | C2-01d, C2-03b, C2-04, C2-05c, C2-08b | Opus | the M12 broker tests, write, delete and revert against a bare repo, the grant refusals, the notice body, her save's author, the M3 snapshot with the write tools |
| C2-14 | S | End-to-end acceptance on a scratch data root and a bare repo in her layout: sync, ask a creative agent in its thread, a reply whose cites verify, a playground cite as canon refused, a force-push held as diverged, an agent's supporting doc write and its notice, a story path write refused, a revert, her Console save as one commit, the Console shows the reply | slice rows | Opus | one script, which the reviewer also runs |
| C2-19 | | `library.promote`, multi-path commit | C2-02, C2-03b | Sol (Rocko) | promote in one commit, human only |
| C2-09b | | `room.open`, `room.send` with @tags, `room.read` | C2-09a | Sol (Rocko) | the C2-09b tests in section 6 |
| C2-18 | | Discord relay (7.6), optional, after its own Jason ruling | C2-09a, C2-06, C2-03a | Opus | the C2-18 tests in 7.6 |
| C2-10c | | Console room view: merged timeline, @name completion | C2-09b, C2-10b | Haiku high | webui tests, @tag parse parity with the broker |
| C2-11a | S | Editor wikilink node. First measure whether `[[` round-trips today | none | Sol (Rocko) | `roundTrip` tests: plain, labelled, heading, in a list, next to front matter |
| C2-11b | S | Console library pages: tree with playground and supporting sections, open, one commit per save through `library.file.save`, `stale-base` keeps her text | C2-02, C2-11a | Haiku high | webui tests: a save makes one commit with her `editor` as author, a moved file shows `stale-base` and keeps her text, an unmapped path offers no save |
| C2-15 | | Hybrid universes in the business file, `exclude` and `pin` | C2-05a | later | |
| C2-16 | | Agents see each other's replies in a room | C2-09b | later | |
| C2-17 | | Dropped: there is no unpushed state (ruling after r11) | | | |
Parallel waves:
- Landed: C2-01, C2-01c, C2-03a, C2-04, C2-08, C2-12, C2-13a, C2-20. C2-13b is in progress.
- Next, side by side: C2-01d, C2-03b, C2-05a, C2-08b, C2-09a, C2-10a, C2-11a, C2-12b.
- Then: C2-05b (row 224, built), C2-05c, C2-06, C2-21 and C2-02. Then C2-10b and C2-11b.
- Last: C2-14. Rooms, the relay and promotion follow when a seat is free.
Dependencies on other tracks: row 190 doesn't apply, since nothing else runs as `<user>`. SSO (Q175,
#412) gates the Console from her machine, and the Console is now her channel, so the slice needs SSO
landed or Jason's ruling on an interim sign-in (10 step 5). T1 row 7 owns
compaction count and clear context (GAP-MAP C2.12, C2.13). Until then, a new thread is a fresh context
(`session.mjs:59-64`).
## 9. Where it runs, Bean and Chet's move, and what can't be ready by 2026-10-27
Where it runs (Jason, Q181, relayed in mos-1031). `<host>` is her VM 10.1.1.54, W-Melanie-OC, VM 1012
on pve. `<user>` is `mosaic`, a system user with no sudo, no supplementary groups and no keys, home
`/srv/mosaic` at mode 0700. It runs as two system units, `mosaic-bus@<business>.service` and `mosaic-webui.service`, both with `ProtectHome`, so nothing it needs may
live under `/home`. Its config directory, `<configDir>`, is `/srv/mosaic/.config/mosaic-dev`, and the token
slot and persona files live under it. ops-01's plan, section 3, set this layout. That plan stopped
with #421, and Q183 keeps a `mosaic` user without sudo on each VM. Section 10 step 2a creates it. The rest of the design
still names `<host>` and `<user>`, so a later move changes only the install inputs. The bus, data root,
mirror and runners live under `<user>`. It needs outbound HTTPS to git.mosaicstack.dev and the model
providers. It needs nothing from her user, because her writing reaches it through the Gitea remote she
already uses.
Model path (Q186, mos-1090). For 10-27, `bean` and `chet` both run harness pi with provider `zai` on
`glm-5.3`, through one key for the hub (10 step 2d). Pi's provider comes from the instance's session
(`packages/plugins/harness/pi/index.mjs:57`), and pi-ai reads a zai key from `ZAI_API_KEY`
(`env-api-keys.js:89`, with `glm-5.3` in `dist/providers/data/zai.json`). `ENV_ALLOW` doesn't pass that name today
(`packages/seat/src/session.mjs:83-88`), so row A adds it. A pi launch has no credential check
(`pi/index.mjs:42`, `packages/cli/src/launcher.mjs:299`), so a missing key shows up only at the first
turn. The C2-13 precheck catches that before the start. The creative roles' web search goes through the
broker's search source, not the harness, so pi having no web tool of its own (`pi/index.mjs:44-45`)
changes nothing here (5.2). After 10-27 Chet moves to his own ChatGPT/Codex login. That needs a
per-instance pi agent dir, which is row B (Vikunja #428), and it starts after a live check that two
clients on one login don't revoke each other's refresh tokens. Running any pi role waits on #383.
Memory. From mos-1000, her two Hermes services use about 1.8 GB together, and each Mosaic agent session
measured 300 to 390 MB on dragon-lin. I estimated at least 2 GB more than the 3.8 GiB her guest saw, and
4 GB more with specialists running at the same time. ops-01 is setting VM 1012 to 16 GiB fixed with 6
cores at 2026-10-21T09:00Z (mos-1031, mos-1038), which covers both. C2-13's precheck still prints free memory before the start.
Isolation on that host. `<user>` without sudo keeps the hub from touching her files. The reverse is the
B7 residual from r2, and it has two parts.
- The factory's paths to .54: pve root, ssh as `melanie`, and the root her passwordless sudo gives
through those paths (mos-1000, jason-master-restart `projects/mosaic-mvp/MELANIE-HOST-RECON.md:27-39`).
Jason accepted them (Q182, Q183), and FED R8 records his acceptance instead of refusing to start.
- The Hermes processes. They run as `melanie` and could use her sudo to read `/srv/mosaic`. Jason's
acceptance doesn't cover this part. It is Mos's residual (mos-1064). Q181 keeps Hermes running, and
it ends when #423 step 5 removes her sudo once Bean and Chet are live.
The in-VM tightening is Vikunja #423. Only its step 1, the `localadmin` account, gates the install
(10 step 2).
### 9.1 Moving Bean and Chet (C2-20)
- What the row reads: the persona definition of each of the two Hermes profiles, default and chet, and
nothing else. Not the memory store (188 facts), not `state.db` or any session or message, not the four
per-channel prompts, not the bot token slots. It lists the per-channel prompts by name and size only, so
Jason can say whether they belong to the personality.
- Profile to name. I expect default to be Bean and chet to be Chet. The row confirms it from each
profile's own configured name, and prints the pairing for Jason before anything is written. If a profile
names neither, it stops and asks.
- How it reads. A script, run on `<host>` by whoever Jason names, because the persona files sit in her
home directory and `<user>` can't read them. It writes each persona text to a 0600 staging file, and a
second step run as `<user>` installs it as `<configDir>/personas/<business>/<instance>.md` (4). It
prints `persona.name` and `persona.sha256` for the business file entries of `bean` and `chet`, and
otherwise names, byte counts and sha256 only. The text never enters the repository, the business file
or `changes.jsonl`. Outside the persona file, it sits only in Bean's or Chet's own run bundles, 0600
until prune, and in the harness argv if the host's claude lacks `--system-prompt-file` (4).
- Dry run first (N2, mos-1051 item 3). Before Jason's go to import, the row runs a dry pass that reads the
same two persona definitions and prints counts only: files, lines, and lines starting `# ` or `## `.
No text and no line numbers. It also counts lines that name a Hermes tool or channel, from a fixed
list in the script: the tool names in the hermes-agent checkout at df5700ebe, read from its code and
not her profiles, plus `Hermes` and `Discord`. That prints one count per persona file, never the line,
its number or the word. A nonzero count changes nothing in the import. It tells Jason a persona refers
to things Bean and Chet won't have in Mosaic. If any count of heading lines is nonzero, the fence rule in 4 would refuse
a real persona, so the rule changes, in a reviewed C2-08 follow-up, before the import runs. The text is
never rewritten to fit the rule unless Melanie says so through Jason.
- Hermes keeps running. The row changes nothing in the Hermes checkout, its profiles, its services or its
Discord bots. Bean and Chet in Mosaic are new agents with the same persona, not a move of the Hermes
ones. She uses both until she is happy, and stopping Hermes is her choice through Jason, after Bean and
Chet are live (10 step 9).
- Memory and history stay in Hermes unless she chooses otherwise through Jason. Bean and Chet in Mosaic
start without her 188 facts. Their knowledge of her work comes from the lookup tools.
### 9.2 The slice and the date
The smallest slice that lets her start is the S rows: C2-01, C2-01c, C2-01d, C2-02, C2-03a, C2-03b, C2-04,
C2-05a, C2-05b, C2-05c, C2-06, C2-08, C2-08b, C2-09a, C2-10a, C2-10b, C2-12, C2-12b, C2-13a, C2-13b, C2-14,
C2-20 and C2-21. C2-11a and C2-11b are slice rows too, and they don't gate 10-27 (Q195). With it she keeps
writing in Obsidian exactly as now. In the Console she opens a thread with Bean, Chet or a specialist and
reads each answer as free text whose citations the broker checked. Each agent write shows as a notice she can
revert. A status line tells her when the hub falls behind her repo. Hermes keeps running beside it.
Not ready by 10-27: promotion (C2-19), rooms with @tags (C2-09b, C2-10c), the Discord relay (C2-18), C2-15
and C2-16. The editor may land after 10-27 without holding it. The slice makes 10-27 only if
section 10 steps 1-7 are done by about 10-24. The Console from her machine needs SSO (#412) or an interim
sign-in ruling, and that is the step most likely to be late.
### 9.3 The Console's forgery limit on .54
Mos asked this in mos-1097 item 2, for Jason. Decision 95 accepted the local-forgery limit only on
dragon-lin under Jason's user, so on .54 it is Jason's call. Everything below comes from the code and
this design. Nobody has looked at the live host.
Who runs as `mosaic`, the Console's uid.
- The bus host, `mosaic-bus@<business>.service`, and everything it starts.
- The Console, `mosaic-webui.service`, and the `human-cli.mjs` children it starts for each bus call
(`packages/webui/src/bus.mjs:2`).
- Bean's and Chet's sessions, and any specialist's. The bus starts each one inside bwrap, and the
runner, the harness and every shell the harness opens run inside it (`packages/seat/src/sandbox.mjs:1-4`).
Inside, the session is still `mosaic` (`packages/harness/README.md`, "Limits", "Same UID").
- The admin steps Jason or his delegate runs as `sudo -u mosaic -H` from `localadmin`: the install,
the precheck, `library survey` and the C2-20 import (10 steps 2b, 6 and 7).
Nothing else. `mosaic` has no login shell, no key and no sudo (10 step 2a).
What a session can reach from inside bwrap.
- The Console's port, yes. bwrap doesn't confine the network. A session shares the host's network
namespace and reaches every service on `127.0.0.1` and every abstract Unix socket
(`packages/harness/README.md`, "Limits", "Network and abstract sockets aren't confined").
- The proof file, no. The sandbox holds only the binds `sandbox.mjs:6-22` lists, and C2-13a requires the
proof file under `<home>/.config/mosaic-dev/secrets/`, which is not one of them. The session's `/proc`
is private and in its own PID namespace (`sandbox.mjs:16`, `:67`), so it can't read the Console's
memory or environment either.
- The bus's human socket, no. The sandbox binds the broker socket, the launch socket and the git socket
only (`sandbox.mjs:97-108`). The bus and Console code opens no abstract socket (a search of
`packages/bus/src`, `packages/webui/src` and `packages/cli/src` for one finds none).
What reaching the port gives a session. Every write in preview mode needs the public Host, and the proof
check comes before every auth-header read (`packages/webui/src/serve.mjs:495-503`). A request with the
public Host and a missing or wrong proof gets 401. A request with a loopback Host is read-only, since
every write route requires `named` (`serve.mjs:532-546`), and the proof header with a loopback Host gets
403. So with the proof on and no tester listener, a session that reaches the port can read what the
Console shows on its loopback path and can't send as Melanie. Without the proof it could, because it can
set the public Host and the auth header itself. That is why C2-13a makes the proof pair required and
refuses `--tester-port`.
A gap I found while checking this: a port squat. When the Console isn't running, a session can bind
its port, if the port is 1024 or above. The proxy then forwards Melanie's browser to the session's
listener, with her sign-in header and the proof header on every request. The session gets the proof
value, and from then on it can send as her to the real Console. It can also show her a fake Console.
The Console is down after a crash for `RestartSec=15`, during each re-pin, and for a moment at boot
before its unit starts. Q189 starts it at boot, so a reboot no longer leaves it down until a human
starts it, as r11's B1 did. Bean and Chet run model-driven shells, so I treat it as reachable. The same reach exists wherever
sessions share a host and a network namespace with a Console on a port above 1023. I haven't looked at
the live install and make no claim about it. The fix in C2-13a is a Console port below 1024. Only a
process with `CAP_NET_BIND_SERVICE` in the host's network namespace can bind it. The Console unit gets
that capability through `AmbientCapabilities=` and `CapabilityBoundingSet=`. A session holds
capabilities only in its own user namespace, which doesn't own the host's network namespace, so it can't
bind the port. The precheck checks that `net.ipv4.ip_unprivileged_port_start` is above the port. The
Console's own `--port` check takes 0..65535 (`packages/webui/src/cli.mjs:27`), so serve needs no change.
Step 5's forward must point at that port. The default board origin, `127.0.0.1:7331`, has a smaller
version of the same gap. Case 2 runs no control board, so a session could listen there and the Console
would show its answers in the board panel. That is content on her screen, not a send as her. I left it
as a named residual and didn't add a flag for it.
The narrowest form by 10-27, with C2-13a's flags (proof on, no tester listener, port below 1024): a
send as Melanie needs the proof value. These can read the proof file:
- `mosaic` outside bwrap: the bus host, the Console and the admin steps above. All run reviewed code
from the copy, or a command Jason's go names. No model-driven process runs there.
- root on .54. That is `localadmin`, the factory's paths through pve (Jason accepted those in Q182 and
Q183), and `melanie` through her passwordless sudo. Her Hermes processes could use that sudo, as
section 9 says for the key file.
Bean's and Chet's sessions reach the port and get 401 or 403. Melanie's own uid without sudo can't
read `/srv/mosaic` (mode 0700).
What closes the rest.
- #423 step 5 removes `melanie`'s sudo once Bean and Chet are live. That ends the Hermes part.
- A separate Console uid (#385) splits the Console from the bus, so the bus host no longer runs as the
uid that owns the proof.
- The containerized install puts the Console in its own container (I4) and the sessions in theirs (I5).
Then no session reaches the Console at all (`docs/plans/2026-10-18_containerized-install-design.md:584-586`),
and the squat goes with it. That design says it doesn't close the limit for a deliberate process on
the host (`:587-588`), so root and `mosaic` outside a container stay.
- Root on .54 stays after all of these. That part is Jason's acceptance, as for the key file.
## 10. Jason-gated steps for C2-13a and C2-13b
The Gitea host. Her books repo is already on git.mosaicstack.dev (mos-1000). On this host that name
resolves to 10.1.1.222, and STATE.md lists its certificate among the edge certificates, so I treat it as
reachable from the internet. Her writing is already there by her own choice. The hub adds no new remote.
It adds no shared bot account. Each writing seat gets its own Gitea account through the grant path, a
collaborator on that one repo with `write`, and the chief of staff one with `read` (ruling after r11).
Her own token signs her Console saves.
1. The host is set (Q181): 10.1.1.54 as `mosaic` under `/srv/mosaic`, 16 GiB and 6 cores. Jason
confirmed Q4 (Q183), so #421 is closed and the install doesn't wait on it.
- Root steps on `<host>` create the `mosaic` user and its home, under step 2's go (2a). C2-13's
precheck refuses if `<user>` has sudo or any supplementary group.
- FED R8 records Jason's acceptance of the factory's paths to .54. Gizmoduck's r4.10 of the
federation design writes that wording, and it is in progress.
- The in-VM tightening on .54 is Vikunja #423. Only its step 1, which creates `localadmin`, gates
the install, through step 2. Its step 5 removes `melanie`'s sudo once Bean and Chet are live, and
that ends Mos's residual (9).
2. The install on `<host>`. Jason's go covers all three parts. 2a and 2c are root steps, owned by
ops-01 (mos-1064). The path is ssh as `localadmin@10.1.1.54` with NOPASSWD sudo, which #423 step 1
creates. Neither uses `melanie`'s sudo or pve root. They are acts on Melanie's machine, so ops-01
doesn't run them without the go.
- 2a, ops-01 via `localadmin`: `useradd` `mosaic` as a system user with no sudo, no supplementary
groups and no keys. Create `/srv/mosaic` at 0700, owned by `mosaic`.
- 2b: the install as `<user>`, by him or his delegate, run as `sudo -u mosaic -H` from `localadmin`.
`mosaic` has no keys and no login shell, and nobody adds a key to make 2b work. `scripts/case2-install.sh` runs `scripts/bootstrap.sh` and makes the stack copy, whose
`npm ci` installs the pinned harness. It runs no `release.sh package` or `activate`: both need
Docker, and the hub runs no containers (webby-0085 B1, sage-2189). The model path is set (9, Q186). Chet's ChatGPT/Codex login is
not part of 10-27. It is row B (#428).
- 2c, ops-01 via `localadmin`: root makes a checkout of the reviewed commit outside `/srv/mosaic`,
owned by root, and checks that its HEAD is the commit Jason's go names. From that checkout it runs
`scripts/case2-unit.sh`, which renders two system units from the checkout's existing templates:
`[email protected]` from `packages/cli/systemd/mosaic-bus.service.in`, enabled as
`mosaic-bus@<business>.service`, and `mosaic-webui.service` from
`packages/webui/systemd/mosaic-webui.service.in`. Inputs: `--user`, `--home`, `--business`,
`--copy`, `--node` and the Console's serve flags. In both, `@REPO@` is the copy
`<home>/.local/share/mosaic-stack/copies/<id>`. `%h` becomes the literal home, because a system
unit's `%h` is root's home. `@PATH@` starts with the directory of `--node`. The render adds `User=`,
`Group=` and four hardening lines, and the Console unit also gets `CAP_NET_BIND_SERVICE` for its
port below 1024 (9.3). Each unit's `WantedBy=` becomes `multi-user.target`, so
both start at boot (Q189). The C2-13a brief lists every line and rule. It checks `User=`, `Group=`, `ExecStart=`, the bus unit's
`EnvironmentFile=` and the hardening lines against fixed values, and the copy id is the one part
that may change between renders. It writes both under `/etc/systemd/system/` as `root:root` 0644.
It never uses `systemctl link`, and it refuses a symlink at either unit path. On a re-install it
refuses an installed unit that differs from the render. Root runs nothing from `/srv/mosaic` and
reads no unit that `mosaic` wrote. Then it enables both units and starts the bus and then the
Console. They stop in the reverse order.
- 2d, Jason: the hub's own key on the existing Z.ai account, with a spend cap if the account offers
one. Model use is spend, so the key and the cap are his. Whose Z.ai account it is hasn't been said,
and that is his input. The key goes in `/srv/mosaic/.config/mosaic-dev/secrets/claude-session.env`
as `ZAI_API_KEY=`, owned by `mosaic`, mode 0600, the file the bus unit's `EnvironmentFile=` line loads
(the same line as `packages/cli/systemd/mosaic-bus.service.in:24`). The name says claude, but it is
the one file decision 119 and `packages/cli/README.md` document, so I kept it. He places it, or
says who may, per the Q173 secrets rule. Agents never print it. Until #423 step 5, `melanie`'s
sudo can read it, and so can her Hermes processes (9).
3. No bot account. The install enrolls the chief of staff, which raises the gitea grants for `books`
through the existing step, and the executor makes each seat a collaborator on her repo (C2-21). The
seats reach the books repo only, not bean-brain and not her other three repos. Whether each grant also
needs his Approve is Q31, asked of him, and this design adds no step for it. The collaborators change
her repo's access list, which is hers, so step 7 tells her.
4. Her own Gitea token for her Console saves, in slot `library-human` under
`<configDir>/secrets/<business>/`, which is `/srv/mosaic/.config/mosaic-dev/secrets/<business>/` for
`mosaic`, mode 0600. He puts it there, or says who may, per the Q173 secrets rule. Agents never print
it. If the install runs before C2-03b lands, the C2-03a fetch still reads slot `library-gitea`, which
then needs a read token as r11 said. After C2-03b the fetch reads that slot only while the bus has
no gitea grant event for the chief (7.2). Step 4b removes it.
4a. `scripts/case2-precheck.sh --business <business> --library-status` as `<user>`, once the bus is up
and the fetch token is in place. It reads the bus's recorded sync state only and syncs nothing. It
refuses when the mirror is missing, when `lastError` is set, or when `lastFetch` is null. It prints
each R-problem as a `problem` line and then a count, never text (sage-2189). The bus syncs every 60
seconds, so the first check after step 4 may have to wait for one sync.
4b. Once C2-21 has run and `library.status` shows the fetch on the chief's grant, Jason deletes slot
`library-gitea` and revokes its token in Gitea. The fetch never reads the slot again, since a gitea
grant event for the chief then exists and events are never deleted (webby-0151 R3, webby-0156 R4).
5. Her Console sign-in from her machine: her Authentik user and `sub` once SSO lands (#412 step 3 is the
model), or his ruling on an interim. The proxy forwards to the Console's port below 1024 and sets the
proof header. The proof file goes under `<configDir>/secrets/`, mode 0600, owned by `mosaic`, and
agents never print it (9.3). He places it, or says who may, per the Q173 secrets rule. Whether he
accepts the forgery limit on .54 in the form 9.3 names is his decision, and Mos takes it to him.
6. The C2-20 persona import: who runs it on `<host>`, his check of the printed profile-to-name pairing,
and whether the per-channel prompts count as personality.
7. Running `library survey` on `<host>` after step 4, which prints names and counts, never text. Then
contacting Melanie: who tells her what changes for Bean and Chet (4), sends the setup sheet (C2-12b),
tells her the agents become collaborators on her repo, and asks her to confirm the mapping, the
universes, the playground and supporting folders, her `editor` name and email, and which specialists
to start with, and when. A specialist she wants later is added by the human through `instances.set`
(4), with no hand edit and no new row.
8. The residual in 5.1. Closed. Q219 set lower caps, and Q223 then removed the caps with the item
format. What remains is the residual the ruling after r11 states, a supporting doc that holds any
text, and C2-12b tells her about it.
9. The instance start. Stopping Hermes comes later, and only when she chooses it.
- The bus and the Console are already running from step 2c, and both come back after a boot on
their own (Q189, which reverses mos-1097 item 1). Nobody starts the Console by hand here. With a
port below 1024, no session can take the port while it is down (9.3).
- One session starts under the bus unit before she is told the hub is up. The first turn of `bean`
reaches a running session, and its run record shows the runner started. The bwrap precheck runs in
`mosaic`'s shell, outside the unit's hardening, so it can't show this. A `sandbox-unavailable`
refusal here means a unit line or the host's AppArmor rule for unprivileged user namespaces stops
bwrap. The fix is a reviewed re-render, never a hand edit of the installed unit. Running a pi role
waits on #383, so this check does too.
- .54 runs Ubuntu 24.04 (MELANIE-HOST-RECON.md:7), the release where row 195 found
`kernel.apparmor_restrict_unprivileged_userns=1` and no bwrap profile on .53. If the precheck's
bwrap check refuses on .54, the fix is row 195's root step 2a': bubblewrap and Ubuntu's
`bwrap-userns-restrict` profile from the pinned `apparmor-profiles` deb (darkwing/row195 at
0075be358, `agents/darkwing/work/queue-195/DESIGN.md` section 1, mos-1091 Q2 4b). On .54 it would
run under step 2's go, as a 2a item for ops-01.