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]>
This commit is contained in:
2026-10-05 17:09:07 -05:00
co-authored by Claude Opus 5.5
parent d1e633b220
commit 2d64c71eb2
34 changed files with 2825 additions and 25 deletions
+204
View File
@@ -0,0 +1,204 @@
// The business file, <configDir>/businesses/<id>.json (REQ-ROLE-3, note
// section 2, addendum A sections 7 and 8). Jason writes it; the stack reads
// it and never writes it. A missing or invalid file refuses (lead decision
// 46, 6.1).
import { dirname, isAbsolute, join, normalize } from "node:path";
import { homedir } from "node:os";
import { refuse } from "./errors.mjs";
import { LAUNCH_CEILING } from "./vocabulary.mjs";
import { checkVars } from "./vars.mjs";
import { parseCredentialRef } from "./credentials.mjs";
import { loadRole } from "./role.mjs";
import {
requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze,
} from "./util.mjs";
const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch"];
// The config directory: next to the system config, $MOSAIC_CONFIG or
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
export function configDir(env = process.env) {
const file = env.MOSAIC_CONFIG || join(homedir(), ".config", "mosaic-dev", "config.json");
return dirname(file);
}
export function businessFilePath(id, dir = configDir()) {
requireId(id, "business id");
return join(dir, "businesses", `${id}.json`);
}
function checkBot(tracker, where) {
requireObject(tracker, where);
rejectUnknownKeys(tracker, ["bot", "botId"], where);
if (typeof tracker.bot !== "string" || !BOT_NAME.test(tracker.bot)) refuse(`${where}.bot must be a Vikunja bot username starting with "bot-"`);
requirePositiveInt(tracker.botId, `${where}.botId`);
return { bot: tracker.bot, botId: tracker.botId };
}
function checkCredentialMap(map, services, where) {
requireObject(map, where);
const names = Object.keys(map).sort();
const wanted = [...services].sort();
if (names.join(",") !== wanted.join(",")) {
refuse(`${where} must reference exactly the services its role definition needs (${wanted.join(", ") || "none"}; got ${names.join(", ") || "none"})`);
}
const out = {};
for (const service of names) out[service] = parseCredentialRef(map[service], service, `${where}.${service}`);
return out;
}
function checkTracker(tracker, file) {
const where = `${file} tracker`;
requireObject(tracker, where);
rejectUnknownKeys(tracker, ["sync", "labels"], where);
requireObject(tracker.sync, `${where}.sync`);
rejectUnknownKeys(tracker.sync, ["bot", "botId", "credentials"], `${where}.sync`);
const sync = {
...checkBot({ bot: tracker.sync.bot, botId: tracker.sync.botId }, `${where}.sync`),
credentials: checkCredentialMap(tracker.sync.credentials, ["vikunja"], `${where}.sync.credentials`),
};
let labels = {};
if (tracker.labels !== undefined) {
requireObject(tracker.labels, `${where}.labels`);
// fromEntries defines own properties, so a "__proto__" title stays data.
labels = Object.fromEntries(Object.entries(tracker.labels).map(([title, id]) => [
requireString(title, `${where}.labels title`, { max: 100 }),
requirePositiveInt(id, `${where}.labels.${title}`),
]));
}
return { sync, labels };
}
function checkRoles(roles, file, rolesDir) {
const where = `${file} roles`;
requireObject(roles, where);
if (Object.keys(roles).length === 0) refuse(`${where} must declare at least one role instance`);
const out = {};
const definitions = {};
const bots = new Set();
const botIds = new Set();
for (const [instance, entry] of Object.entries(roles)) {
requireId(instance, `${where} instance name`);
const at = `${where}.${instance}`;
requireObject(entry, at);
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials"], at);
requireId(entry.definition, `${at}.definition`);
const definition = Object.hasOwn(definitions, entry.definition) ? definitions[entry.definition] : loadRole(rolesDir, entry.definition);
definitions[entry.definition] = definition;
if (definition.roleVersion !== 2) refuse(`${at}: role definition ${entry.definition} is version 1 and can't back a business role instance`);
const services = definition.credentials.map((c) => c.service);
let tracker = null;
if (services.includes("vikunja")) {
if (entry.tracker === undefined) refuse(`${at} needs "tracker" with its Vikunja bot, because ${entry.definition} uses vikunja`);
tracker = checkBot(entry.tracker, `${at}.tracker`);
if (bots.has(tracker.bot)) refuse(`${at}.tracker.bot ${tracker.bot} is used by another instance`);
if (botIds.has(tracker.botId)) refuse(`${at}.tracker.botId ${tracker.botId} is used by another instance`);
bots.add(tracker.bot);
botIds.add(tracker.botId);
} else if (entry.tracker !== undefined) {
refuse(`${at} has "tracker" but ${entry.definition} uses no vikunja credential`);
}
out[instance] = {
definition: entry.definition,
holder: entry.holder === undefined ? null : requireId(entry.holder, `${at}.holder`),
vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`),
tracker,
credentials: checkCredentialMap(entry.credentials ?? {}, services, `${at}.credentials`),
};
}
return { roles: out, definitions };
}
function checkLaunch(launch, roles, definitions, file) {
const where = `${file} launch`;
requireObject(launch, where);
rejectUnknownKeys(launch, ["by", "instances", "max"], where);
requireId(launch.by, `${where}.by`);
if (!Object.hasOwn(roles, launch.by)) refuse(`${where}.by names ${launch.by}, which the business file doesn't declare`);
const launcher = definitions[roles[launch.by].definition];
if (!launcher.authority.withinRole.includes("role.launch")) {
refuse(`${where}.by names ${launch.by}, whose role ${launcher.name} doesn't hold role.launch within-role`);
}
const instances = requireDistinctList(launch.instances, `${where}.instances`, (name) => {
requireId(name, `${where}.instances entry`);
if (!Object.hasOwn(roles, name)) refuse(`${where}.instances names ${name}, which the business file doesn't declare`);
if (name === launch.by) refuse(`${where}.instances can't include the launcher itself (${name})`);
}, { nonEmpty: true });
requireObject(launch.max, `${where}.max`);
if (Object.keys(launch.max).length === 0) refuse(`${where}.max must name at least one model family`);
const max = {};
for (const [family, count] of Object.entries(launch.max)) {
const ceiling = Object.hasOwn(LAUNCH_CEILING, family) ? LAUNCH_CEILING[family] : undefined;
if (ceiling === undefined) refuse(`${where}.max names unknown model family ${JSON.stringify(family)} (known: ${Object.keys(LAUNCH_CEILING).join(", ")})`);
if (!Number.isSafeInteger(count) || count < 0 || count > ceiling) refuse(`${where}.max.${family} must be an integer from 0 to ${ceiling}`);
max[family] = count;
}
return { by: launch.by, instances, max };
}
// Validate a parsed business document read from `file`. `rolesDir` is where
// role definitions live. Returns a frozen business with its definitions.
export function validateBusinessDocument(document, file, { rolesDir }) {
requireObject(document, "business file");
rejectUnknownKeys(document, TOP_KEYS, "business file");
if (document.businessVersion !== 1) refuse(`business file "businessVersion" must be 1 (${file})`);
requireId(document.id, "business id");
const base = file.split("/").pop().replace(/\.json$/, "");
if (document.id !== base) refuse(`business "id" (${document.id}) must match its filename (${base}.json)`);
for (const key of ["human", "arbiters", "projects", "tracker", "roles"]) {
if (document[key] === undefined) refuse(`business file requires "${key}" (${file})`);
}
const human = requireId(document.human, "business human");
const vars = checkVars(document.vars ?? {}, "business", `${file} vars`);
const { roles, definitions } = checkRoles(document.roles, file, rolesDir);
requireObject(document.arbiters, `${file} arbiters`);
rejectUnknownKeys(document.arbiters, ["delivery", "technical"], `${file} arbiters`);
const arbiters = {};
for (const kind of ["delivery", "technical"]) {
const name = document.arbiters[kind];
requireId(name, `${file} arbiters.${kind}`);
if (!Object.hasOwn(roles, name)) refuse(`${file} arbiters.${kind} names ${name}, which the business file doesn't declare`);
arbiters[kind] = name;
}
requireObject(document.projects, `${file} projects`);
if (Object.keys(document.projects).length === 0) refuse(`${file} projects must name at least one project`);
const projects = {};
for (const [id, entry] of Object.entries(document.projects)) {
requireId(id, `${file} project id`);
requireObject(entry, `${file} projects.${id}`);
rejectUnknownKeys(entry, ["root"], `${file} projects.${id}`);
if (typeof entry.root !== "string" || !isAbsolute(entry.root) || normalize(entry.root) !== entry.root || entry.root.includes("\0")) {
refuse(`${file} projects.${id}.root must be a normalized absolute path`);
}
projects[id] = { root: entry.root };
}
const tracker = checkTracker(document.tracker, file);
for (const [instance, role] of Object.entries(roles)) {
if (role.tracker && (role.tracker.bot === tracker.sync.bot || role.tracker.botId === tracker.sync.botId)) {
refuse(`${file} tracker.sync must use its own bot, not the one roles.${instance} uses`);
}
}
const launch = document.launch === undefined ? null : checkLaunch(document.launch, roles, definitions, file);
return deepFreeze({
businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, definitions,
});
}
// Load businesses/<id>.json from the config directory. The file holds
// authority, so it must belong to this user and not be writable by group
// or other.
export function loadBusiness(id, { dir = configDir(), rolesDir }) {
if (!rolesDir) throw new Error("loadBusiness needs rolesDir");
const file = businessFilePath(id, dir);
const document = readJsonFile(file, "business file", (stat) => {
if (stat.uid !== process.getuid()) refuse(`business file must belong to uid ${process.getuid()}: ${file}`);
if ((stat.mode & 0o022) !== 0) refuse(`business file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
});
return validateBusinessDocument(document, file, { rolesDir });
}
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env node
// mosaic business validate <business>
// mosaic business resolve <business> <instance> [--project <id>]
//
// validate loads the business file, every role definition it uses, the
// project files that exist under its declared roots, and stat-checks every
// credential reference. resolve prints one role instance's resolved record.
// Both print JSON on stdout and problems on stderr.
//
// Exit codes: 0 ok; 2 invalid (business, role, project file or a credential
// reference); 3 system config problem; 4 usage, or a required file missing.
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { BusinessError } from "./errors.mjs";
import { loadBusiness } from "./business.mjs";
import { loadProject, projectFilePath } from "./project.mjs";
import { checkCredentialRef } from "./credentials.mjs";
import { systemVars, resolveInstance } from "./resolve.mjs";
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const USAGE = "usage: mosaic business validate <business> | resolve <business> <instance> [--project <id>]";
function fail(code, message) {
process.stderr.write(`mosaic business: ${message}\n`);
process.exit(code);
}
function loadSystem() {
const proc = spawnSync(process.execPath, [join(REPO, "scripts", "mosaic-config.mjs"), "validate"], {
encoding: "utf8",
maxBuffer: 1024 * 1024,
});
if (proc.status !== 0) fail(3, `system config problem (mosaic-config exit ${proc.status}): ${proc.stderr.trim()}`);
try {
return JSON.parse(proc.stdout);
} catch {
fail(3, "system config: mosaic-config printed something that isn't JSON");
}
}
function rolesDir() {
return resolve(process.env.MOSAIC_ROLES_DIR || join(REPO, "roles"));
}
// Check credential references; print warnings; return the problem list.
function checkRefs(refs, forbiddenRoots) {
const problems = [];
const warnings = [];
for (const ref of refs) {
const result = checkCredentialRef(ref, { forbiddenRoots });
problems.push(...result.problems);
warnings.push(...result.warnings);
}
return { problems, warnings };
}
function forbiddenRoots(config, business) {
return [REPO, config.dataRoot, ...Object.values(business.projects).map((p) => p.root)];
}
function validate(args) {
if (args.length !== 1) fail(4, USAGE);
const config = loadSystem();
const system = systemVars(config);
const business = loadBusiness(args[0], { rolesDir: rolesDir() });
const projects = {};
const warnings = [];
for (const [id, entry] of Object.entries(business.projects)) {
const file = projectFilePath(entry.root);
if (!existsSync(file)) {
projects[id] = "absent";
warnings.push(`project ${id}: no project file at ${file}; it has no project variables`);
continue;
}
const project = loadProject(entry.root);
if (project.id !== id) fail(2, `project file ${file} has id ${project.id}, but business ${business.id} declares it as ${id}`);
for (const instance of Object.keys(business.roles)) resolveInstance({ system, business, project, instance });
projects[id] = "valid";
}
const refs = [
...Object.values(business.tracker.sync.credentials),
...Object.values(business.roles).flatMap((r) => Object.values(r.credentials)),
];
const checked = checkRefs(refs, forbiddenRoots(config, business));
warnings.push(...checked.warnings);
const instances = {};
for (const instance of Object.keys(business.roles)) {
instances[instance] = resolveInstance({ system, business, instance }).digest;
}
for (const w of warnings) process.stderr.write(`mosaic business: warning: ${w}\n`);
if (checked.problems.length > 0) {
for (const p of checked.problems) process.stderr.write(`mosaic business: ${p}\n`);
fail(2, `business ${business.id}: ${checked.problems.length} credential reference problem(s)`);
}
process.stdout.write(`${JSON.stringify({ business: business.id, file: business.file, projects, instances }, null, 2)}\n`);
}
function resolveCommand(args) {
let project = null;
const rest = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === "--project") {
if (!args[i + 1] || project !== null) fail(4, USAGE);
project = args[++i];
} else rest.push(args[i]);
}
if (rest.length !== 2) fail(4, USAGE);
const [businessId, instance] = rest;
const config = loadSystem();
const business = loadBusiness(businessId, { rolesDir: rolesDir() });
let loaded = null;
if (project !== null) {
const entry = Object.hasOwn(business.projects, project) ? business.projects[project] : null;
if (!entry) fail(2, `business ${business.id} declares no project ${JSON.stringify(project)}`);
loaded = loadProject(entry.root);
}
const resolved = resolveInstance({ system: systemVars(config), business, project: loaded, instance });
const checked = checkRefs(Object.values(resolved.credentials), forbiddenRoots(config, business));
for (const w of checked.warnings) process.stderr.write(`mosaic business: warning: ${w}\n`);
if (checked.problems.length > 0) {
for (const p of checked.problems) process.stderr.write(`mosaic business: ${p}\n`);
fail(2, `${business.id} ${instance}: ${checked.problems.length} credential reference problem(s)`);
}
process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
}
const [verb, ...args] = process.argv.slice(2);
try {
if (verb === "validate") validate(args);
else if (verb === "resolve") resolveCommand(args);
else fail(4, USAGE);
} catch (error) {
if (error instanceof BusinessError) fail(error.exitCode, error.message);
throw error;
}
+97
View File
@@ -0,0 +1,97 @@
// Credential references (note section 2, addendum A section 7). A
// reference names where a token lives, never the token. The checks here use
// lstat and realpath only; nothing in this package opens a token file.
import { lstatSync, realpathSync } from "node:fs";
import { isAbsolute, normalize, relative, sep } from "node:path";
import { refuse } from "./errors.mjs";
import { SERVICES } from "./vocabulary.mjs";
import { requireObject, rejectUnknownKeys, requireDate } from "./util.mjs";
const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
const DATE_KEY = Object.freeze({ gitea: "rotateBy", vikunja: "expires" });
const WARN_DAYS = 7;
const DAY_MS = 24 * 60 * 60 * 1000;
// Shape check for one reference. Returns a frozen { service, file | env,
// rotateBy | expires }.
export function parseCredentialRef(ref, service, where) {
if (!SERVICES.includes(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`);
requireObject(ref, where);
const dateKey = DATE_KEY[service];
rejectUnknownKeys(ref, ["file", "env", dateKey], where);
const hasFile = ref.file !== undefined;
const hasEnv = ref.env !== undefined;
if (hasFile === hasEnv) refuse(`${where} must name exactly one of "file" or "env"`);
const out = { service };
if (hasFile) {
if (typeof ref.file !== "string" || !isAbsolute(ref.file) || normalize(ref.file) !== ref.file || ref.file.includes("\0")) {
refuse(`${where}.file must be a normalized absolute path`);
}
out.file = ref.file;
} else {
if (typeof ref.env !== "string" || !ENV_NAME.test(ref.env)) refuse(`${where}.env must match ${ENV_NAME}`);
out.env = ref.env;
}
if (ref[dateKey] === undefined) refuse(`${where} needs "${dateKey}" (YYYY-MM-DD)`);
out[dateKey] = requireDate(ref[dateKey], `${where}.${dateKey}`);
return Object.freeze(out);
}
function inside(root, path) {
const rel = relative(root, path);
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
}
// Check a parsed reference against the filesystem and the calendar.
// Returns { problems: [...], warnings: [...] }; a caller that finds any
// problem refuses. `forbiddenRoots` are directories a token file must not
// sit in (the repository, dataRoot). `now` is a Date.
export function checkCredentialRef(ref, { forbiddenRoots = [], now = new Date(), env = process.env, uid = process.getuid() } = {}) {
const problems = [];
const warnings = [];
const label = `${ref.service} ${ref.file ? `file ${ref.file}` : `env ${ref.env}`}`;
if (ref.file) {
let stat = null;
try {
stat = lstatSync(ref.file);
} catch {
problems.push(`${label}: not found`);
}
if (stat) {
if (stat.isSymbolicLink() || !stat.isFile()) problems.push(`${label}: must be a regular file, not a symbolic link`);
else {
if (stat.uid !== uid) problems.push(`${label}: owned by uid ${stat.uid}, not ${uid}`);
if ((stat.mode & 0o077) !== 0) problems.push(`${label}: mode ${(stat.mode & 0o777).toString(8)} gives group or other access; use 600`);
if (stat.size === 0) problems.push(`${label}: empty`);
let real = ref.file;
try {
real = realpathSync(ref.file);
} catch {
problems.push(`${label}: path can't be resolved`);
}
for (const root of forbiddenRoots) {
let realRoot = root;
try {
realRoot = realpathSync(root);
} catch {
// A root that doesn't exist yet can't contain the file.
}
if (inside(realRoot, real)) problems.push(`${label}: inside ${root}; token files live outside the repository and dataRoot`);
}
}
}
} else if (env[ref.env] === undefined || env[ref.env] === "") {
warnings.push(`${label}: not set in this environment; the launcher must provide it`);
}
const days = (dateText) => Math.floor((Date.parse(`${dateText}T00:00:00Z`) - now.getTime()) / DAY_MS);
if (ref.expires) {
const left = days(ref.expires);
if (Date.parse(`${ref.expires}T00:00:00Z`) <= now.getTime()) problems.push(`${label}: expired on ${ref.expires}`);
else if (left < WARN_DAYS) warnings.push(`${label}: expires on ${ref.expires}`);
}
if (ref.rotateBy && Date.parse(`${ref.rotateBy}T00:00:00Z`) <= now.getTime()) {
warnings.push(`${label}: rotation was due on ${ref.rotateBy}`);
}
return { problems, warnings };
}
+13
View File
@@ -0,0 +1,13 @@
// Exit codes follow docs/TOOLS.md: 2 invalid data or refused, 4 usage or a
// missing file. Every refusal in this package is a BusinessError.
export class BusinessError extends Error {
constructor(message, exitCode = 2) {
super(message);
this.name = "BusinessError";
this.exitCode = exitCode;
}
}
export function refuse(message, exitCode = 2) {
throw new BusinessError(message, exitCode);
}
+14
View File
@@ -0,0 +1,14 @@
// @mosaic/business: role definitions, the business and project files, the
// variable registry and the resolver (slice 1 row S1). Everything here reads
// files and refuses on a problem; nothing writes.
export { BusinessError } from "./errors.mjs";
export {
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE, LAUNCH_CEILING,
} from "./vocabulary.mjs";
export { validateRoleDocument, loadRoleFile, loadRole } from "./role.mjs";
export { LAYERS, REGISTRY, checkVars, mergeVars } from "./vars.mjs";
export { parseCredentialRef, checkCredentialRef } from "./credentials.mjs";
export { configDir, businessFilePath, validateBusinessDocument, loadBusiness } from "./business.mjs";
export { projectFilePath, validateProjectDocument, loadProject } from "./project.mjs";
export { systemVars, resolveInstance, classify } from "./resolve.mjs";
+36
View File
@@ -0,0 +1,36 @@
// The project file, <root>/.mosaic/project.json. It lives in the project's
// repository and changes by reviewed commits there. The stack only reads it.
import { isAbsolute, join, normalize } from "node:path";
import { refuse } from "./errors.mjs";
import { checkVars } from "./vars.mjs";
import { requireObject, rejectUnknownKeys, requireId, readJsonFile, deepFreeze } from "./util.mjs";
export function projectFilePath(root) {
return join(root, ".mosaic", "project.json");
}
export function validateProjectDocument(document, file) {
requireObject(document, "project file");
rejectUnknownKeys(document, ["projectVersion", "id", "vars", "roles"], "project file");
if (document.projectVersion !== 1) refuse(`project file "projectVersion" must be 1 (${file})`);
requireId(document.id, "project id");
const vars = checkVars(document.vars ?? {}, "project", `${file} vars`);
const roles = {};
if (document.roles !== undefined) {
requireObject(document.roles, `${file} roles`);
for (const [instance, entry] of Object.entries(document.roles)) {
requireId(instance, `${file} role instance`);
requireObject(entry, `${file} roles.${instance}`);
rejectUnknownKeys(entry, ["vars"], `${file} roles.${instance}`);
roles[instance] = { vars: checkVars(entry.vars ?? {}, "project", `${file} roles.${instance}.vars`) };
}
}
return deepFreeze({ projectVersion: 1, id: document.id, file, vars, roles });
}
export function loadProject(root) {
if (typeof root !== "string" || !isAbsolute(root) || normalize(root) !== root) refuse(`project root must be a normalized absolute path (got ${JSON.stringify(root)})`);
const file = projectFilePath(root);
return validateProjectDocument(readJsonFile(file, "project file"), file);
}
+104
View File
@@ -0,0 +1,104 @@
// The resolver (REQ-VAR-1 and 2). It joins one role instance's definition,
// the business file, the optional project file and the system config into
// the record a launcher or the broker uses. Plain values: the most specific
// layer wins. Limits: every layer narrows, nothing widens.
import { refuse } from "./errors.mjs";
import { ACTIONS, NETWORKS } from "./vocabulary.mjs";
import { checkVars, mergeVars } from "./vars.mjs";
import { canonicalJson, sha256, deepFreeze } from "./util.mjs";
import { projectFilePath } from "./project.mjs";
// The system layer from `mosaic-config.mjs validate` output.
export function systemVars(config) {
return checkVars({
environment: config.environment,
dataRoot: config.dataRoot,
"execution.backend": config.execution.backend,
"execution.provider": config.execution.provider,
"execution.model": config.execution.model,
"execution.adapter": config.execution.adapter,
}, "system", "system config");
}
function narrowerNetwork(a, b) {
return NETWORKS.indexOf(a) <= NETWORKS.indexOf(b) ? a : b;
}
// resolveInstance({ system, business, project, instance })
// system systemVars(...) output
// business loadBusiness(...) output
// project loadProject(...) output, or null
// instance a role instance the business file declares
export function resolveInstance({ system, business, project = null, instance }) {
const entry = Object.hasOwn(business.roles, instance) ? business.roles[instance] : null;
if (!entry) refuse(`business ${business.id} declares no role instance ${JSON.stringify(instance)}`);
const definition = business.definitions[entry.definition];
const layers = [
{ layer: "system", source: "system", vars: system },
{ layer: "business", source: `business:${business.id}`, vars: business.vars },
];
let projectId = null;
if (project) {
const declared = Object.entries(business.projects).find(([, p]) => projectFilePath(p.root) === project.file);
if (!declared || declared[0] !== project.id) {
refuse(`project ${project.id} (${project.file}) isn't declared under that id in business ${business.id}`);
}
projectId = project.id;
for (const name of Object.keys(project.roles)) {
if (!Object.hasOwn(business.roles, name)) refuse(`project ${project.id} sets vars for role instance ${name}, which business ${business.id} doesn't declare`);
}
layers.push({ layer: "project", source: `project:${project.id}`, vars: project.vars });
if (Object.hasOwn(project.roles, instance)) {
layers.push({ layer: "project", source: `project:${project.id}:roles.${instance}`, vars: project.roles[instance].vars });
}
}
layers.push({ layer: "agent", source: `business:${business.id}:roles.${instance}`, vars: entry.vars });
const { vars, provenance } = mergeVars(layers);
let tools = [...definition.tools];
let network = definition.network;
let withinRole = [...definition.authority.withinRole];
let crossRole = [...definition.authority.crossRole];
if (vars["limits.tools"]) tools = tools.filter((t) => vars["limits.tools"].includes(t));
if (vars["limits.network"]) network = narrowerNetwork(network, vars["limits.network"]);
if (vars["limits.authority"]) {
withinRole = withinRole.filter((a) => vars["limits.authority"].includes(a));
crossRole = crossRole.filter((a) => vars["limits.authority"].includes(a));
}
// role.launch needs the business file's launch block naming this
// instance (addendum A section 8), and the verb must survive
// limits.authority. Otherwise the verb is gated and `launch` is null,
// so the two never disagree.
const launch = business.launch && business.launch.by === instance && withinRole.includes("role.launch") ? business.launch : null;
if (!launch) {
withinRole = withinRole.filter((a) => a !== "role.launch");
crossRole = crossRole.filter((a) => a !== "role.launch");
}
const resolved = {
business: business.id,
project: projectId,
instance,
definition: definition.name,
holder: entry.holder,
contract: definition.contractPath,
vars,
provenance,
limits: { tools, network, authority: { withinRole, crossRole } },
credentials: entry.credentials,
tracker: entry.tracker,
launch,
};
return deepFreeze({ ...resolved, digest: sha256(canonicalJson(resolved)) });
}
// Classify one action for a resolved instance: "within", "cross" or
// "gated". An action outside the vocabulary refuses.
export function classify(resolved, action) {
if (!ACTIONS.includes(action)) refuse(`unknown action: ${JSON.stringify(action)}`);
if (resolved.limits.authority.withinRole.includes(action)) return "within";
if (resolved.limits.authority.crossRole.includes(action)) return "cross";
return "gated";
}
+145
View File
@@ -0,0 +1,145 @@
// Role definitions, roles/<name>.json. Version 2 (REQ-ROLE-1) adds a
// contract, an authority map over the closed vocabulary and the
// credentials the role needs, and keeps version 1's tool and network
// ceilings. Version 1 files still load: they carry no authority, so every
// vocabulary action is gated for them.
import { lstatSync } from "node:fs";
import { basename, dirname, join } from "node:path";
import { refuse } from "./errors.mjs";
import {
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
} from "./vocabulary.mjs";
import {
requireObject, rejectUnknownKeys, requireId, requireString, requireDistinctList, readJsonFile, deepFreeze,
} from "./util.mjs";
const CONTRACT_NAME = /^[a-z0-9][a-z0-9._-]{0,60}\.md$/;
const V1_KEYS = ["roleVersion", "name", "tools", "network"];
const V2_KEYS = ["roleVersion", "name", "title", "contract", "tools", "network", "authority", "credentials"];
function checkTools(tools) {
if (!Array.isArray(tools) || tools.length === 0) refuse('role "tools" must be a non-empty array of tool names');
return requireDistinctList(tools, "role tools", (tool) => {
if (!TOOLS.includes(tool)) refuse(`unsupported tool: ${JSON.stringify(tool)} (supported: ${TOOLS.join(", ")})`);
});
}
function checkNetwork(network) {
if (!NETWORKS.includes(network)) refuse(`role "network" must be one of: ${NETWORKS.join(", ")}`);
return network;
}
function checkAuthority(authority) {
requireObject(authority, 'role "authority"');
rejectUnknownKeys(authority, ["withinRole", "crossRole"], 'role "authority"');
const lists = {};
for (const key of ["withinRole", "crossRole"]) {
lists[key] = requireDistinctList(authority[key], `role authority.${key}`, (action) => {
if (!ACTIONS.includes(action)) refuse(`unknown action in authority.${key}: ${JSON.stringify(action)}`);
if (GATED_ONLY.includes(action)) refuse(`authority.${key} lists ${action}, which is always gated`);
});
}
const both = lists.withinRole.filter((a) => lists.crossRole.includes(a));
if (both.length > 0) refuse(`action listed as both withinRole and crossRole: ${both.join(", ")}`);
return lists;
}
function checkGiteaScopes(scopes) {
const list = requireDistinctList(scopes, "gitea scopes", (scope) => {
const m = typeof scope === "string" ? /^(read|write):([a-z]+)$/.exec(scope) : null;
if (!m || !GITEA_SCOPE_CATEGORIES.includes(m[2])) {
refuse(`unsupported gitea scope: ${JSON.stringify(scope)} (expected read:<category> or write:<category>; categories: ${GITEA_SCOPE_CATEGORIES.join(", ")})`);
}
}, { nonEmpty: true });
const categories = list.map((s) => s.split(":")[1]);
const twice = categories.filter((c, i) => categories.indexOf(c) !== i);
if (twice.length > 0) refuse(`gitea scopes name ${twice[0]} twice; a token holds one level per category`);
return list;
}
function checkVikunjaScopes(scopes) {
requireObject(scopes, "vikunja scopes");
if (Object.keys(scopes).length === 0) refuse("vikunja scopes must name at least one route group");
const out = {};
for (const [group, verbs] of Object.entries(scopes)) {
const allowed = VIKUNJA_GRANTABLE[group];
if (!allowed) refuse(`vikunja route group not grantable to a role: ${JSON.stringify(group)}`);
out[group] = requireDistinctList(verbs, `vikunja scopes.${group}`, (verb) => {
if (!allowed.includes(verb)) refuse(`vikunja verb not grantable to a role: ${group}.${JSON.stringify(verb)}`);
}, { nonEmpty: true });
}
return out;
}
function checkCredentials(credentials) {
if (!Array.isArray(credentials)) refuse('role "credentials" must be an array');
const seen = new Set();
return credentials.map((entry, i) => {
requireObject(entry, `role credentials[${i}]`);
rejectUnknownKeys(entry, ["service", "scopes"], `role credentials[${i}]`);
if (!SERVICES.includes(entry.service)) refuse(`role credentials[${i}].service must be one of: ${SERVICES.join(", ")}`);
if (seen.has(entry.service)) refuse(`role credentials name ${entry.service} twice`);
seen.add(entry.service);
const scopes = entry.service === "gitea" ? checkGiteaScopes(entry.scopes) : checkVikunjaScopes(entry.scopes);
return { service: entry.service, scopes };
});
}
function checkContract(contract, file) {
if (typeof contract !== "string" || !CONTRACT_NAME.test(contract)) {
refuse(`role "contract" must be a Markdown file name in the role's directory, matching ${CONTRACT_NAME} (got ${JSON.stringify(contract)})`);
}
const path = join(dirname(file), contract);
let stat;
try {
stat = lstatSync(path);
} catch {
refuse(`role contract not found: ${path}`);
}
if (!stat.isFile() || stat.isSymbolicLink() || stat.size === 0) refuse(`role contract must be a non-empty regular file: ${path}`);
return path;
}
// Validate a parsed role document read from `file`. Returns a frozen role:
// { roleVersion, name, title, contract, contractPath, tools, network,
// authority: { withinRole, crossRole }, credentials: [{ service, scopes }] }.
export function validateRoleDocument(document, file) {
requireObject(document, "role");
if (document.roleVersion !== 1 && document.roleVersion !== 2) refuse('role "roleVersion" must be 1 or 2');
rejectUnknownKeys(document, document.roleVersion === 1 ? V1_KEYS : V2_KEYS, "role");
requireId(document.name, "role name");
const base = basename(file).replace(/\.json$/, "");
if (document.name !== base) refuse(`role "name" (${document.name}) must match its filename (${base}.json)`);
const tools = checkTools(document.tools);
if (document.roleVersion === 1) {
const network = document.network === undefined ? "none" : checkNetwork(document.network);
return deepFreeze({
roleVersion: 1, name: document.name, title: null, contract: null, contractPath: null, tools, network,
authority: { withinRole: [], crossRole: [] }, credentials: [],
});
}
for (const key of V2_KEYS) {
if (document[key] === undefined) refuse(`role version 2 requires "${key}"`);
}
return deepFreeze({
roleVersion: 2,
name: document.name,
title: requireString(document.title, 'role "title"', { max: 80 }),
contract: document.contract,
contractPath: checkContract(document.contract, file),
tools,
network: checkNetwork(document.network),
authority: checkAuthority(document.authority),
credentials: checkCredentials(document.credentials),
});
}
export function loadRoleFile(file) {
return validateRoleDocument(readJsonFile(file, "role file"), file);
}
export function loadRole(rolesDir, name) {
requireId(name, "role name");
return loadRoleFile(join(rolesDir, `${name}.json`));
}
+109
View File
@@ -0,0 +1,109 @@
import { closeSync, constants, fstatSync, openSync, readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { refuse } from "./errors.mjs";
import { ID_PATTERN } from "./vocabulary.mjs";
export function isPlainObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function requireObject(value, where) {
if (!isPlainObject(value)) refuse(`${where} must be a JSON object`);
return value;
}
export function rejectUnknownKeys(object, allowed, where) {
for (const key of Object.keys(object)) {
if (!allowed.includes(key)) refuse(`unsupported ${where} key: ${JSON.stringify(key)}`);
}
}
export function requireId(value, where) {
if (typeof value !== "string" || !ID_PATTERN.test(value)) {
refuse(`${where} must match ${ID_PATTERN} (got ${JSON.stringify(value)})`);
}
return value;
}
export function requireString(value, where, { max = 200 } = {}) {
if (typeof value !== "string" || value.trim().length === 0 || value.length > max || value.includes("\0")) {
refuse(`${where} must be a non-empty string of at most ${max} characters`);
}
return value;
}
export function requirePositiveInt(value, where) {
if (!Number.isSafeInteger(value) || value < 1) refuse(`${where} must be a positive integer (got ${JSON.stringify(value)})`);
return value;
}
// A list of distinct strings, each checked by `check`.
export function requireDistinctList(value, where, check, { nonEmpty = false } = {}) {
if (!Array.isArray(value)) refuse(`${where} must be an array`);
if (nonEmpty && value.length === 0) refuse(`${where} must not be empty`);
const seen = new Set();
for (const item of value) {
check(item);
if (seen.has(item)) refuse(`duplicate entry in ${where}: ${JSON.stringify(item)}`);
seen.add(item);
}
return [...seen];
}
// Calendar date YYYY-MM-DD that exists (2026-02-30 refuses).
export function requireDate(value, where) {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) refuse(`${where} must be a date YYYY-MM-DD (got ${JSON.stringify(value)})`);
const d = new Date(`${value}T00:00:00Z`);
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== value) refuse(`${where} is not a real date: ${value}`);
return value;
}
// Read a JSON file that must be a regular file, not a symbolic link.
// Missing or not a regular file is 4; unparseable is 2. One descriptor,
// opened without following a link, serves every check and the read.
// `checkStat` sees that descriptor's stat before the read, so the file
// can't be swapped between the check and the read.
export function readJsonFile(file, what, checkStat) {
let fd;
try {
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
} catch (error) {
if (error.code === "ENOENT") refuse(`${what} not found: ${file}`, 4);
refuse(`${what} must be a regular, non-symbolic-link file: ${file}`, 4);
}
let text;
try {
const stat = fstatSync(fd);
if (!stat.isFile()) refuse(`${what} must be a regular, non-symbolic-link file: ${file}`, 4);
checkStat?.(stat);
text = readFileSync(fd, "utf8");
} finally {
closeSync(fd);
}
try {
return JSON.parse(text);
} catch (error) {
refuse(`${what} is not valid JSON (${file}): ${error.message}`);
}
}
// JSON with object keys sorted at every level, for digests.
export function canonicalJson(value) {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (isPlainObject(value)) {
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(",")}}`;
}
return JSON.stringify(value);
}
export function sha256(text) {
return createHash("sha256").update(text).digest("hex");
}
export function deepFreeze(value) {
if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
Object.freeze(value);
for (const v of Object.values(value)) deepFreeze(v);
}
return value;
}
+168
View File
@@ -0,0 +1,168 @@
// The variable key registry (REQ-VAR-1, note section 2, addendum A section
// 7). Every key is declared here once, with its type, the layers allowed to
// set it and its merge rule. A key that isn't here refuses, and so does a
// key set at a layer it isn't allowed in.
//
// Layers, least to most specific:
// system ~/.config/mosaic-dev/config.json, resolved by mosaic-config.mjs
// business vars in businesses/<id>.json
// project vars in <root>/.mosaic/project.json, then that file's
// roles.<instance>.vars (still the project layer, applied after)
// agent roles.<instance>.vars in the business file
//
// Merge rules:
// replace the most specific layer that sets the key wins
// intersect every layer that sets the key narrows it; nothing widens
import { refuse } from "./errors.mjs";
import { ACTIONS, NETWORKS, TOOLS } from "./vocabulary.mjs";
import { isPlainObject, requireDistinctList, requirePositiveInt, requireString } from "./util.mjs";
export const LAYERS = Object.freeze(["system", "business", "project", "agent"]);
const BRANCH = /^(?!-)(?!.*\.\.)(?!.*\/\/)(?!.*\.lock$)[A-Za-z0-9._/-]{1,100}(?<![./])$/;
function string(max = 200) {
return (v, key) => requireString(v, `variable ${key}`, { max });
}
function oneOf(values) {
return (v, key) => {
if (!values.includes(v)) refuse(`variable ${key} must be one of: ${values.join(", ")} (got ${JSON.stringify(v)})`);
return v;
};
}
function integer(min) {
return (v, key) => {
if (!Number.isSafeInteger(v) || v < min) refuse(`variable ${key} must be an integer of at least ${min} (got ${JSON.stringify(v)})`);
return v;
};
}
// http or https, no user info, query or fragment, no trailing slash.
function baseUrl(v, key) {
requireString(v, `variable ${key}`, { max: 300 });
let url;
try {
url = new URL(v);
} catch {
refuse(`variable ${key} must be an http or https URL (got ${JSON.stringify(v)})`);
}
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash || v.endsWith("/")) {
refuse(`variable ${key} must be an http or https base URL with no credentials, query, fragment or trailing slash`);
}
return v;
}
function branch(v, key) {
if (typeof v !== "string" || !BRANCH.test(v)) refuse(`variable ${key} must be a git branch name (got ${JSON.stringify(v)})`);
return v;
}
function list(check, { nonEmpty = true } = {}) {
return (v, key) => requireDistinctList(v, `variable ${key}`, (item) => check(item, `${key} entry`), { nonEmpty });
}
function absolutePath(v, key) {
if (typeof v !== "string" || !v.startsWith("/") || v.includes("\0")) refuse(`variable ${key} must be an absolute path`);
return v;
}
const def = (layers, check, extra = {}) => Object.freeze({ layers: Object.freeze(layers), merge: "replace", check, default: undefined, ...extra });
export const REGISTRY = Object.freeze({
// System. mosaic-config.mjs owns their validation; these checks only
// keep the resolver honest about types.
environment: def(["system"], string(64)),
dataRoot: def(["system"], absolutePath),
"execution.backend": def(["system"], string(64)),
"execution.provider": def(["system"], string(64)),
"execution.model": def(["system"], string(200)),
"execution.adapter": def(["system"], string(64)),
// Business.
"tracker.kind": def(["business"], oneOf(["vikunja"]), { default: "vikunja" }),
"tracker.baseUrl": def(["business"], baseUrl),
"tracker.reconcileMinutes": def(["business"], integer(1), { default: 60 }),
"tracker.pollSeconds": def(["business", "project"], integer(10), { default: 30 }),
"human.discordUserId": def(["business"], (v, key) => {
if (typeof v !== "string" || !/^[1-9][0-9]{16,19}$/.test(v)) refuse(`variable ${key} must be a Discord user id, 17 to 20 digits as a string`);
return v;
}),
"gitea.baseUrl": def(["business"], baseUrl),
// Project.
"tracker.project": def(["project"], (v, key) => requirePositiveInt(v, `variable ${key}`)),
"git.workingBranch": def(["project"], branch),
"git.protectedBranches": def(["project"], list(branch)),
suites: def(["project"], list(string(300))),
"issues.repo": def(["project"], (v, key) => {
if (typeof v !== "string" || !/^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/.test(v)) refuse(`variable ${key} must be owner/name`);
return v;
}),
// Agent: one role instance.
harness: def(["agent"], oneOf(["pi", "claude-code"])),
model: def(["agent"], string(200)),
thinking: def(["agent"], oneOf(["off", "minimal", "low", "medium", "high", "xhigh"])),
// Limits narrow the role definition's ceilings. limits.authority is an
// allowlist: a role action it doesn't name becomes gated.
"limits.tools": def(["business", "project", "agent"], list((v, key) => {
if (!TOOLS.includes(v)) refuse(`variable ${key} names an unsupported tool: ${JSON.stringify(v)}`);
}, { nonEmpty: false }), { merge: "intersect" }),
"limits.network": def(["business", "project", "agent"], oneOf(NETWORKS), { merge: "intersect" }),
"limits.authority": def(["business", "project", "agent"], list((v, key) => {
if (!ACTIONS.includes(v)) refuse(`variable ${key} names an unknown action: ${JSON.stringify(v)}`);
}, { nonEmpty: false }), { merge: "intersect" }),
});
// Check one layer's vars object. `where` names the file and path for the
// message. Returns a frozen copy with checked values.
export function checkVars(vars, layer, where) {
if (!LAYERS.includes(layer)) throw new Error(`unknown layer ${layer}`);
if (!isPlainObject(vars)) refuse(`${where} must be a JSON object`);
const out = {};
for (const [key, value] of Object.entries(vars)) {
const entry = Object.hasOwn(REGISTRY, key) ? REGISTRY[key] : null;
if (!entry) refuse(`${where}: unknown variable ${JSON.stringify(key)}`);
if (!entry.layers.includes(layer)) refuse(`${where}: variable ${key} can't be set at the ${layer} layer (allowed: ${entry.layers.join(", ")})`);
out[key] = entry.check(value, key);
}
return Object.freeze(out);
}
function narrower(a, b) {
return NETWORKS.indexOf(a) <= NETWORKS.indexOf(b) ? a : b;
}
// Merge checked layers, given least specific first as [{ layer, source,
// vars }]. Returns { vars, provenance }. provenance[key] is the source
// that set a replace key, or the list of sources that narrowed a limit.
export function mergeVars(layers) {
const vars = {};
const provenance = {};
for (const [key, entry] of Object.entries(REGISTRY)) {
if (entry.default !== undefined) {
vars[key] = entry.default;
provenance[key] = "default";
}
}
for (const { source, vars: layerVars } of layers) {
for (const [key, value] of Object.entries(layerVars)) {
const entry = REGISTRY[key];
if (entry.merge === "replace" || vars[key] === undefined) {
vars[key] = value;
provenance[key] = entry.merge === "replace" ? source : [source];
} else if (key === "limits.network") {
vars[key] = narrower(vars[key], value);
provenance[key].push(source);
} else {
vars[key] = vars[key].filter((item) => value.includes(item));
provenance[key].push(source);
}
}
}
return { vars, provenance };
}
+88
View File
@@ -0,0 +1,88 @@
// The closed action vocabulary and the other frozen lists a role file is
// checked against. Design: agents/darkwing/work/slice1-data-model-2026-10-04.md
// section 1.4, addendum A sections 2 and 8, addendum B section 2.
//
// A change to any list here is a reviewed commit to this file. Role files
// that name something outside these lists are refused.
// Every action with an outside effect that slice 1 touches. Routine work
// (editing files, running tests, writing docs, retrying) has no outside
// effect and isn't listed.
export const ACTIONS = Object.freeze([
"task.create",
"task.assign",
"task.schedule",
"task.update.assigned",
"task.close",
"task.reassign",
"task.scope.change",
"task.priority.change",
"git.push.working",
"git.push.protected",
"git.merge.protected",
"review.request",
"review.verdict",
"message.send",
"message.external",
"role.launch",
"role.revoke",
"credential.mint",
"spend",
"deploy",
"policy.change",
"prd.approve",
"decision.resolve.technical",
]);
// Always gated: a role file may not list these under withinRole or
// crossRole. role.revoke is here because lead decision 46 (6.7) keeps every
// revoke gated in slice 1.
export const GATED_ONLY = Object.freeze([
"credential.mint",
"git.merge.protected",
"git.push.protected",
"deploy",
"spend",
"message.external",
"policy.change",
"prd.approve",
"role.revoke",
]);
// pi's documented built-in tools; the same list scripts/mosaic-task.mjs
// accepts for tasks.
export const TOOLS = Object.freeze(["read", "write", "edit", "bash", "grep", "find", "ls"]);
// Ordered from narrowest to widest. Intersecting two values keeps the
// narrower one.
export const NETWORKS = Object.freeze(["none", "api-only", "open"]);
export const SERVICES = Object.freeze(["gitea", "vikunja"]);
// Gitea token scopes are <read|write>:<category>. "all" and the admin
// category are never granted to a role.
export const GITEA_SCOPE_CATEGORIES = Object.freeze([
"activitypub", "issue", "misc", "notification", "organization", "package", "repository", "user",
]);
// Vikunja 2.7.0 route groups and verbs a role token may hold. Each pair was
// minted or used on a scratch 2.7.0 (Researcher's probes, addendum B
// section 2). Anything not listed, including every verb addendum B lists
// as never granted, is refused. A pair missing here that a role really
// needs gets added after a probe shows Vikunja accepts it.
export const VIKUNJA_GRANTABLE = Object.freeze({
projects: Object.freeze(["read_one", "views_buckets", "views_buckets_tasks", "views_buckets_tasks_get"]),
projects_views: Object.freeze(["read_all"]),
tasks: Object.freeze(["read_all", "read_one", "create", "update"]),
tasks_comments: Object.freeze(["read_all", "create"]),
tasks_assignees: Object.freeze(["create", "delete"]),
tasks_relations: Object.freeze(["create", "delete"]),
tasks_labels: Object.freeze(["create", "delete"]),
labels: Object.freeze(["read_all"]),
});
// Model families the launch block may cap, and the PRD's ceiling for each
// (REQ-LAUNCH-1: at most 4 Opus and 4 Sonnet sessions at once).
export const LAUNCH_CEILING = Object.freeze({ opus: 4, sonnet: 4 });
export const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;