Files

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 needs human, arbiters, projects, tracker with a sync bot and a Vikunja credential ref, and roles, 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 by checkMapping (packages/library/src/mapping.mjs). There is no top-level library key.
  • library.sync and library.status are bus verbs, human only (broker.mjs:92, :2452-2476). The only client is packages/bus/src/human-cli.mjs over 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 outside withLibraryLock. Only a per-process running set keeps two syncs apart (packages/library/src/sync.mjs:97-98), so a second process calling sync() 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 fails library-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). lastError is { code, at } with code matching ^[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 ID writes <configDir>/personas/<business>/<instance>.md at 0600 in 0700 dirs, never overwriting, and prints instance 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 ID needs <home>/.local/share/mosaic-stack/copies/<ID>, which stack-copy.sh copy COMMIT --copies DIR makes. The copy runs npm ci from 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:

  • id equals --business.
  • library: at least one library project, bus.tester not true, roles bean and chet with definition companion, and no persona.* var in the input, since D3 adds them.
  • factory: no library project, and bus.tester true. 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

  1. Refuse to run as root.
  2. scripts/bootstrap.sh. It's idempotent and never rewrites an existing config.
  3. scripts/stack-copy.sh copy <--commit> --copies <home>/.local/share/mosaic-stack/copies. The copy case2-unit.sh --copy names has to exist before root renders the units (row 195 need 3).
  4. library only: the persona import's install step from --personas DIR (D3).
  5. Write <configDir>/businesses/<id>.json at 0600 with O_EXCL (D4).
  6. library only: make <configDir>/secrets/<business>/ at 0700, empty. The token is Jason's, design 10 step 4.
  7. 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).
  8. Print the copy id and the next steps: root's 2c, Jason's 2d, 3 and 4, then the --library-status check (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 its changes.jsonl is 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's persona.sha256 (persona-mismatch). The output prints names and digests, never text.
  • No <dataRoot>/discord/*.json exists (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-missing when the mirror path doesn't exist. It checks this itself, so the message says the bus hasn't made it, not library-git;
  • calls libraryStatus(context) only, and refuses with the thrown code when that fails, for example library-git for an unsafe mirror or library-sync-busy when the flock wait runs out;
  • refuses library-never-synced when lastFetch is null;
  • refuses library-last-error <code> when lastError is 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 through JSON.stringify, then one problems <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 package and activate;
  • 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-differs and 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.tester off.
  • Install refuses: run as root, a business id mismatch, missing --personas under library, and a validate refusal, after which no business file is left.
  • N4: a re-run where the matching business file is already there and validate refuses 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.jsonl at 0644, and a Discord binding. A clean library host passes D6a with no mirror.
  • D6b, with the mirror made by ensureMirror and the state written by a real sync() 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-error with its code;
    • a synced bare repo with one R2 path prints its problem line and problems 1, and passes.

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_RELEASE seam 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 lastError refusal.
  • 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: problem prefix and a problems count.
  • 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 problem line keeps the path. After JSON.stringify the 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 because JSON.stringify leaves 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: lastError is checked before lastFetch. A first sync with no token records library-credential-missing and leaves lastFetch null, so it refuses library-last-error, not library-never-synced. The fresh-mirror test has lastError null and refuses library-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-mode when it exists with a group or other bit. It checks secrets/ and secrets/<id>/ the same way under library (secrets-mode, slot-mode).
  • --home must equal $HOME (home-mismatch). Every path comes from $HOME and MOSAIC_CONFIG, as bootstrap's does, so a different --home would 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-differs and persona-differs there, so a refused run writes nothing. After the import the script checks that the import's two instance lines 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 tip library.sync --accept would 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 ok lines 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.