Files
jason.woltje 158205c5cc lead: commit darkwing's untracked evidence (348 files: queue-137 queue-141 queue-150 queue-162 queue-165 queue-166 queue-167 queue-169 queue-179 queue-180 queue-181 queue-182 queue-185 queue-186 queue-187 queue-188 queue-190 queue-93)
Unclaimed files left in the canonical checkout. Inventory: canonical-cleanup-inventory.md (commit-as-evidence groups). detect-secrets scan clean on 2026-11-02: hits are sha256 pins, git object ids, test sentinels already in tracked tests and unit-file lines.
2026-11-02 15:05:14 -06:00

29 KiB

Row 190 design: several Mosaic Stack instances on one host

Brief: docs/plans/2026-10-20_multi-instance.md, blob 4e8f1e0528 (commit 48ebee0ff, rev 2259). Line 16 now limits isolation to addressing on one uid and points to row 195 (190b). Gitea #1688. MVP T1 item 1 (mos-0974, DECISIONS Q176-Q178). Owner Darkwing, reviewer Filbert, gate owner Sage. Base: da230ca48. Worktree: /mnt/storage/scratch/darkwing-r190, branch darkwing/row190.

Mos ruled Q1 in mos-0986 (2026-10-20T18:53:48Z). O1 is approved with one added sentence, and R1 is ranked required, as row 190b. See "Rulings" at the end. Revision 2 of this file adds that sentence and the section. Revision 3 answers Filbert's review of revision 2 (B1, B2, N1-N6). "Review rounds" at the end lists each change.

Summary

  • One carrier. Inside every process, the instance is the config file that MOSAIC_CONFIG names, or the default config when it is unset. This is how the code works today. No package learns an instance name.
  • --instance NAME exists only on the shell entry points that name units, ports or wrappers, or that create a config. NAME maps to ~/.config/mosaic-NAME/config.json. The default instance is dev, which maps to today's ~/.config/mosaic-dev/config.json. With no flag, everything is byte-identical to today.
  • Units: mosaic-bus-NAME@<business>, mosaic-webui-NAME, mosaic-discord-NAME@<binding>. The default keeps mosaic-bus@, mosaic-webui and mosaic-discord@.
  • A data root is claimed by a marker file that bootstrap writes. A tool aimed at one instance refuses a data root that carries another instance's marker, or no marker at all for a named instance.
  • Copies stay shared. The pointer, activation log, backups, rollback sets and rehearsals already live under $DATA, so they become per instance with no new code.
  • One limit holds through all of this. Every instance runs as the same Unix user. That separates instances by addressing: no Mosaic command aimed at one names a file, unit or port of another. It is not an OS permission boundary. R1 covers what that means for Melanie's instance.

Inventory

Every single-instance point I found in scripts/ and packages/*/src, outside tests, at da230ca48. The grep covered .config/mosaic-dev, mosaic-dev/config.json, MOSAIC_CONFIG, .mosaic-dev, dataRoot, the unit names, 7330/7332/7333, claude-session.env, BUSINESS=, COPIES and secrets. About 55 files read dataRoot from the loaded config. Those are already per instance and are not listed.

# Where What is single-instance Change
F1 scripts/mosaic-config.mjs:41-45, :183 default config path and default dataRoot ~/.mosaic-dev --instance NAME on bootstrap, validate, env; new op instance (D1)
F2 scripts/bootstrap.sh runs mosaic-config.mjs bootstrap with no instance passes --instance NAME through (D2)
F3 packages/cli/systemd/mosaic-bus.service.in:24 fixed EnvironmentFile=-%h/.config/mosaic-dev/secrets/claude-session.env, no MOSAIC_CONFIG two placeholders that render to today's bytes for the default (D3)
F4 scripts/bus-service.sh:25-26, :102-118, :177 unit name [email protected]; busy and drop-in scans glob mosaic-bus@* --instance; names and globs from D3
F5 packages/webui/systemd/mosaic-webui.service.in, scripts/webui-service.sh:68-69, :85, :274-279 one unit mosaic-webui.service; port default 7332; bus_on_copy globs mosaic-bus@* --instance; a named instance must pass --port; port refusal (D4)
F6 packages/discord/systemd/[email protected], scripts/discord-service.sh:20-21, :153 one template; status reads ${XDG_CONFIG_HOME}/mosaic-dev/config.json and ignores MOSAIC_CONFIG --instance; status reads the instance's config
F7 scripts/stack-copy.sh:8, :95 BUSINESS=mosaic-stack; check reads mosaic-webui.service and $BIN/mosaic check --instance; wrapper --instance (D5)
F8 scripts/stack-release.sh:31, :416-425, :533-546, :607-628, :1478 CONSOLE_UNIT, mosaic-bus@ in capture, restore, busy check and drop-in scan; wrapper $BIN/mosaic names from D3 and D5
F9 scripts/stack-release.sh:72, :99, :1405-1414 tester port refuses 7332 only; rehearse reserves 7332, 7333 and the pointer's port; LIVE_CFG default reserved set from D4; LIVE_CFG follows --instance
F10 packages/conversation/src/guard.mjs:47-55, :111 the live-session guard protects ~/.mosaic-dev and the default config's dataRoot only also protects each named instance's config dir, ~/.mosaic-NAME and its config's dataRoot, for names that pass the D1 rule (D7)
F11 packages/business/src/business.mjs:26, packages/seat/src/seat.mjs:47, packages/discord/src/binding.mjs:54 MOSAIC_CONFIG or the default none: already follow the carrier
F12 packages/control-board/src/scan.mjs:30 default config only, --config PATH overrides none: the control board is dev tooling, and it already takes --config
F13 scripts/reset.sh:2-4 refuses any target but /home/jwoltje/.mosaic-dev none: reset stays default-only, and refuses a named instance
F14 scripts/land-work.sh:99, scripts/conductor-apply.sh the conductor pipeline's config dir none: dev pipeline, not an instance
F15 packages/business/src/business.mjs:23-27 (configDir) the business files and secrets sit beside the config none: per instance once the config is
F16 copies ${XDG_DATA_HOME}/mosaic-stack/copies in four scripts one copies directory shared on purpose (D5)

One naming collision to know about. "Instance" already means a role instance in packages/business (resolveInstance, b.roles keys). That word stays out of the packages. The new flag lives only in shell scripts and mosaic-config.mjs. The docs say "stack instance" wherever a reader could confuse the two.

D1. Instance identity

  1. Name rule: ^[a-z][a-z0-9]{1,15}$. No hyphen. With a hyphen, systemd's prefix drop-ins (mosaic-bus-.service.d and so on) would gain a level per hyphen, and the guard scans would no longer be a fixed list. A hyphen would also let one name be a prefix of another's unit.

  2. Mapping: NAME's config is <home>/.config/mosaic-NAME/config.json, where <home> is os.homedir() / $HOME, as mosaic-config.mjs uses today, not XDG_CONFIG_HOME. dev is the default instance, so --instance dev is the same as no flag, byte for byte. The mapping never produces ~/.config/mosaic (v1 and scripts/foundation use that).

  3. Selection, in every shell entry point that takes it:

    --instance MOSAIC_CONFIG Result
    absent absent default, exactly today
    absent set that config, with default unit and wrapper names: today's behavior, and what every existing test does
    NAME absent NAME's config, and the entry point exports MOSAIC_CONFIG to everything it runs
    NAME set allowed only when both resolve (realpath) to the same file; otherwise exit 4, instance-config-mismatch
  4. Node code takes no flag. scripts/mosaic, the bus host, the Console, the Discord connector, the task runner and the human CLI follow MOSAIC_CONFIG as they do now. A human reaches a named instance through its wrapper mosaic-NAME (D5) or by setting MOSAIC_CONFIG.

  5. The mapping lives in one place: mosaic-config.mjs instance [--instance NAME] prints shell-safe exports. They are MOSAIC_INSTANCE, MOSAIC_CONFIG, MOSAIC_CONFIG_DIR and MOSAIC_UNIT_SUFFIX (empty for the default, -NAME otherwise). A sourced scripts/instance.sh turns those into BUS_TEMPLATE, BUS_GLOB, CONSOLE_UNIT, DISCORD_TEMPLATE, WRAPPER_NAME and SESSION_ENV. Every entry point uses that file, and row 191 can too.

  6. Refusals: a bad name exits 4 (instance-name-invalid). For a named instance, a missing config exits 3 with the bootstrap hint, as today. Data-root claims are D2.

D2. Config canon and bootstrap

Bootstrap: scripts/bootstrap.sh --instance NAME runs mosaic-config.mjs bootstrap --instance NAME. It:

  1. creates ~/.config/mosaic-NAME/ (mode 0700) and the config with wx, as today. It never overwrites; an existing config is validated and left alone;
  2. writes the default dataRoot ~/.mosaic-NAME. The operator may change it before first use. The default instance keeps ~/.mosaic-dev;
  3. claims the data root. It creates the root if absent (0700) and writes <dataRoot>/instance.json, {"instanceVersion":1,"name":"NAME"}, with wx, mode 0644. It refuses if the root exists and is not empty and holds no marker of its own name. An existing config whose root lacks the marker is a refusal, not a repair.

The claim check runs in scripts/instance.sh. Every entry point that takes --instance sources that file on every run, flag or no flag, so the check covers all four rows of the D1 table. It runs once the config has loaded, before the entry point reads or writes anything else. A missing config keeps today's refusal. bootstrap is the one exception, because it makes the claim (step 3 above).

  • With --instance NAME (D1 rows 3 and 4), <dataRoot>/instance.json must be a regular, non-symlink file naming NAME. Otherwise exit 3, instance-root-unclaimed or instance-root-mismatch.
  • With no flag (D1 rows 1 and 2), the selection takes the default's unit and wrapper names, so the marker must be absent or name dev. Otherwise exit 3, instance-root-mismatch, naming the marker's instance and the --instance flag to use. Row 2 is where this matters: MOSAIC_CONFIG=~/.config/mosaic-a/config.json scripts/stack-release.sh activate ID with no flag would otherwise drive a's data root through the default's units and $BIN/mosaic. With the check, it refuses before any unit is read or called.
  • No marker exists anywhere today, and the existing tests' scratch configs carry none, so rows 1 and 2 behave exactly as they do now.

This blocks two configs from sharing one data root through the shell tools. It does not stop a hand-run MOSAIC_CONFIG=... scripts/mosaic bus start from a copy, but the bus store's writer lock (<dataRoot>/bus/writer.lock) still refuses a second broker on one root.

Q1, for Mos: the AGENTS.md invariant 2 wording

Today:

  1. Configuration: ~/.config/mosaic-dev/config.json is the sole system config — created only by scripts/bootstrap.sh, never overwritten, fail-closed on any problem. Repo-scoped role authority lives in roles/*.json (versioned, reviewed commits only).

Proposed (O1, my recommendation):

  1. Configuration: each stack instance has exactly one system config. The default instance's is ~/.config/mosaic-dev/config.json. Instance NAME's is ~/.config/mosaic-NAME/config.json, and its data root carries a marker that names it. ~ is the home of the OS user that runs the instance. A process reads only the config MOSAIC_CONFIG names, or the default's when that is unset. Configs are created only by scripts/bootstrap.sh [--instance NAME], never overwritten, fail-closed on any problem. Repo-scoped role authority lives in roles/*.json (versioned, reviewed commits only).

The "Data map (canon)" line in AGENTS.md changes to match: "~/.config/mosaic-dev/config.json, or ~/.config/mosaic-NAME/config.json per named instance: system config (user-authored; never auto-written)".

Alternatives:

  • O2: named instances under the default's directory, ~/.config/mosaic-dev/instances/NAME/config.json. That keeps one config root, but the default's config dir would then contain every other instance's businesses and secrets. Anything allowed into the default's config dir reaches all of them. I recommend against it.
  • O3: no canon change, MOSAIC_CONFIG only, no names. Unit names, ports and wrappers then have nothing to key on, so the brief's units point can't be met.

AGENTS.md is Sage's file. The row edits only those two lines, and only with the wording Mos rules.

D3. Units

Default (unchanged) Instance NAME
bus template [email protected], %i = business [email protected], %i = business
Console mosaic-webui.service mosaic-webui-NAME.service
Discord template [email protected], %i = binding [email protected], %i = binding
wrapper $BIN/mosaic $BIN/mosaic-NAME

The templates gain two placeholder lines:

  • @CONFIG_ENV@. For the default, render deletes the line. For NAME, it becomes Environment=MOSAIC_CONFIG=<abs config path>, held to the plain character set the scripts already enforce.
  • In the bus template, EnvironmentFile=-@SESSION_ENV@. For the default this renders as %h/.config/mosaic-dev/secrets/claude-session.env, today's text. For NAME it is <abs config dir>/secrets/claude-session.env.

Default renders therefore stay byte-identical to the base template's. A test (T-I8) compares them. The Console unit's comment "The unit sets no MOSAIC_* ... variable" gains "except MOSAIC_CONFIG for a named instance". The Description lines get [NAME] for named instances only.

The scans follow the names:

  • Busy checks glob mosaic-bus@*.service for the default, as today, and mosaic-bus-NAME@*.service for NAME. Neither glob matches the other's units, so a running bus in one instance never blocks or reaches the other.
  • The drop-in scan (bus-service.sh:118, stack-release.sh:628) covers bus units only, today and in this row. For a named instance it adds the bus directories that systemd applies to mosaic-bus-NAME@<business>: mosaic-bus-NAME@<business>.service.d, [email protected] and the shared prefix mosaic-bus-.service.d. The existing mosaic-.service.d and service.d scans stay. Since mosaic-bus-.service.d applies to every named bus, each named instance's scan refuses an ExecStart override there.
  • Console and Discord drop-in scans stay out of scope, as they are today: no script scans mosaic-webui*.service.d or mosaic-discord*.service.d, and this row adds no scan for them. That includes the shared prefixes mosaic-webui-.service.d and mosaic-discord-.service.d, which systemd applies to every named Console and Discord unit. I'll record it as a follow-up.

Shared prefix directories are never instance state. The directories systemd applies to more than one instance are mosaic-bus-.service.d, mosaic-webui-.service.d, mosaic-discord-.service.d, mosaic-.service.d and service.d. Capture and restore never save or rewrite any of them. For NAME, the rollback set holds [email protected], mosaic-webui-NAME.service, [email protected], mosaic-bus-NAME@<business>.service.d, mosaic-webui-NAME.service.d and $BIN/mosaic-NAME, and nothing else. The default's set stays as it is (stack-release.sh:416-425). None of its directories applies to a named unit: systemd derives [email protected]'s prefixes by cutting at hyphens, which never yields [email protected] or mosaic-webui.service.d. So one instance's rollback can't rewrite a directory another instance's units read.

The human-proof rule still holds per instance. Jason or Mos starts each Console. Nothing here adds a start verb.

D4. Ports

  • A named instance's Console has no default port. webui-service.sh install|render --instance NAME without --port exits 4. The default keeps 7332.
  • Reserved for named instances: 7330 (the operator's bare Console), 7332 (the tunnel) and 7333 (the default's tester). The existing rule that no tester takes 7332 stays for every instance.
  • Collision refusal. webui-service.sh install and stack-release.sh activate read every installed Console unit in UNIT_DIR: mosaic-webui.service (no --port there means 7332) and mosaic-webui-*.service. They take each unit's --port and --tester-port. If this instance's port or tester port equals any of them, the run exits 1 or 3 with port-claimed, naming the unit that holds the port. stack-release.sh rehearse adds those ports to its reserved list.
  • Unit files are host configuration, not another instance's state, so reading them keeps the D7 rule that each instance reads only its own config and data root. No instance reads another's pointer.
  • The claim covers installed units only. An instance that is bootstrapped but has no Console unit yet holds no port, so a second instance can take the port first. The loser's install then refuses port-claimed, which is safe. Nothing reserves a port at bootstrap.
  • A port another process already holds still fails at bind, as today (serve exits 2).

D5. Copies, pointer and wrapper

  • Copies stay shared, one ${XDG_DATA_HOME}/mosaic-stack/copies. They are read-only trees named by full commit id, and nothing prunes them (decision 96 condition 4). Any future prune must read every instance's pointer and log. This row doesn't add one.
  • stack-release.sh takes --instance NAME. Its DATA, STATE, POINTER, LOG, backups, rollback sets, post-attempt directories and rehearsals all come from the loaded config's dataRoot (:1473-1477). They are per instance once the config is. flags_drift reads the pointer and this instance's unit only. Capture and restore (:416-425, :533-552) use the D3 names and $BIN/$WRAPPER_NAME.
  • stack-copy.sh check --instance NAME checks mosaic-bus-NAME@<business>, mosaic-webui-NAME and $BIN/mosaic-NAME. --business stays required for a named instance. BUSINESS=mosaic-stack stays the default's default.
  • The named-instance wrapper. stack-copy.sh wrapper ID --instance NAME writes $BIN/mosaic-NAME through a temp file and a rename. Line 2 is a marker, # mosaic-cli wrapper, instance NAME (row 190). The body sets MOSAIC_CONFIG to NAME's config, then runs exec "<copy>/scripts/mosaic" "$@". It has no Q81 lease line: that probe belongs to the default. It creates the file when absent, replaces a file with its own marker, and refuses any other file. The default wrapper code is unchanged.
  • Q175 C1 applies per instance. A named instance's pointer can roll back to a copy from before Q175, and that copy refuses a config with a console key, the same trap the default has. Nothing new handles it here.

D6. Secrets

  • The Claude session env file is <configDir>/secrets/claude-session.env, set by the bus unit (D3). The default's stays %h/.config/mosaic-dev/secrets/claude-session.env.
  • The secrets slot root is <configDir>/secrets/mosaic-stack/, the same rule Q175 uses. Business credential files are absolute paths in the business file, which lives in <configDir>/businesses/. The operator puts each instance's tokens under its own config dir.
  • Nothing in this row reads, creates or lists a secret file. Bootstrap creates <configDir> at 0700 and no secrets/ directory.

D7. Isolation proof (tests)

All tests use scratch roots under TMPDIR and the stand-in systemctl, journalctl and systemd-analyze from packages/cli/tests/stack-release.test.mjs. HOME is a scratch directory, so ~/.config/mosaic-* resolves inside it. None of them touches ~/.mosaic-dev, ~/.config/mosaic-dev, a live unit or a slot file. New tests go in stack-release.test.mjs (T55, so they share mk), plus a new packages/cli/tests/instances.test.mjs for the config and bootstrap cases and the two real hosts.

T-I5 binds two bus sockets under the scratch roots. The run keeps TMPDIR under ~/darkwing-scratch, within the 107-byte socket path limit (the row 177 lesson), and BUILD.md states the scratch path's length.

The guard (F10) is the one stated exception to "each instance reads only its own config and data root". It reads, and never writes, dataRoot from each ~/.config/mosaic-NAME/config.json whose NAME passes the D1 rule, so that a session can't write into another instance's root. The glob mosaic-* is filtered by the name rule. On this host it matches only mosaic-dev: mosaic, mosaic.bak and mosaic.bak1 don't match. An unreadable config adds nothing, as at guard.mjs:49-54. So, as with .mosaic-dev at :111, the guard also protects ~/.mosaic-NAME and ~/.config/mosaic-NAME/ for each name it finds, whatever that config says. A broken config for b can't drop b's default root from the guard.

Test Shows
T-I1 bootstrap bootstrap --instance a and --instance b create mosaic-a and mosaic-b configs, roots and markers. A second bootstrap changes no byte. A non-empty unmarked root refuses, including an existing non-empty ~/.mosaic-worktrees under --instance worktrees (the name passes the rule, so the claim is what stops it). Names A, a-b, x, a 17-character name and ../x exit 4. --instance dev writes exactly what no flag writes
T-I2 selection the four rows of the D1 table, including the mismatch refusal and the realpath case that agrees
T-I3 render bus, Console and Discord renders for a and b carry their own MOSAIC_CONFIG, EnvironmentFile and unit names, and no other instance's path
T-I4 ports a --port 7340, then b --port 7340, refuses port-claimed. So do b --tester-port 7340, b with no --port, and b --port 7332. b --port 7341 passes
T-I5 side by side two real bus hosts (scripts/mosaic bus start --no-launch) from the worktree, one per scratch config, both up at once. Each bus status answers from its own socket under its own root
T-I6 no cross-reach activate and rollback on a, with b fully installed: b's units, drop-ins, wrapper, pointer, log, store and backups are byte-identical before and after, and so are the shared prefix directories mosaic-bus-.service.d, mosaic-webui-.service.d and mosaic-discord-.service.d, each seeded with a .conf file. a's rollback set holds none of them. calls.log names no b unit. An inotify read watch (the row 187 T53 watch) on b's config dir and data root counts zero events. Then the same with a and b swapped
T-I7 claims a's config pointing at b's root refuses instance-root-mismatch. A named root with no marker refuses instance-root-unclaimed. A default config whose root carries a's marker refuses. D1 row 2: MOSAIC_CONFIG=<a's config> stack-release.sh activate C1 and bus-service.sh install, both with no flag, refuse instance-root-mismatch with exit 3. calls.log names no unit, and no unit, wrapper, pointer or store changes. A scratch config with no marker, run with no flag, passes as today
T-I8 default unchanged default renders of all three units equal the da230ca48 templates' renders byte for byte. stack-release.sh --plan, status and stack-copy.sh check print the same text from the base script and the candidate in one fixture. The existing stack-release tests pass unchanged
T-I9 busy scoping b's bus active does not refuse a's activate, and a's own bus active does
T-I10 guard LiveSessionGuard refuses a path inside a named instance's config dir or data root. With b's config corrupt, it still refuses a path in ~/.mosaic-b and in ~/.config/mosaic-b/. A directory ~/.config/mosaic.bak adds nothing

Mutants

Each one must fail at least one test above. They are run against the candidate, the script restored and its sha256 checked after each, as in rows 187 and 188.

# Change Expected killer
M1 instance.sh ignores NAME and returns the default names T-I3, T-I6
M2 the port-claimed check skipped T-I4
M3 the marker check skipped T-I7
M4 the bus render drops the MOSAIC_CONFIG line for a named instance T-I3
M5 SESSION_ENV always the default path T-I3
M6 the named busy glob widened to mosaic-bus*@*.service T-I9
M7 the default render keeps an empty line where @CONFIG_ENV@ was T-I8
M8 capture saves $BIN/mosaic for a named instance T-I6
M9 --instance and a different MOSAIC_CONFIG accepted T-I2
M10 the name pattern accepts a hyphen T-I1
M11 bootstrap --instance opens the config without wx T-I1
M12 the guard drops the named instances' roots T-I10
M13 the claim check runs only when --instance is given T-I7 (D1 row 2 case)
M14 capture and put_back include mosaic-bus-.service.d for a named instance T-I6
M15 the guard drops the unconditional ~/.mosaic-NAME and keeps only the config's dataRoot T-I10 (corrupt-config case)

Scope and boundaries

  • In the row: F1-F10 code, the three templates, scripts/instance.sh, the tests, the package READMEs, and the two AGENTS.md lines in Mos's ruled wording (O1 plus the ~ sentence), nothing else in that file. docs/TOOLS.md gets the --instance text, written by me as part of the manifest (sage-1335).
  • Not in the row:
    • standing up a real second instance (a later Q173 window, with a backup first and a STATE report after);
    • any tunnel or Traefik exposure (Jason, after Q175 SSO);
    • SSO, users and RBAC (T1 items 2 and 3);
    • the kill switch (row 191).
  • No re-pin needed. The default's units, paths and ports don't change, and the live copy keeps running its own templates until a later activate. At that activate the default renders are byte-identical (T-I8).

Risks

  • R1. Same Unix user. Every instance runs as jwoltje. A role session or a hand-run process in one instance can read another's files if it names them. This row only makes sure no Mosaic tool aimed at one instance names another's files, units or ports. For Melanie's instance, the real boundary is SSO plus RBAC (T1 items 2-3) for the Console. Mos ranked the stronger separation as required (mos-0986): row 190b, chained on this row, puts Melanie's hub under its own Linux user. This row stays on one uid.
  • R2. The marker check guards the shell tools only (D2). A hand-run MOSAIC_CONFIG=... scripts/mosaic from a copy skips it. The writer lock still stops two brokers on one root. Filbert's N5 offered a fix: the shared loader could check the marker whenever the config path is a canonical ~/.config/mosaic-NAME/config.json. I'm leaving it out. Q175 C1 showed the cost of a shared-loader change, since every old copy refuses what it doesn't know, and a rollback lands on old copies. Row 190b, with its own uid, is the real boundary for the hub.
  • R3. stack-release.sh is 1490 lines with many name literals. M1 and M8 plus T-I6 are there to catch a missed literal. Before in-review I'll grep the candidate for every bare mosaic-bus@, mosaic-webui and $BIN/mosaic and list each remaining one with its reason.

For row 191 (Rocko)

The resolution contract to build on:

  1. Inside processes, MOSAIC_CONFIG, or the default when unset, through the existing loaders.
  2. All state goes under that config's dataRoot.
  3. Unit names come from scripts/instance.sh: BUS_TEMPLATE, BUS_GLOB, CONSOLE_UNIT, DISCORD_TEMPLATE and WRAPPER_NAME. Never from literals.
  4. Shell entry points take --instance NAME. That name may change if Mos rules O2 or O3 on Q1.

Rulings

mos-0986 (2026-10-20T18:53:48Z), on revision 1 (sha256 3a6479764283...):

  • Q1: O1 is approved, with one sentence added to the invariant: "~ is the home of the OS user that runs the instance." The proposed wording in D2 now carries it. The D1 mapping already uses os.homedir(), so an instance under another uid needs no more code in this row. The roles line and the Data map line change as proposed. O2 and O3 are rejected.
  • R1: Mos ranks it required, not a follow-up (federation review B5, duckworth-0001; mos-0984 and mos-0985). A separate uid per instance is a hard precondition of federation R8 and of Case 2 row C2-13. This row lands as designed, on one uid, so scratch two-instance tests run without root. Row 190b (queue row 195, Gitea #1693, reviewer Duckworth), chained on this one and owned by Darkwing, designs Melanie's hub under its own Linux user.

Review rounds

Filbert, on revision 2 (sha256 5493c9df3ec0..., 2026-10-20T18:56:34Z): CHANGES, B1 and B2. Revision 3:

  • B1. D2 now says the claim check runs on every row of the D1 table, flag or no flag, with bootstrap the one exception. With no flag, the marker must be absent or name dev. T-I7 gains the D1 row 2 case (a's config with no flag, through stack-release.sh activate and bus-service.sh install): exit 3 instance-root-mismatch, and calls.log names no unit. A marker-less scratch config still passes. M13 (the check runs only with --instance) dies on that case.
  • B2 (1). D3 now says the drop-in scan covers bus units only, today and in this row. Console and Discord drop-in scans stay out of scope, including their shared prefixes, as a recorded follow-up.
  • B2 (2). D3 lists the shared prefix directories and says capture and restore never save or rewrite them. It also gives the exact rollback set for NAME. T-I6 seeds mosaic-bus-.service.d, mosaic-webui-.service.d and mosaic-discord-.service.d and checks them byte for byte. M14 (capture includes mosaic-bus-.service.d) dies there.
  • N1. D7 states the guard as the one read-only exception. The glob is filtered by the name rule. ~/.mosaic-NAME and ~/.config/mosaic-NAME/ are protected whatever the config says. T-I10 gains the corrupt-config and mosaic.bak cases, and M15 checks the unconditional root.
  • N2. D4 says installed units are the only claim.
  • N3. D7 states the TMPDIR rule, and BUILD.md will give the path length.
  • N4. T-I1 gains the ~/.mosaic-worktrees case.
  • N5. R2 records the loader option and why I'm leaving it out.
  • N6. D5 has the one line on Q175 C1 per instance.
  • N7 is Sage's brief line. Nothing changes here.