19 KiB
Row 226 build notes, C2-13b install script and library precheck (#1727)
Brief: docs/plans/2026-10-20_case2-rows.md "C2-13b install script and library precheck" (:1073-1134 at
d37210288). Design: agents/gosalyn/work/case2/DESIGN.md r11 sections 4 and 10, on gosalyn/case2-design
75fb3e10e. Row 195's needs: agents/darkwing/work/queue-195/DESIGN.md "What row 195 needs from row 226".
This is design round d2. Nothing is built yet. d1 was f46e66883, and Webby's review of it is webby-0085
(CHANGES, B1 and N1-N6) and webby-0086 (the ordering problem). Sage ruled on both in sage-2189. The
changes from d1 are listed at the end.
Base
Branch gosalyn/c2-13b from refactor d37210288. Rows 215, 221, 222 and 230 are done.
Facts the design rests on
- No CLI writes a business file.
mosaic business validate <id>checks one and exits 0 ok, 2 invalid (packages/business/src/cli.mjs). A business file needshuman,arbiters,projects,trackerwith a sync bot and a Vikunja credential ref, androles, each role with its tracker bot when its definition uses Vikunja (business.mjs:23, :84-100, :131-149). - The library is a project,
projects.<id>.kind === "library", checked bycheckMapping(packages/library/src/mapping.mjs). There is no top-levellibrarykey. library.syncandlibrary.statusare bus verbs, human only (broker.mjs:92, :2452-2476). The only client ispackages/bus/src/human-cli.mjsover the bus socket.- The bus makes each library mirror at start,
ensureMirror({ business, project, dataRoot })(packages/bus/src/runtime.mjs:143-145). Before the bus first starts there is no mirror. sync()fetches outsidewithLibraryLock. Only a per-processrunningset keeps two syncs apart (packages/library/src/sync.mjs:97-98), so a second process callingsync()would race the bus's timer.libraryStatus(context)reads the recorded state under the flock and writes nothing (mirror.mjs:232-239). A missing or unsafe mirror failslibrary-git(ownedRoot, mirror.mjs:43-49). A mirror that has never synced returns the empty state,lastFetch: null,lastError: null,problems: [](mirror.mjs:27, :205-210).lastErroris{ code, at }withcodematching^[a-z-]{1,64}$(mirror.mjs:225).- The bus builds the library context as
{ business, project, dataRoot }and the slot as<slotDir>/secrets/<business.id>/library-gitea(runtime.mjs:146-151, :162-164). case2-persona-import.mjs install --staging DIR --config-dir DIR --business IDwrites<configDir>/personas/<business>/<instance>.mdat 0600 in 0700 dirs, never overwriting, and printsinstance bean: persona.name Bean, persona.sha256 <hex>per persona.- A Discord binding is
<dataRoot>/discord/<name>.json(packages/discord/src/binding.mjs:70-83). case2-unit.sh --copy IDneeds<home>/.local/share/mosaic-stack/copies/<ID>, whichstack-copy.sh copy COMMIT --copies DIRmakes. The copy runsnpm cifrom package-lock.json (stack-copy.sh:70), which installs the pinned pi.
Design
D1. The business file is an input
Unchanged from d1. case2-install.sh takes --business-file FILE, a JSON business document written for
the host from Jason's go. The script doesn't build one from flags, because bots, botIds and credential
refs are host values and the brief says every host value is an input. It checks the input, adds what
only it knows, writes it, and then runs mosaic business validate, so business.mjs stays the only
schema.
What the script checks before it writes anything, beyond validate:
idequals--business.library: at least one library project,bus.testernot true, rolesbeanandchetwith definitioncompanion, and nopersona.*var in the input, since D3 adds them.factory: no library project, andbus.testertrue. These are the precheck's rules at case2-precheck.sh:127-135, so the install refuses what the precheck would refuse.
D2. Install steps, in order
- Refuse to run as root.
scripts/bootstrap.sh. It's idempotent and never rewrites an existing config.scripts/stack-copy.sh copy <--commit> --copies <home>/.local/share/mosaic-stack/copies. The copycase2-unit.sh --copynames has to exist before root renders the units (row 195 need 3).libraryonly: the persona import'sinstallstep from--personas DIR(D3).- Write
<configDir>/businesses/<id>.jsonat 0600 with O_EXCL (D4). libraryonly: make<configDir>/secrets/<business>/at 0700, empty. The token is Jason's, design 10 step 4.mosaic business validate <id>. If it refuses and step 5 created the file in this run, remove that file, then refuse. A file that already matched on a re-run (D4) is never removed (N4).- Print the copy id and the next steps: root's 2c, Jason's 2d, 3 and 4, then the
--library-statuscheck (D6b).
d1's step 3, release.sh package and activate, is gone (Q4a, webby-0085 B1, sage-2189). Package is a
docker compose build, and activate runs a pi Compose health check that mounts PI_AUTH_FILE
(compose.yaml:43). Neither fits 2a, which has no Docker and no pi credential, and the hub doesn't run
containers. The pinned harness comes from the copy's npm ci (Q3). The CASE2_RELEASE seam goes with
it.
It starts no unit, writes no unit file, doesn't write /etc/mosaic-stack/dev.args and doesn't run
adopt. These are my answers to row 195's needs 4 and 5 (gosalyn-0087), unchanged. Row 195's W8 now
passes --business-file and has no release step.
D3. Personas come from the staging dir
Unchanged from d1. library requires --personas DIR, the staging dir from case2-persona-import.mjs stage. The install runs case2-persona-import.mjs install --staging DIR --config-dir <configDir> --business <id> and reads its two instance lines into roles.bean.vars and roles.chet.vars. Only
those two lines go into the business file. They hold a name and a digest, never text.
This moves the persona import's stage part of design 10 step 6 ahead of 2b: the read of her Hermes profiles and Jason's check of the printed pairing. Webby agreed in webby-0085 (Q1).
D4. Re-runs
Unchanged from d1, plus N4. Each step is safe to run again. bootstrap and the copy are idempotent. An
existing business file that matches the one the script would write passes, and the script remembers it
didn't create it. One that differs refuses business-file-differs, and the script doesn't touch it.
The persona import already refuses to overwrite. On a re-run the install compares each existing
persona file's sha256 with the staging manifest, and refuses on a mismatch.
D5. Inputs and seams
case2-install.sh --business ID --business-file FILE --commit SHA [--profile factory|library]
[--personas DIR] [--home DIR]
--home defaults to $HOME, which is /srv/mosaic under sudo -u mosaic -H. MOSAIC_CONFIG is
honoured as bootstrap.sh honours it. One test seam, in case2-unit.sh's CASE2_* pattern:
CASE2_STACK_COPY is the copy script, and the test uses a stub that makes the copy dir.
Exit codes: 0 ok, 1 refused, 4 usage, as case2-unit.sh uses.
D6. Precheck library checks, split in two (Q4b, sage-2189)
d1 ran sync() from the precheck. Webby's N1 and webby-0086 showed two faults. The precheck runs before
the units start, so there is no mirror yet (runtime.mjs:143-145). And a precheck sync() after the bus
is up would race the bus's timer, since sync() fetches outside the lock (sync.mjs:97-98). Sage split
the check in sage-2189, and the brief's "runs library.sync" becomes "reads the bus's recorded sync
state".
D6a. Before the units start, --profile library
These run where the precheck runs today, after 2c's render and before the start. They sync nothing and
read no mirror. Each is a refuse line in the script's style.
- The business file is owned by the uid, with no group or other bit (
business-file-mode).businesses/.history/<id>/is 0700 and itschanges.jsonlis 0600, when present (history-mode). - The persona dirs are 0700. Each creative role's persona file is regular, owned and 0600
(
persona-mode), and its sha256 equals the role'spersona.sha256(persona-mismatch). The output prints names and digests, never text. - No
<dataRoot>/discord/*.jsonexists (discord-binding).
D6b. case2-precheck.sh --business ID --library-status, after the bus is up and after step 4
A new mode, run as <user> once the bus is up and Jason has placed the token. It changes nothing. A
node block builds the context the bus builds, { business, project, dataRoot } for each library
project, with dataRoot from the config (N3, runtime.mjs:146-151, :162-164). For each one it:
- refuses
library-mirror-missingwhen the mirror path doesn't exist. It checks this itself, so the message says the bus hasn't made it, notlibrary-git; - calls
libraryStatus(context)only, and refuses with the thrown code when that fails, for examplelibrary-gitfor an unsafe mirror orlibrary-sync-busywhen the flock wait runs out; - refuses
library-never-syncedwhenlastFetchis null; - refuses
library-last-error <code>whenlastErroris not null. The code is the fixed^[a-z-]{1,64}$string, so it's safe to print. This also covers Webby's N2; - prints each recorded R-problem as
problem R<n> <path>, with the path throughJSON.stringify, then oneproblems <count>line (N5). Her file names are the only part that prints, never text. R-problems don't refuse (Q2, agreed by Webby).
The bus syncs each library with a user every 60 s, so the first run after step 4 may need a minute,
or Jason's own library.sync. The run sheet says so.
D7. host-run-sheet.md fix
docs/guides/host-run-sheet.md:240 says "I2's install writes it" about dev.args. It becomes: root
writes it at the unit render, from the values the go names, and the install never writes it, because
<user> can't write /etc and root reads nothing <user> wrote. Sage asked in sage-1832 for this fix
to go in this build commit, with its path in the manifest. Section 9's library-gitea line (:275) says
"C2-13b's library.sync (not landed)". It becomes the --library-status check (D6b). The install
section also gets a line that the copy's npm ci needs the npm registry from the host (Q3, webby-0085).
D8. Design 10 text
Sage ruled that design 10 gets the same change when 226 lands, with the line in this row's manifest. In agents/gosalyn/work/case2/DESIGN.md:
- step 2b drops
release.sh packageandactivate; - a new step after step 4 runs
case2-precheck.sh --business <business> --library-status; - the precheck's library text says it reads the bus's recorded sync state, not that it runs
library.sync.
D9. Tests, packages/cli/tests/case2-install.test.mjs
These follow case2-unit.test.mjs: scratch HOME, MOSAIC_CONFIG, stub bin dir and seams, a local bare repo
cloned --bare --no-local as sync.test.mjs does, and the fixture's key never in output.
- Install
library:- writes the business file at 0600 with bean's and chet's persona vars;
- writes the persona files at 0600 in 0700 dirs and the empty slot dir at 0700;
- logs the copy call and no release call;
- starts nothing;
- passes on a re-run;
- refuses
business-file-differsand leaves the file unchanged.
- Install
factory:- writes no library mapping, no persona file and no slot dir, which is the brief's case;
- refuses a library project and
bus.testeroff.
- Install refuses: run as root, a business id mismatch, missing
--personasunder library, and avalidaterefusal, after which no business file is left. - N4: a re-run where the matching business file is already there and
validaterefuses leaves that file in place, byte for byte. - D6a refusals, one case each: a persona file at 0644, a digest mismatch, a business file at 0644, a
changes.jsonlat 0644, and a Discord binding. A clean library host passes D6a with no mirror. - D6b, with the mirror made by
ensureMirrorand the state written by a realsync()against the local bare repo, as the bus would:- no mirror refuses
library-mirror-missing; - a fresh mirror refuses
library-never-synced; - a sync against a remote that doesn't resolve refuses
library-last-errorwith its code; - a synced bare repo with one R2 path prints its
problemline andproblems 1, and passes.
- no mirror refuses
Every new refusal gets a mutant that removes it. The count goes in the slot request.
D10. Files
Owned by the brief: scripts/case2-install.sh (new), scripts/case2-precheck.sh (library checks and
--library-status only) and packages/cli/tests/case2-install.test.mjs (new). Also
docs/guides/host-run-sheet.md (D7, sage-1832), agents/gosalyn/work/case2/DESIGN.md (D8, sage-2189)
and this BUILD.md. Before the slot request I'll grep for outside readers of case2-precheck.sh, since
case2-unit.test.mjs runs it.
Questions
All three d1 questions are settled:
- Q1, persona stage before 2b: Webby agreed (webby-0085).
- Q2, R-problems don't refuse: Webby agreed.
- Q3, the pinned harness is the copy's
npm ci: Webby agreed, and asked for the registry note in D7. - Q4a and Q4b: Sage, sage-2189.
One choice for Webby to check: D6b's problem line carries the path through JSON.stringify. N5 asked
for a prefix and a count. I kept the path because Jason needs it to tell her which file to fix, and the
names are hers, not text.
Changes from d1
- B1: release package and activate dropped, with the
CASE2_RELEASEseam and its test (D2, D5). - N1 and webby-0086: the sync is gone from the precheck. D6 splits into D6a before the start and D6b,
--library-status, after the bus is up and step 4 (sage-2189). - N2: covered by D6b's
lastErrorrefusal. - N3: D6b builds the context and slot as the bus does.
- N4: step 7 removes only a file this run created, with a test.
- N5:
problemprefix and aproblemscount. - N6: the run sheet lines are :240 and :275.
- New D8, the design 10 text change, and its path in D10.
Build
Webby approved d2 in webby-0087 (REVIEW-design-d2.md, webby/reviews 2f488b4ea). Nothing above this
section changed after approval. This section records the build.
D8 lands on case2-design
Sage ruled in sage-2195, after Webby's N1, that D8 goes on gosalyn/case2-design, because
agents/gosalyn/work/case2/DESIGN.md isn't on refactor. The change is commit 624212842 there, pushed:
step 2b drops release.sh package and activate, new step 4a runs --library-status, and the C2-13b
row text says the precheck reads the bus's recorded sync state. DESIGN.md is not in this row's manifest.
The brief's lines docs/plans/2026-10-20_case2-rows.md:1093-1096 still name release package, activate and a library.sync fetch. Sage owns the brief and will amend those lines to match D8 in a doc-only commit after the re-pin window (sage-2205). They are not in this row's manifest.
Webby's d2 notes
- N2: the
problemline keeps the path. AfterJSON.stringifythe precheck also escapes DEL, the C1 controls U+0080-009F, U+200E and U+200F, U+2028 and U+2029, and the bidi controls U+202A-202E and U+2066-2069 as\uXXXX. Webby named C1 and bidi. I added the rest becauseJSON.stringifyleaves them raw too, and each one can change how a terminal shows the line. The test path holds U+202E and U+0085, and the test checks that neither prints raw. - N3:
lastErroris checked beforelastFetch. A first sync with no token recordslibrary-credential-missingand leaveslastFetchnull, so it refuseslibrary-last-error, notlibrary-never-synced. The fresh-mirror test haslastErrornull and refuseslibrary-never-synced.
Choices made in the build
- The config dir. The persona import refuses a config dir with group or other bits, and bootstrap makes
one with the default mode. So the install makes the config dir at 0700 when it's absent, before
bootstrap, and refuses
config-dir-modewhen it exists with a group or other bit. It checkssecrets/andsecrets/<id>/the same way under library (secrets-mode,slot-mode). --homemust equal$HOME(home-mismatch). Every path comes from$HOMEandMOSAIC_CONFIG, as bootstrap's does, so a different--homewould only name a second home the script doesn't use.- A plan step runs before anything is written. It reads the input and the staging manifest, computes
the persona vars from the manifest, and compares the result with what's on disk. It refuses every
input rule,
business-file-differsandpersona-differsthere, so a refused run writes nothing. After the import the script checks that the import's twoinstancelines carry the manifest's digests. - A re-run with both persona files present and matching skips the import, which would refuse to
overwrite them. One matching file and one missing refuses
persona-differs. - A diverged library prints one line under
--library-status, naming the tiplibrary.sync --acceptwould take. It doesn't refuse. The design doesn't list it as a refusal, and the bus still serves the old tip. - The precheck's library checks print
oklines for each check they pass, as the rest of the script does. A business file that's missing prints no extra line, since the existing business-file check already refuses it. - The business id must match the persona import's rule,
^[a-z0-9][a-z0-9-]{0,63}$. That's narrower than the precheck's, which also takes.and_, because the persona dir uses the id.
Outside readers
git grep over packages//tests/, packages//src/ and scripts/test-*.sh finds case2-precheck.sh read
by case2-unit.test.mjs, which runs it, and case1-accept.test.mjs, which names a copy's path but runs a
fake. No test reads host-run-sheet.md. No test pins a hash of either script. case2-unit.test.mjs's
precheck host has a 0600 business file and no persona vars, so the new D6a checks pass there.
Mutants
24, in /mnt/storage/scratch/gosalyn-r226/mutate.py. Each replaces one exact string once. Line numbers are the candidate's. run.sh stops with BASE RED before any mutant unless the base run is rc=0 and fail 0.
| Mutant | Line | Check removed |
|---|---|---|
| I1 | install :73 | run-as-root |
| I2 | install :74 | home-mismatch |
| I3 | install :81 | config-dir-mode |
| I4 | install :101 | business-id-mismatch |
| I5 | install :107 | profile-library-business |
| I6 | install :108 | bus-tester-off |
| I7 | install :111 | bus-tester-on |
| I8 | install :115 | persona-var-input |
| I9 | install :158 | business-file-differs |
| I10 | install :152 | persona-differs |
| I11 | install :218 | N4, removes the file even when this run didn't write it |
| I12 | install :218 | validate's removal of the file this run wrote |
| P1 | precheck :229 | business-file-mode |
| P2 | precheck :235 | history-mode for changes.jsonl |
| P3 | precheck :246 | persona-mode for a persona file |
| P4 | precheck :251 | persona-mismatch |
| P5 | precheck :241 | persona-mode for a persona dir |
| P6 | precheck :258 | discord-binding |
| P7 | precheck :104 | library-mirror-missing |
| P8 | precheck :107 | library-last-error |
| P9 | precheck :107 | N3, the order swapped |
| P10 | precheck :108 | library-never-synced |
| P11 | precheck :92 | N2, the escape after stringify |
| P12 | precheck :65 | --library-status refuses --profile |
history-mode for the history dir itself has no mutant and no test. The brief names only the
changes.jsonl case.
Tests
Not run yet. They wait for a slot.