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

383 lines
29 KiB
Markdown

# 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@<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:
> 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 | `[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.