Files
stack/packages/business/README.md
T
jason.woltjeandClaude Opus 5.5 179697a8a3 bus: tester identity for PM tests (row 110, #1604)
A tester binds with a process proof from the bus's own tester-cli.mjs
when the business var bus.tester is true. It may send [mos test] messages
to a role, receive replies on the tester address and use the read verbs;
every other verb refuses. Messages store from_role tester and write no
human.input event. turnRequest() marks them as never Jason's instruction.
human.mjs is unchanged.

Candidate manifest bdaf7f476abd0b2b5095ab2f7f43e38540e6ba48ee14a02a4f4995a1469b79df
(Darkwing round 2, packet 9f1563ec1), BASE badfa52b9. Approved by Filbert
(comment 28360) and Sage (comment 28361). Sage gate rerun passed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 20:43:40 -05:00

8.8 KiB

Business

Role definitions, the business and project files, the variable registry and the resolver (slice 1 row S1, #1518). Brief: docs/plans/2026-10-04_slice-1.md, row S1. Design: agents/darkwing/work/slice1-data-model-2026-10-04.md and its addenda A and B. The package reads files and refuses on any problem. It never writes, and it never opens a token file.

scripts/mosaic business validate mosaic-stack
scripts/mosaic business resolve mosaic-stack coder --project stack
node --test packages/business/tests/

Files

File Who writes it Holds
roles/<name>.json reviewed commits a role definition: tools, network, authority, credential scopes
roles/<name>.md reviewed commits the duty contract a version 2 role file names
<configDir>/businesses/<id>.json Jason role instances, holders, bots, token references, arbiters, projects, launch limits
<root>/.mosaic/project.json reviewed commits in that project project variables, per-instance project variables

<configDir> is the directory holding the system config: the directory of $MOSAIC_CONFIG, or ~/.config/mosaic-dev. Role files come from roles/ in this repository, or $MOSAIC_ROLES_DIR if set.

Role files, version 2

{
  "roleVersion": 2, "name": "coder", "title": "Coder", "contract": "coder.md",
  "tools": ["read", "write", "edit", "bash", "grep", "find", "ls"],
  "network": "api-only",
  "authority": {
    "withinRole": ["task.update.assigned", "git.push.working", "review.request", "message.send"],
    "crossRole": ["task.scope.change"]
  },
  "credentials": [
    { "service": "gitea", "scopes": ["write:issue", "write:repository", "read:user"] },
    { "service": "vikunja", "scopes": { "tasks": ["read_one", "update"], "tasks_comments": ["create"], "projects": ["views_buckets_tasks"] } }
  ]
}
  • name matches the file name. contract is a Markdown file name in the same directory, not a path. It must exist, be a regular file and not be empty.
  • authority names actions from the closed list in src/vocabulary.mjs. withinRole actions run without asking. crossRole actions need the arbiter. Any action not listed is gated: it goes to Jason. The nine always-gated actions (deploy, spend, git.push.protected, git.merge.protected, credential.mint, message.external, policy.change, prd.approve, role.revoke) can't appear in either list.
  • Gitea scopes are read:<category> or write:<category>, one level per category. all and admin are refused. Vikunja scopes map a route group to verbs, and only the pairs in VIKUNJA_GRANTABLE pass.
  • Version 1 files (roleVersion, name, tools, network) still load. They have no authority and no credentials, and a business can't use them for a role instance.

scripts/mosaic-task.mjs resolve-role checks both versions through this package and prints MOSAIC_ROLE_CONTRACT for version 2.

Business file

examples/mosaic-stack.example.json is a complete example. As shipped it refuses on purpose: every botId is 0 and every date is YYYY-MM-DD. Copy it to <configDir>/businesses/mosaic-stack.json, put in the bot ids and token dates from docs/guides/slice-1-identities.md, fix the token paths and run validate. The file must belong to you and must not be writable by group or other. The loader checks the owner and mode on the descriptor it reads from, so the file can't be swapped between the check and the read.

Each role instance names a definition (a version 2 role file), an optional holder, its Vikunja bot (tracker, required when the definition uses Vikunja), a credential reference for each service the definition lists, and optional agent-layer vars. Several instances may share one definition. Bot names and bot ids are unique across the instances and the sync bot.

A credential reference names a token file by absolute path, or an environment variable by name, plus rotateBy for Gitea or expires for Vikunja. The checks use lstat and realpath only. A token file must be a regular file you own, mode 600 or tighter, not empty, and outside the repository, dataRoot and every project root. A Vikunja token past expires refuses. One within seven days of it, or a Gitea token past rotateBy, prints a warning.

launch lets one instance (by) start the listed instances, up to max sessions per model family (Opus 4, Sonnet 4 at most). by must name an instance whose definition holds role.launch within-role; otherwise the file refuses. Only that instance keeps role.launch. Every other instance loses it, so for them it's gated.

Chief-of-staff instance

The chief-of-staff role (roles/chief-of-staff.json, contract roles/chief-of-staff.md, lead decision 104) is the main agent that carries Jason's requests to the PM. It holds message.send within role and nothing else: no write tool, no credentials, no crossRole action, and no launch block. Its instance is mos (decision 116). Jason adds this entry under roles in his business file, next to the other instances. The stack never writes that file.

"mos": { "definition": "chief-of-staff", "holder": "mos" }

The entry has no tracker, because the role uses no Vikunja, and no credentials, because it holds none. The file must not name mos as the launch.by instance: the loader refuses that, since the role has no role.launch. packages/business/tests/chief-of-staff.test.mjs checks the role against decision 104's list and loads it through loadBusiness from a fixture business under the name chief.

Project file

{ "projectVersion": 1, "id": "stack",
  "vars": { "tracker.project": 3, "git.workingBranch": "refactor", "git.protectedBranches": ["main", "next"] },
  "roles": { "coder": { "vars": { "limits.tools": ["read", "edit", "bash"] } } } }

id must match the business file's name for the project. A missing project file is a warning in validate and an error in resolve --project.

Variables

Every key is declared once in src/vars.mjs with its type, the layers that may set it and its merge rule. An unknown key refuses, and so does a key set at a layer it doesn't belong to.

Layers, least specific first: defaults, system (the system config), business (vars), project (vars, then roles.<instance>.vars), agent (the business file's roles.<instance>.vars). For a plain key the most specific layer wins. limits.tools, limits.network and limits.authority intersect: each layer that sets one narrows it, and the result also narrows the role definition. limits.authority is an allowlist, so a role action it leaves out becomes gated.

bus.tester is business-only and defaults to false. Set to true, the bus binds a tester from tester-cli.mjs for PM tests; it reads the value at bus start, so a change takes a bus restart.

resolve prints provenance beside vars. For a plain key it's the layer that set the value (default, system, business:<id>, project:<id>, project:<id>:roles.<instance>, business:<id>:roles.<instance>). For a limit it's the list of layers that narrowed it.

API

import {
  loadBusiness, loadProject, systemVars, resolveInstance, classify,
  loadRole, validateRoleDocument, checkCredentialRef,
} from "../packages/business/src/index.mjs";

const business = loadBusiness("mosaic-stack", { rolesDir });   // frozen
const project = loadProject(business.projects.stack.root);     // frozen
const coder = resolveInstance({ system: systemVars(config), business, project, instance: "coder" });
classify(coder, "git.push.working");  // "within" | "cross" | "gated"

resolveInstance returns a frozen record: business, project, instance, definition, holder, contract (absolute path), vars, provenance, limits (tools, network, authority.withinRole, authority.crossRole), credentials (references only), tracker (bot, botId or null), launch (the launch block, or null) and digest, the SHA-256 of the record's canonical JSON. classify refuses an action outside the vocabulary. launch is set exactly when classify(record, "role.launch") is within. If limits.authority leaves out role.launch for the launcher, launch is null. A launcher should still ask classify, not read launch alone.

Errors are BusinessError with exitCode 2 (invalid) or 4 (a required file missing). The CLI adds 3 for a system config problem and 4 for usage.

Limits

  • The action vocabulary lives here, on the host. Neither workers nor container images read it, so it isn't under contracts/.
  • The checks run as you, against files you can change. They catch mistakes; they don't stop someone who already runs as your user.
  • A token file can change between validate and the moment it's opened. The code that opens it at run time repeats these checks (rows S2 and S3).
  • Slice 1 knows two services and one tracker kind. A new one is a reviewed change to src/vocabulary.mjs and src/vars.mjs.