With tracker.descendants on, the tracker project is the root of a tree.
Each reconcile reads the sync bot's project list (archived projects and
negative pseudo-project ids left out) and walks it by parent_project_id.
A child that passes the install checks is read through its board, and
one that doesn't is watch-only (bucket 0) and never refuses the
business. A tick reads each project in the tree. A child answering 403
or 404 leaves the tree only if GET /projects/<id> then answers 403 or
404 too, and its open tasks get the one-by-one read. Task refs carry
the project, so a task moved inside the tree shows under its new ref
and the old ref gets its tombstone. tracker.descendants reaches the adapter
through the business var and packages/cli/src/config.mjs (lead decision
87). Off, the adapter reads as v0 did.
Darkwing built it (agents/darkwing/work/queue-62), in three rounds.
Round 3 was approved by Filbert (27317) and Dewey (27338) on manifest
8b81e836, 13 paths. Their non-blocking notes and Darkwing's follow-ups
(two surviving mutants in test 18, the fake's view-404 code) go to a
follow-up row.
Sage's gate on 918a4a19, sequential and teed: node suites tasks 71,
business 60, bus 80, cli 110 and queue 150, 0 failed; webui 39 of 40,
then 40 of 40 on three reruns (row 62 touches no webui file; a timing
test under load 17 with Dewey's gate running beside it); test-auth 15,
test-conductor 17, test-config 24, test-discord 66,
test-extension-package 18, test-foundation 44, test-queue 27,
test-release 14, test-task 98, all 0 failed (Docker on). edc5de71..HEAD
touches none of the 13 paths.
Live read, 2026-10-11T21:28:03Z, tasks.woltje.com v2.7.0, root #19,
descendants on, sync key only, GETs only (the startup scope probes not
sent): reconcile 15 GETs, 42 snapshots; tick 8 GETs, 0 snapshots.
Children 18, 20 and 21 are watch-only. A missing view id on #18 answers
404 (code 3014) while #18 reads 200, as the README says. Record:
agents/sage/work/vikunja-setup/2026-10-11_tick-62.json.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
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.reassign", "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. 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>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, 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.
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.
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.