Files

146 KiB

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):

"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:

{"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, " wrote " or " deleted ", 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 [email protected] 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.