# Row 190 design: several Mosaic Stack instances on one host Brief: `docs/plans/2026-10-20_multi-instance.md`, blob 4e8f1e052894 (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@`, `mosaic-webui-NAME`, `mosaic-discord-NAME@`. 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 `mosaic-bus@.service`; 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/mosaic-discord@.service.in`, `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 `/.config/mosaic-NAME/config.json`, where `` 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 `/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), `/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 (`/bus/writer.lock`) still refuses a second broker on one root. ### Q1, for Mos: the AGENTS.md invariant 2 wording Today: > 2. **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): > 2. **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 | `mosaic-bus@.service`, `%i` = business | `mosaic-bus-NAME@.service`, `%i` = business | | Console | `mosaic-webui.service` | `mosaic-webui-NAME.service` | | Discord template | `mosaic-discord@.service`, `%i` = binding | `mosaic-discord-NAME@.service`, `%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=`, 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 `/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@`: `mosaic-bus-NAME@.service.d`, `mosaic-bus-NAME@.service.d` 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 `mosaic-bus-NAME@.service`, `mosaic-webui-NAME.service`, `mosaic-bus-NAME@.service.d`, `mosaic-bus-NAME@.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 `mosaic-bus-NAME@x.service`'s prefixes by cutting at hyphens, which never yields `mosaic-bus@.service.d` 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@`, `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 "/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 `/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 `/secrets/mosaic-stack/`, the same rule Q175 uses. Business credential files are absolute paths in the business file, which lives in `/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 `` 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= 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.