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"] } }
]
}
namematches the file name.contractis a Markdown file name in the same directory, not a path. It must exist, be a regular file and not be empty.authoritynames actions from the closed list insrc/vocabulary.mjs.withinRoleactions run without asking.crossRoleactions need the arbiter. Any action not listed is gated: it goes to Jason, or to the arbiter for its domain when the raise cites the project's current spec commit (PRD 1.5 REQ-DEC-2). The nine hard waits inGATED_ONLY(REQ-WAIT-1:spend,data.delete,network.expose,account.create,credential.mint,message.external,git.push.protected,git.push.remote,deploy) always go to Jason and can't appear in either list (REQ-WAIT-2).git.push.remotejoined in row 199 (#1697).GATED_ONLYis every action thatROUTEDdoesn't name, so an action added to the vocabulary is a hard wait until a PRD says otherwise and the commit adds it toROUTED(REQ-WAIT-3).git.merge.protected,policy.change,prd.approveandrole.revokeleft the list in 1.5 (row 139, #1633).- Gitea scopes are
read:<category>orwrite:<category>, one level per category.allandadminare refused. Vikunja scopes map a route group to verbs, and only the pairs inVIKUNJA_GRANTABLEpass. - 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.account (optional, row 126) is {name, credential}: the account
the business's Claude Code launches spend, an identifier, and the
environment variables of one login set joined with + (for example
CLAUDE_CODE_OAUTH_TOKEN), never a value. Which sets exist is the
launcher's check. launch.limits.tokens.daily maps an account to
{limit, since, reset, timeZone}: reset is HH:MM, timeZone an IANA
name Intl accepts. A daily quota for any name but launch.account.name
refuses the file, since it would count nothing.
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
(no shipped role is granted role.revoke, so it stays gated), 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). Under Jason's ruling Q169 (row 155, #1649) the chief of
staff is the one agent that ends the assistant, which ends every assistant
thread run, and it configures the assistant through assistants (Assistant
settings, below) and names instances through set_name. PRD 1.7 REQ-COS-3
(row 194) grants it role.hire and role.fire within role: it hires and
fires with a reason and no decision, through the broker's instance.hire and
instance.fire, which the harness's hire and fire tools call. 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.
Security-reviewer and merge-gate instances
Two roles finish the coding workflow next to coder and reviewer
(REQ-ROLE-5; row 149, #1643).
The security-reviewer role (roles/security-reviewer.json, contract
roles/security-reviewer.md) reviews a candidate for security only. It
holds review.verdict within role and nothing else: read-only tools
(read, grep, find, ls), no shell, no write tools, no credentials. The
verdict reaches the role that asked through review.verdict itself, so the
role needs no message.send. Without it the runner's automatic reply to
a message is refused; the verdict still arrives.
The merge-gate role (roles/merge-gate.json, contract
roles/merge-gate.md) lands approved work. Its one grant is
git.merge.protected as a cross-role action, the grant row 137's land op
checks. It raises the merge decision itself with domain technical,
because only the raising instance and thread can use an approval
(REQ-DEC-5), and lands once the technical arbiter resolves it. It has the
same read-only tools and no credentials.
Both declare messaging lists. The technical arbiter must be able to
message merge-gate, or the raise is refused (row 133): the shipped lists
include cto, so a business whose technical arbiter is another
definition has to change merge-gate's lists by a reviewed commit. Because
of those lists, neither role can be named in arbiters; the business
file is refused.
No verb adds a role instance. organization.set's instances only sets
the model of an instance the business file already declares
(launch-settings.mjs), so the human adds both entries by editing the
business file, then runs validate. No shipped business declares either
role yet; adding them to a live business is a separate step with its own
go. The entries are:
"merge-gate": { "definition": "merge-gate" },
"security-reviewer": { "definition": "security-reviewer" }
packages/business/tests/workflow-roles.test.mjs checks both against
their approved lists, the arbiter refusal and the proactive line in every
contract; packages/bus/tests/workflow-roles.test.mjs checks the merge
route and the verdict path on the broker.
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.
A managed session's route is three agent vars (row 65, REQ-PLUG-2):
harness names a harness plug-in module (the loader's name pattern),
model is a launch.models key, and thinking is one of off,
minimal, low, medium, high, xhigh and max. Whether the module
exists, is switched on and applies the level is checked at launch and at a
write, never at load. fallback.harness, fallback.model and
fallback.thinking state a second route; a layer sets all three or none,
and a partial set refuses. No layer falls back to the system's
execution.adapter or execution.model, which stay for run-task.sh.
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
validateand 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.mjsandsrc/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, instance
model selections, routes and assistant settings, and checks every route 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).
Routes (row 65, #1558, mos-0840)
launch-settings.mjs also has the routing table's read and write. The bus
reaches them through the human-only verbs routes and routes.set. Neither
is in ORGANIZATION_KEYS, so organization.set and the chief of staff's
change_organization can't send a routes key. Through limits.set the
chief changes an instance's model through instances, and the assistant's
whole vars through assistants (Q169). Both writes run the same
checkRoutes.
routes(id) returns the revision, the switched-on harnesses
(plugins.harness), the launch.models keys, levels (each harness the
file names or switches on, with the levels its module applies, or null for
a module that didn't load) and one entry per instance: harness, model,
thinking (null when unset) and fallback ({harness, model, thinking}
or null). setRoutes(id, args, actor) takes:
{ "revision": "<64 hex>", "routes": { "coder": { "harness": "pi", "model": "glm-5.3-flash", "thinking": "high", "fallback": null } } }
Each named instance's route vars are replaced in one write, and a null
fallback clears all three fallback vars. It refuses, writing and logging
nothing:
| Code | When |
|---|---|
invalid-request |
a key other than revision and routes, a bad revision, an empty routes, or a route without exactly harness, model, thinking and fallback as strings |
BusinessError (the bus answers invalid-request) |
the next document fails validateBusinessDocument: a level no harness has, a harness name outside the pattern, or a model that isn't a launch.models key |
organization-unknown-instance |
an instance the file lacks |
organization-partial-fallback |
a fallback that isn't null and isn't exactly three strings |
organization-no-change |
the file already says that |
unknown-model |
a fallback.model that isn't a launch.models key |
harness-off |
a changed route's harness isn't in plugins.harness |
harness-refused |
a changed route's harness module didn't load |
thinking-unsupported |
a changed route's module doesn't apply its level |
revision-conflict |
the file changed since the read |
checkRoutes is shared: instances.set and limits.set run it too, so no
write of the route vars skips it. It checks a route only when the write
changes its harness or thinking, so a model change still saves while
plugins.harness is unset, and that instance's launch refuses
harness-off instead. The model check covers every set model and
fallback model. A route var
the instance doesn't set is left to the launch, which refuses no-route.
The bus host passes levels from harnessLevels (packages/bus); a writer
built without it answers null for every harness.
Assistant settings (row 155, #1649)
Jason's ruling Q169 lets the human and the chief of staff configure the
assistant. The update key is assistants, next to limits, models and
instances:
{ "revision": "<64 hex>", "assistants": { "assistant": { "vars": { "model": "opus" }, "tracker": null } } }
- The key maps an instance to any of
vars,trackeranddefinition. A given field replaces that field of the entry,tracker: nullremoves the tracker, and a field left out stays. An empty entry isinvalid-request. - The instance must exist (
invalid-request) and its definition must beTHREAD_DEFINITIONfromvocabulary.mjs, or the write refusesorganization-not-assistant. - A given
definitionmust beTHREAD_DEFINITIONtoo, ororganization-definition-refused. Today that makes it a no-op, so a change can't strip the protectionEND_PROTECTEDgives the assistant. holderandcredentialsaren't accepted; they stay hand edits.- After applying every key,
setrefusesorganization-definition-refusedif any instance's definition differs from the file's, so no key gives an instance anEND_PROTECTEDdefinition after a decision about it was resolved. - The whole next document still goes through
validateBusinessDocumentand the model check, the same as a hand edit.
read returns assistants as well: each assistant instance's definition,
vars ({} when absent) and tracker (null when absent). The human sends
the patch with mosaic limits set --file or the Console's Assistant section;
the chief of staff sends it with change_organization. The chief of staff
reads the revision with organization.revision (row 134), which returns the
revision alone.
changes.jsonl logs the actor: the human's id, or chief-of-staff.<instance>.
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.
Role instances (row 165, #1662)
Q171 and REQ-ROLE-3 let the human add a role instance and change an existing
one's definition, vars and launch membership without a hand edit.
src/instance-settings.mjs is that writer. The bus reaches it through the
human-only verbs instances and instances.set. Neither is in the broker's
ORGANIZATION_KEYS, so organization.set and the chief of staff's
change_organization can't reach it (sage-0918).
read returns the file's revision, each instance (definition, vars,
launched, launcher, protected), whether the file has a launch block, and
each definition in the roles directory with the services its credentials name
and whether add and set accept it. set takes:
{ "revision": "<64 hex>", "instance": "sec-1", "op": "add", "definition": "security-reviewer", "vars": {}, "launch": true }
addneedsdefinition;changeneeds at least one ofdefinition,varsandlaunch.varsreplaces the instance's vars whole.launchadds the instance tolaunch.instancesor removes it. Any other key isinvalid-request, soholder,credentialsandtrackerstay hand edits. There is no delete.- An instance named
humanortesterrefuses withinvalid-request. The broker refuses a business whose roles use either name when it loads, so such an add would stop the bus at its next start (row 165 r2). - A definition that declares credentials refuses with
organization-credentials-out-of-scope(the brief's named refusal, sage-0922). So does a definition change on an instance whose definition declares them, because its credentials would bind to the new definition's authority. The writer never writes an instance without its binding. A vars or launch change on such an instance succeeds and leavescredentialsandtrackeras they are. - An
END_PROTECTEDdefinition, given or held, refuses withorganization-definition-refused; the assistant changes throughassistantsabove. organization-instance-exists,organization-unknown-instance,organization-launch-not-configuredandorganization-no-changerefuse what their names say.- The whole next document goes through
validateBusinessDocument, the route checks (checkRoutes, under "Routes" above), and each project file's id and instance resolution (organization-project-mismatch,organization-unresolvable-instance), the checksmosaic business validateruns. - The organization writer then refuses a stale revision
(
revision-conflict) and logs the change with the actor.
A refusal writes nothing and logs nothing. The answer is the read with the
transaction and restart. restart is true after an add or a definition
change: the running broker keeps the roles it loaded at start. The writer
doesn't restart anything. The CLI (mosaic business instance add|set) and the
Console Settings page send the patch.