Files
stack/packages/business/README.md
T
jason.woltjeandClaude Opus 5.5 2d64c71eb2 feat(business): roles v2, business and project files, variable layers (row 36, S1, darkwing)
Darkwing's round 2 candidate, approved by Filbert (#1518 comment 26730).
build-r2.patch a27890d5, manifest 869168c7, 34 files, applied on HEAD and
checked 34/34. Integration gate on an export of HEAD plus the patch:
business 60/60 on Node 24 and 26, every package test and every
scripts/test-*.sh green, test-task 98/98 with the live-provider cases.
Conductor, queue, conversation and discord confirmed in git worktrees of
HEAD with and without the patch, identical results. Lead decision 63
accepts the vocabulary location, the example path and the business
branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 17:09:07 -05:00

167 lines
7.7 KiB
Markdown

# 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.
```sh
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
```json
{
"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"] } }
]
}
```
- `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.
### Project file
```json
{ "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
```js
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`.