Files
stack/packages/business
jason.woltjeandClaude Opus 5.5 fba879f6a1 mosaic plugins set: a patch changes only the kinds it names; null removes a kind (vikunja:19/309, row 145)
Jason-approved merge, rebased: git diff f4d7b91a9..0e7d1d8b8 from
work/vikunja-19-309, stacked on 305. Business reviewer verdict dfd44af2
approve on 0e7d1d8b8; Jason resolved merge decision 12a20e47 via the CLI.

No conflict. packages/plugins/README.md, which refactor also changed,
merged with the source commit's +/- lines; the other four paths are the
source blobs. Filbert reviews the rebased diff.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 17:50:59 -05:00
..

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. models maps selection keys to exact {provider, model} pairs; each role vars.model selects a key. limits accepts optional total, providers: {provider: count} and models: {provider: {model: count}}. Counts are whole numbers of zero or more with no policy ceiling; absent means unlimited and zero stops new launches. The example starts with four Opus and four Sonnet sessions. The old max shape requires print-only mosaic limits migrate before the new copy can load it. 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.

launch.threadIdleSeconds (optional, row 121) is how long a thread's session runs with nothing pending and no new message or open before the launcher stops it. Absent means 900 (THREAD_IDLE_SECONDS in src/business.mjs). A set value is a positive integer with no upper bound; anything else refuses the file. The key is accepted whether or not the file declares a thread instance. A thread instance is one whose definition is assistant. The launcher starts it once per thread, when the thread has a pending message or was opened, and never on its own.

plugins (optional) switches on plug-in modules, one key per kind from packages/plugins/src/kinds.mjs, each a list of module names matching ^[a-z][a-z0-9-]*$ with no name twice. An unknown kind or a bad name refuses the file. Absent means nothing is switched on. Whether a named module exists and loads is the plug-in loader's check, at load time; see packages/plugins/README.md.

Chief-of-staff and assistant instances

Two roles stand above the PM (PRD 1.2; row 119, #1613).

The assistant role (roles/assistant.json, contract roles/assistant.md, REQ-ASSIST-1) is the user's main agent: one per user per organization. It talks with the user and passes work to the chief of staff. It holds message.send within role and nothing else: no credentials, no crossRole action, read-only tools. It doesn't direct agents, create or assign tasks, or launch sessions. The broker's send limits for it are row 120 (REQ-ASSIST-2).

The chief-of-staff role (roles/chief-of-staff.json, contract roles/chief-of-staff.md, REQ-COS-1) runs the organization: it hires and terminates role instances, owns the structure, takes work from the assistant and never talks to the user. It holds message.send and role.launch within role (decision 139), plus role.end and organization.change (row 123): it ends a run with a reason and no decision (role.revoke stays gated for every role), and it changes the organization file through the broker's organization.set, limited to the keys the human's limits.set takes; the harness's end_run and change_organization tools call them (row 132). It has no credentials and the same read-only tools. It is the only shipped role that holds role.launch, so it is the only role launch.by can name. The PM holds no role.launch (REQ-LAUNCH-1); it asks the chief of staff for a session.

The owner adds both instances under roles in the business file, next to the others, and points launch.by at the chief of staff. Initial role entry and schema migration installation are human steps; later launch settings edits use the shared, broker-backed organization writer. The instance names are the owner's choice; these are the example file's:

"chief": { "definition": "chief-of-staff", "vars": { "model": "opus" } },
"assistant": { "definition": "assistant", "holder": "mos", "vars": { "model": "opus" } }
...
"launch": { "by": "chief", "instances": ["coder", "reviewer"], "models": {
  "opus": { "provider": "anthropic", "model": "claude-opus-5-5" },
  "sonnet": { "provider": "anthropic", "model": "claude-sonnet-5-5" }
}, "limits": { "models": { "anthropic": { "claude-opus-5-5": 4, "claude-sonnet-5-5": 4 } } } }

Neither entry has a tracker or credentials, because neither role holds any. A session that launches needs a harness and a model, so give each instance vars (harness, model) as the PM has, unless the system's execution settings cover it. A launch.by that names a PM, an assistant or any other instance whose role lacks role.launch is refused. A file that still names the PM fails to load with the shipped roles, so edit the file before a copy with these roles goes live. packages/business/tests/chief-of-staff.test.mjs and assistant.test.mjs check each role against its approved list and load it through loadBusiness; role.test.mjs loads every file in roles/ through the validator.

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.

Organization writes

src/organization.mjs is the trusted host write module. The caller supplies a verified actor and the SHA-256 revision read earlier. It validates the whole document, and again as the bytes it will write read back as JSON, so a value JSON can't carry (a Date, a Map) refuses instead of landing as something else; changes.jsonl logs those bytes. It refuses stale revisions and concurrent writers, keeps exact previous bytes under businesses/.history/<id>/<transaction>/previous.json, then publishes a fsynced temporary file by atomic rename. changes.jsonl records prepared and committed transactions with actor, hashes and old/new values per changed field. Write-time recovery compares prepared hashes with current bytes; uncertain or torn evidence refuses organization writes. Bus startup, Settings reads and launches keep using the whole validated file on disk, without consulting receipts. A stale writer lock also refuses: an operator must confirm that its old writer is gone before clearing it; the host does not race another recovering process by deleting locks automatically. Settings reads never repair evidence themselves. launch-settings.mjs restricts human edits to limits, model table and instance model selections and validates effective selections before publication. plugin-settings.mjs is the only writer of plugins: the host calls it, and no broker verb, mosaic command or Settings form reaches it. A refused plugins write throws invalid-plugins or invalid-request before the writer runs and logs nothing. Migration uses none of this: it only prints a converted document (decision 145).

For inherited selections, print-only migration reads the existing system config through its normal validator and requires an exact provider/model match. Missing or invalid config refuses; it never changes the system config. Explicit role models retain their external model and use the human-supplied provider table.