Files

20 KiB

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/"] }
  }
}
  • 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.

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 <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, 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

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.