# 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. ```json { "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/"] } } } ``` - `remote` is https with no credentials, query or fragment. `branch` is a plain ref name. - `user` is 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. - `vaultPrefix` is optional, one folder name. - `editor` is 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. `user` is her Gitea login for the `library-human` slot (sage-2925), checked as the entry's `user` is. `editor` and its `user` come back only when given; a save without them refuses in C2-02. - `types` maps a folder name to `character`, `place`, `research`, `tracker` or `summary`. `manuscript` lists folder names and shares none with `types`. - A universe has `name`, `books` and `playground`. A hybrid adds `kind: "hybrid"`, two or more plain-universe `sources`, `constraints` (each one `{ "exclude": ... }` or `{ "pin": { "entity", "field", "value" } }`) and `notes`, and may have `books: []`. A draft universe has only `kind: "draft"`, `name` and one or more `playground` prefixes, and comes back as `{ kind: "draft", name, books: [], playground, supporting }`. `kind` is 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: 1. a string of at most 1,024 UTF-8 bytes; 2. no control character (the `editor` class), and the message leaves the path out; 3. no backslash; 4. not absolute; 5. no empty, `.` or `..` segment, which also refuses `//` and a trailing `/`; 6. `classify` gives `supporting` in that same universe, which refuses unmapped, `.canvas`, `.base` and attachment paths, canon, a playground-only folder and another universe's prefix; 7. no folder on the way, and not the path itself, is a symlink (`120000`) or gitlink (`160000`) in `tree`; 8. 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: 1. `root`: the path from the repo root. 2. `vault-prefix`: the same path with `vaultPrefix/` stripped. 3. `relative`: relative to the linking file, never above the repo root. 4. `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 ` wrote @` or ` deleted @`. 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 ```sh mosaic library survey [--mapping ] ``` 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/` 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 ` 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://': ` and `Password for 'https://@': `, 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 `/secrets//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 :refs/heads/`, 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, `///gitea`. - `humanCredential(context, { user, configDir })` gives her save its credential: her Gitea login and `/secrets//library-human`. A missing login, or a slot that is missing, a link, another uid's or not 0600, refuses `library-human-missing` before any git call. - The fetch takes the broker's choice, `libraryFetchCredential(business)`. The chief is a `roles` entry whose definition is `chief-of-staff`; a fire keeps it there. With no Gitea grant evidence for a chief, the fetch uses slot `library-gitea` and the entry's `user`. Once there is some, only the one listed chief's live, unheld grant answers, with that grant's user and store file. Anything else refuses `library-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 adds `fix: "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 ```sh 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 ` or `Mosaic: delete ` message, and the trailer `Mosaic-Agent: /`. 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.