git mv packages/business -> packages/organization (@mosaic/organization). The old package stays as one-line re-export shims of each moved module, with src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs forwarding to the moved executable. BusinessError is OrganizationConfigError (one constructor, name kept BusinessError); OrganizationError stays distinct. The bus adapter gains organization-context.mjs (busOrganization), with business.mjs (busBusiness) as the same function. Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure walker (organization-imports.mjs, both roots required) move to the new path. v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage A contract tests stay where they were and pass as before.
packages/library
Reads a writing repo by a mapping of folders to books, types, canon and playground. This is Case 2
row C2-01 (docs/plans/2026-10-20_case2-rows.md), built from design sections 1.1 to 1.3 and 7.4 in
agents/gosalyn/work/case2/DESIGN.md on gosalyn/case2-design.
It is pure code with no state. Business file loading (C2-04), sync (C2-03a), the index (C2-05a) and the
guard (C2-06) build on it.
Mapping
checkMapping(entry, where, { fail }) checks a kind: "library" project entry and returns a
deep-frozen copy. where names the entry in messages. fail(code, message) defaults to throwing a
LibraryError. C2-04 passes its own so a business file refusal carries the file name.
{
"kind": "library",
"remote": "https://git.example.invalid/her/books.git",
"branch": "main",
"user": "books-bot",
"vaultPrefix": "books",
"types": { "Characters": "character", "Places": "place", "Research": "research" },
"manuscript": ["Chapters"],
"universes": {
"harbor": { "name": "Harbor", "books": ["Book One"], "playground": ["Book One/Playground/"] }
}
}
remoteis https with no credentials, query or fragment.branchis a plain ref name.useris the bot account name from design step 3, required for sync. It is optional in the mapping and checked with row 199's account name pattern,[A-Za-z0-9_.-], 1 to 100 characters.vaultPrefixis optional, one folder name.editoris optional,{ "name", "email" }plus an optional"user": the ident her Console saves commit under (C2-01d). Each value is text that is non-empty after trim, with no control character (U+0000-U+001F,U+007F-U+009F),<or>, and no whitespace or git-stripped character at either end.useris her Gitea login for thelibrary-humanslot (sage-2925), checked as the entry'suseris.editorand itsusercome back only when given; a save without them refuses in C2-02.typesmaps a folder name tocharacter,place,research,trackerorsummary.manuscriptlists folder names and shares none withtypes.- A universe has
name,booksandplayground. A hybrid addskind: "hybrid", two or more plain-universesources,constraints(each one{ "exclude": ... }or{ "pin": { "entity", "field", "value" } }) andnotes, and may havebooks: []. A draft universe has onlykind: "draft",nameand one or moreplaygroundprefixes, and comes back as{ kind: "draft", name, books: [], playground, supporting }.kindis absent,"hybrid"or"draft". - A playground prefix ends in
/and has one of three levels (C2-01c). Book level lies inside one of its own entry's book folders. Universe level, in a plain universe or a hybrid, lies outside every book folder. A draft's prefixes all lie outside every book folder. No prefix holds another or is listed twice, across every entry. - Every universe kind may also have
supporting, a list of prefixes where agents may write (C2-01d, M12). It comes back on every universe,[]when absent. A supporting prefix follows the playground's rules: it ends in/, has the same three levels, holds no other supporting prefix and is listed once, across every entry. - Between the two lists: a supporting prefix may equal a playground prefix of its own entry, and a playground prefix may hold one of its own entry's supporting prefixes. A supporting prefix may not hold a playground prefix, which scope would then never reach, and may not equal, hold or lie inside another entry's playground prefix.
Refusals:
| Code | When |
|---|---|
library-playground-other-universe |
a playground prefix lies inside, or holds, a book folder of another entry |
library-supporting-other-universe |
a supporting prefix lies inside, or holds, a book folder of another entry |
library-mapping-invalid |
any other shape fault: unknown key, bad remote or branch, an unknown kind, a book folder in two universes, a book folder that holds another, a playground or supporting prefix that holds another of its list or is listed twice, a prefix equal to a book folder or holding its own entry's book, a supporting prefix that holds a playground prefix or overlaps another entry's, a bad editor, a draft with books or with no prefix, a hybrid source that is unknown, a hybrid or a draft |
Scope
classify(mapping, path) decides scope from the path alone. Only paths ending in lowercase .md are
mapped. It checks prefixes first, supporting before playground. A path under any entry's supporting
prefix is supporting, and otherwise one under a playground prefix is playground. Either way that
entry is universe and the book folder holding the prefix is book, or null at universe level and
in a draft. Any other path under a book folder is canon. Everything else is unmapped. The type is
the first folder after the prefix, or after the book: a types name, manuscript, or null.
Agent write paths
supportingPath(mapping, universe, path, tree) returns path when an agent may write it in that
universe, else it refuses library-write-story-path (C2-01d, M12). tree is the array listTree
returns for the commit the write lands on; anything else is a TypeError. It reads no git. The checks
run in this order, so a path with two faults always names the same one:
- a string of at most 1,024 UTF-8 bytes;
- no control character (the
editorclass), and the message leaves the path out; - no backslash;
- not absolute;
- no empty,
.or..segment, which also refuses//and a trailing/; classifygivessupportingin that same universe, which refuses unmapped,.canvas,.baseand attachment paths, canon, a playground-only folder and another universe's prefix;- no folder on the way, and not the path itself, is a symlink (
120000) or gitlink (160000) intree; - no folder on the way, and not the path itself, matches a tree path or tree folder after
normalize('NFC').toLowerCase()unless it is that exact name. APFS folds case and Unicode form, so her checkout would put the file somewhere else. A tree that already holds case twins refuses a write to either (R7).
A delete's further rule, that the path exists at the tip as a 100644 blob, belongs to the C2-02
broker.
Reader
readLibrary({ repo, commit, mapping }) reads one commit, never a working tree. repo may be a
clone or a bare mirror. commit must be a full 40-hex id that names a commit, else library-commit.
Git runs through git() and gitBytes() from packages/project/src/git.mjs, so the same safe config,
environment and ceiling directory apply. A failed git call refuses library-git.
It returns { commit, files, problems }, deep-frozen and sorted. Each file has its path, scope,
universe, book, type, numbered lines, bodyLine, parsed frontmatter or null, frontmatterRaw
and links. Front matter appears_in and relationships wikilinks count as links. A link in a block
list carries its list item's line. One in a scalar or flow value carries its key's line.
Problems carry { code, path, line, message }:
| Rule | Meaning | File indexed |
|---|---|---|
| R1 | not a regular 100644 blob of at most 1 MiB, or not UTF-8, or holds a NUL |
no |
| R2 | path is unmapped | no |
| R3 | front matter not fully parsed, line is where parsing stopped |
yes, front matter kept raw |
| R4 | link resolves to no file or to more than one | yes, link dropped |
| R5 | canon file links into the playground | yes |
| R6 | canon file links into another universe, a hybrid's sources excepted, or a hybrid's file links into a path its exclude covers |
yes |
| R7 | two paths differ only in case | no, neither |
| R8 | pinned value changed: a hybrid's pinned entity is missing or its field differs from value, or a hybrid version of it sets field to another value |
yes |
The front matter parser takes scalars, quoted strings, flow lists, block lists and wikilinks. Nested maps, anchors, block scalars and duplicate keys stop it with R3.
Links
resolver(paths, { vaultPrefix }) tries four steps in order, each with the target as written and
then with .md added:
root: the path from the repo root.vault-prefix: the same path withvaultPrefix/stripped.relative: relative to the linking file, never above the repo root.shortest: a target with no/that matches the base name of exactly one indexed file.
It resolves only against indexed files. A link to a .canvas file or an attachment is R4. A link into
another repo resolves to nothing. extractLinks skips fenced and inline code.
A ![[...]] embed resolves like any link. A body embed that resolves to nothing and whose target ends
in an extension other than .md, in any case, embeds an attachment, and it is never R4. An extension is
a dot and up to 8 letters and digits, at least one a letter. Any other unresolved embed is R4, and so is
an unresolved front matter value written as an embed (C2-01b N3, sage-1900).
renderReply
renderReply(body, { check, changed, name }) turns a stored reply into what both channels show. It
replaces C2-01's items renderer and is pure. A body is free text, or the design 5.2 object
{ "text", "citations" } with library cites { path, commit, lines, quote, as? } and web cites
{ url, quote }. Any other body, a JSON object with another key or a bad cite included, renders as
free text: the whole body, with no citations.
check is the stored check result (C2-06), { citations: [{ scope, agent }] } in body order. A path
cite's badge (canon, playground or supporting) and its agent-written label come only from it. A
url cite is always web, and is a link only when its scheme is https. changed({ path, commit }, i)
says whether the file's last commit is newer than the cited one. plain is the rendering as text:
Mara's eyes are grey in chapter 3.
[canon] Book One/Chapters/Chapter 03.md:41 @aaaaaaa "her grey eyes"
[supporting] Notes/Eyes.md:9-10 @aaaaaaa "green eyes" (agent-written: acme/scribe) (changed since)
[web] https://example.invalid/tides "spring tides"
The broker's write notice, { "notice": "library-write", op, path, commit }, gives the line
<name> wrote <path> @<commit> or <name> deleted <path> @<commit>. A notice is recognized only
when check is null or absent: a checked agent message with that shape stays whole-body free text
with no Revert control. Pre-217 history has no check, so a notice-shaped older body still renders as
a notice; the accepted legacy residual is recorded in the row225 BUILD.md.
Survey
mosaic library survey <repo> [--mapping <file>]
It reads the repo's HEAD commit and prints folder names, file counts by extension, front matter key
sets, link-form counts and the count of body embeds whose target has an extension other than .md,
which still count as embeds. That count goes by the name alone, so an embed of Dr.Quinn.md written
![[Dr.Quinn]] counts there too. Folder names print as JSON strings, with DEL, C1 and bidi controls,
U+061C among them, also as \uXXXX. It never prints file text, front matter values, file names or
link targets.
A key that is not a short identifier prints as (other key), since it could be her words. With
--mapping it adds scope counts, resolver step counts and problem counts by rule.
Exit codes follow packages/project: 0 done, 2 the repo can't be read (library-git,
library-commit), 3 a bad mapping, 4 usage. scripts/mosaic sends library survey here. Other
library verbs will be bus verbs.
Sync
sync(context, { slot, accept }) (C2-03a, design 7.2) fetches the entry's branch from its remote
into refs/remotes/library/<branch> of the C2-04 mirror, then publishes under the library lock:
| Fetched tip | served |
State |
|---|---|---|
| a fast-forward, or the first sync | moves to the tip | problems rebuilt from the tip |
| the served commit | stays | lastFetch set |
| not a fast-forward | stays | diverged holds the tip, library-diverged, until accept |
| fetch failed, slot missing | stays | lastError holds library-fetch-failed or library-credential-missing |
lastFetch is the last fetch that succeeded. Once diverged, served stays put until a human runs
library.sync with accept set to the fetched tip, even if a later tip is a fast-forward again. A
tip back at the served commit clears it. A
stale accept refuses library-accept-stale. accept also starts the state over, so it is how a
changed branch gets a fresh record. An entry with no user refuses library-remote-invalid and
runs no git.
syncCommand('fetch', url, { mirror, branch, user, slot }) gives the one argv and env. The argv is
SAFE from packages/project/src/git.mjs, then -c protocol.https.allow=always -c http.followRedirects=false -c credential.helper= -c core.createObject=rename, then -C <mirror> and
the fetch. core.createObject=rename is there because git's default files an object by link then
unlink, and a reader's mirror check refuses a file with two links. The env is
gitEnv() with LC_ALL=C, GIT_ASKPASS naming bin/askpass, and the host, user and slot path. The
helper answers only Username for 'https://<host>': and Password for 'https://<user>@<host>': ,
the second from the slot's first line, and exits 1 on any other prompt. Sync checks that the slot is
a private regular file and never opens it. The token is in no argv, env, URL, config or log.
Sync never runs gc or prune. A file read takes served under the lock and reads its objects after it
lets go, so an object must outlive the next move of served. The bus host runs sync for every entry
with a user, once at start and then every 60 seconds; the slot is
<configDir>/secrets/<business>/library-gitea. library.sync is the human's bus verb for the same.
The human transport (packages/bus/src/human-cli.mjs) waits 130 seconds for library.sync, past the
server's 120-second hold, and 5 seconds for every other verb. A client that gives up before the answer
gets outcome-unknown, but the sync keeps running: a second call meanwhile gets library-sync-busy,
and library.status shows the result once it ends. A sync that outlasts the 120-second hold gets
request-timeout from the server and still finishes the same way. The Console has no library route yet: cli.mjs
passes librarySync to startServer, and serve.mjs doesn't read it.
Push and credentials (C2-03b)
push(context, { commit, credential, retried }) sends a full commit id, already in the mirror, to the
entry's branch as push --porcelain --no-verify --no-follow-tags --end-of-options <url> <commit>:refs/heads/<branch>, through the same prefix, env and helper as the fetch. The refspec has no
+, so the remote takes a fast-forward only.
| git says | Answer |
|---|---|
| the push landed | { pushed: true } |
[rejected] (fetch first) or (non-fast-forward) |
one fetch with the same credential, then { pushed: false, raced: true, tip } |
the same, with retried: true |
stale-base |
| the same, while a sync of the mirror runs | library-sync-busy, with no fetch |
anything else, [remote rejected] (a hook) included |
library-push-failed |
Push moves no local ref; C2-02 re-applies and moves served. git's output is never kept.
A credential is { kind, user, file }, with kind one of slot, chief, seat or human, or the
bus's { refused: code }. The helper reads only file. It opens the file on fd 3 and refuses unless
the open file and the path have the same device and inode, and the file is regular, the bus's uid's,
mode 0600 and at most 16384 bytes, as readSecretFile in packages/bus/src/gitea-grant.mjs checks a
grant store file. A FIFO swapped in after test -f blocks the open, the same residual
readSecretFile has.
- A seat writes with its own Gitea user and grant store file,
<storeRoot>/<business>/<agent>/gitea. humanCredential(context, { user, configDir })gives her save its credential: her Gitea login and<configDir>/secrets/<business>/library-human. A missing login, or a slot that is missing, a link, another uid's or not 0600, refuseslibrary-human-missingbefore any git call.- The fetch takes the broker's choice,
libraryFetchCredential(business). The chief is arolesentry whose definition ischief-of-staff; a fire keeps it there. With no Gitea grant evidence for a chief, the fetch uses slotlibrary-giteaand the entry'suser. Once there is some, only the one listed chief's live, unheld grant answers, with that grant's user and store file. Anything else refuseslibrary-fetch-no-grant, and so does any Gitea grant evidence in a business with no chief entry. That refusal opens no file and runs no git. The bus answers its code; the timer's log line addsfix: "add the chief of staff, or grant the library through C2-21".
The sync state adds credential, { kind, user } of the last fetch, or null when none ran. It is
optional, so a state written before C2-03b still reads.
Tests
node --test packages/library/tests/
Fixtures are made-up repos built per test in a temp dir. A marker string stands in for her text, and the survey tests check that it never appears in output.
C2-02 supporting writes and human saves
The host exposes library.doc.write {universe,path,base,content} and
library.doc.delete {universe,path,base} only to a creative session holding
library.write and a live, unheld Gitea grant for the library project. The
broker chooses the project from the universe mapping, the author/committer
from the instance identity, and the credential from its grant store. base
is the served-tree blob id, or null to create. Supporting-path checks run
before remote effects and against each fetched tip; deletes require an
existing 100644 blob. Content is UTF-8 without NUL and at most 1 MiB;
agent requests still have the ordinary 64 KiB socket envelope limit.
Each operation is one plumbing commit with one parent, a fixed
Mosaic: write <path> or Mosaic: delete <path> message, and the trailer
Mosaic-Agent: <business>/<instance>. Pushes are fast-forward only. A moved
remote tip retries once when the target blob still equals base; otherwise
it refuses stale-base. A failed push leaves no queued work or local ref
change. A successful push updates served/tracking, rebuilds the index and
records lastPush: {commit,at}. The broker inserts the exact fixed notice
{notice:"library-write",op,path,commit} from the writer to the human in
that thread, independently of the agent-message citation guard.
library.file.save {project,path,base,content} and
library.revert {project,commit} are human-only. Project is the business
project key. A save reaches any mapped .md file under the structural path
rules, uses the mapping's editor as author and committer, and pushes with
slot library-human. Revert accepts only a reachable, one-parent,
one-path commit with this business's agent trailer, and restores its
parent's blob with the agent commit's blob as base. The result is
{project,path,reverted,commit}. Revoking/firing the original writer does
not prevent a human revert. Missing editor refuses
library-mapping-invalid; missing/unsafe human slot refuses
library-human-missing, before Git. An agent calling either human verb
gets human-required. Only library.file.save joins the larger Console
save envelope exception; agent writes retain 64 KiB.
The write guard excludes concurrent sync and admits a race fetch only for
its opaque owner token. The library flock spans fetch/build/push as the
brief requires, so readers can receive library-sync-busy after the
existing five-second lock wait while network Git runs (MVP tradeoff,
sage-3628). Publication errors return outcome-unknown: served is restored
to match old state unless the new state was already published. The remote
commit remains; callers must inspect status rather than retry blindly.
The human push login is editor.user (optional in the mapping, required
for save/revert), distinct from user, which names the fetch bot.
An absent editor login refuses library-human-missing; no fallback uses
the bot's login. Once an agent push succeeds, the broker stores the exact
write notice with that pushed commit id before local publication. If
publication then fails, the agent receives outcome-unknown and the
notice remains. Revert can refuse library-revert-not-agent until a sync
makes the commit reachable from served. No notice outbox or retry is
needed. If the Console store itself refuses the notice insert, the caller
still receives outcome-unknown and a separate metadata-only host log
records library-notice-store-failed with the business, instance, operation
and pushed commit id; it contains no file text or credential.