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]>
167 lines
7.7 KiB
Markdown
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`.
|