Files
stack/agents/darkwing/work/slice1-s1/build.md
T
jason.woltjeandClaude Opus 5.5 b851f83c39 docs(slice1): row 36 S1 build packet, v3a trigger count correction (darkwing)
S1 candidate for Filbert's review on #1518: build.md, build.patch and
build-manifest.sha256 (34 files at base fef4b362). The candidate itself
is not committed.

proto-v3a-notes-correction: the schema has 36 triggers; the printed 35 is
counted after the tamper check's DROP TRIGGER. Found by Rocko.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-04 22:28:30 -05:00

7.9 KiB

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=<absolute path> 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/<name>.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 <dir>/ 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.