# Row 36, S1: roles v2, business and project files, variable layers Darkwing, 2026-10-04. Issue #1518, reviewer Filbert. Brief: `docs/plans/2026-10-04_slice-1.md`, section "Slice 1 S1". Design: `agents/darkwing/work/slice1-data-model-2026-10-04.md` sections 1 and 2, addendum A sections 7 and 8, addendum B section 2. Nothing in the candidate is committed, staged or pushed. Base is fef4b362 (origin/refactor at build time). In a fresh clone at that commit the patch applies, the result matches the manifest 34/34, and `node --test packages/business/tests/` passes 57/57 in three runs. ## Files `build.patch` (sha256 `8067882052abc6e75684b318f738ef990f53c7a35913a8313a341282d259bd94`) changes 4 files and adds 30. `build-manifest.sha256` (sha256 `d934e5af30cd646194bbf1d9bffc2b4d51486a4bb4c097ec43376352830b0195`) pins all 34 after the patch. New: - `roles/pm.json`, `cto.json`, `coder.json`, `reviewer.json`: version 2 definitions. `roles/pm.md`, `cto.md`, `coder.md`, `reviewer.md`: their duty contracts (Duties, Authority, Protocol). - `packages/business/src/`: `vocabulary.mjs` (the closed lists), `role.mjs` (versions 1 and 2), `vars.mjs` (the key registry and merge), `credentials.mjs` (reference parse and stat checks), `business.mjs`, `project.mjs`, `resolve.mjs` (`resolveInstance`, `classify`), `cli.mjs`, `util.mjs`, `errors.mjs`, `index.mjs`. - `packages/business/tests/`: 57 tests in 7 files plus `helpers.mjs`. - `packages/business/examples/mosaic-stack.example.json`: the business file for Sage's SR template. It refuses as shipped (every `botId` is 0, every date `YYYY-MM-DD`); a test proves it validates once filled in. - `packages/business/README.md`, `package.json`. Changed: - `scripts/mosaic-task.mjs`: `validateRole` delegates to the package, so both versions share one validator. `resolve-role` also prints `MOSAIC_ROLE_CONTRACT=` for a version 2 role. - `scripts/test-task.sh`: 8 new checks (version 1 prints no contract line; the four shipped roles resolve with their contracts; a minimal version 2 role passes; unknown action, gated-only action, a Vikunja verb no role may hold, a missing contract and a linked contract each exit 2). - `scripts/mosaic`: a `business` branch, like `queue`. This is outside the brief's file list. The brief asks for a TOOLS entry for any new command, and a command needs an entry point; the change is four lines. - `docs/TOOLS.md`: a section for `scripts/mosaic business`, and the `resolve-role` row now says it takes versions 1 and 2. ## Does the vocabulary go under `contracts/`? No. `contracts/` is baked into the worker image because workers read it. Nothing in a worker reads the action vocabulary: the broker classifies actions on the host, and the role files are read on the host by the launcher and the broker. Baking it in would give the image a copy that nobody checks against. The vocabulary lives in `packages/business/src/vocabulary.mjs`, and a change to it is a reviewed commit, like a role file. `Containerfile` copies `package.json`, `contracts`, `src` and `adapters` only, so neither `packages/` nor `scripts/mosaic-task.mjs` is in the image, and the new import doesn't reach it. No contracts change, so no review by Sage for one. ## Choices and deviations from the design 1. `contract` is a file name in the role file's own directory (`"coder.md"`), not the note's `"roles/coder.md"`. A path relative to the repository breaks under `MOSAIC_ROLES_DIR`, which `agent.sh` and the tests use for sandbox role directories. The validator refuses a name with a slash, a non-`.md` name, a missing, empty or linked file. 2. pm authority adds three actions to the note's table (section 1.2): - `task.update.assigned` (within): addendum A section 2 says all four roles update the tasks assigned to them, and the note's pm row left it out. - `task.reassign` (within): addendum A's action table gives reassign to pm, cross-role for the others. - `task.scope.change` (cross): pm splits and rewrites tasks, and a scope change on one in technical work needs the CTO, the technical arbiter. Filbert, push back if you read it differently. 3. The business file must belong to the user and not be group or other writable. It holds authority, so a file someone else can edit would let them widen it. Missing file: exit 4. A linked file: exit 4. 4. Bot names and bot ids are unique across role instances and the sync bot. Two instances sharing one Vikunja bot would make the tracker's record of who did what wrong. 5. `role.launch` stays within-role only for the instance the business file's `launch.by` names. Everywhere else the resolver removes it, so `classify` returns gated. With no `launch` block nobody launches. 6. `limits.authority` is an allowlist. A role action it doesn't name becomes gated. It can't add an action the definition lacks. 7. Credential checks use `lstat` and `realpath` only. A test makes a token file mode 0200 (write-only) and the check still passes, which shows the package never opens it. A token inside the repository, `dataRoot` or a project root refuses, also through a linked directory. Vikunja: past `expires` refuses, within 7 days warns. Gitea: past `rotateBy` warns; Gitea tokens don't expire on their own. An `env` reference that isn't set warns, because the launcher sets it. 8. Lookups use `Object.hasOwn`. Ids may be any lowercase name, and `constructor` is one, so `roles[name]` would have treated it as declared. Tests cover arbiters, launch, the model family and a label titled `__proto__`. 9. `validate` treats a declared project with no project file as a warning ("absent"). `resolve --project` treats it as a missing file, exit 4. When a project file exists, `validate` resolves every instance against it, so a project file naming an undeclared instance refuses there. 10. Error text for version 1 roles now comes from the package. Every existing `test-task.sh` case still passes; only wording changed. 11. The package says "role file" for `roles/.json`, so it doesn't share a name with the Markdown contract. `mosaic-task.mjs` and `agent.sh` keep their old wording ("role contract"); I didn't touch those messages. ## Tests and suites Node 26.8.1 on the host, outputs teed: - `node --test packages/business/tests/`: 57/57. Files: role 12, vars 6, credentials 6, business 14, project 2, resolve 8, cli 9. - `scripts/test-config.sh` 24/24, `scripts/test-task.sh` 98/98, `scripts/test-conductor.sh` 17/17, `scripts/test-queue.sh` 27/27 (`scripts/mosaic` changed). Node 24.21.0 in the `node:24` image, no network, the clone mounted read-only: `node --test "packages/business/tests/*.test.mjs"` 57/57. The glob is needed because Node 24 reads `node --test tests/` as a module path and fails. That is true of every package in the repository; I checked `packages/queue` the same way. Follow-up for Sage, not part of S1. Mutants, each run against the full test set and each caught: the forbidden-root check off (3 failures), the `role.launch` gate off (1), the token mode mask widened to other only (1), `hasOwn` dropped from the instance lookup (2), the business file mode check off (1), limit intersection replaced by overwrite (2). The CLI tests point `MOSAIC_CONFIG`, `HOME` and `MOSAIC_ROLES_DIR` into a temp directory. No test reads the real `~/.config`. ## For other rows - S2 (Rocko) consumes `loadBusiness` and `resolveInstance`. The frozen API is in `packages/business/README.md`, section "API". - S3 (mine) opens token files, so it repeats the stat checks at open time and trims one trailing newline. - S6 decides how a launcher passes `MOSAIC_ROLE_CONTRACT` to the session. `agent.sh` reads only `MOSAIC_ROLE_TOOLS` today and ignores the new line. ## Follow-ups, not in this candidate - `node --test /` fails on Node 24 in every package (above). - `scripts/mosaic-task.mjs` keeps its own `SUPPORTED_TOOLS` for tasks. The package has the same list in `TOOLS`. One should import the other in a later change.