Files
stack/packages/business/src/resolve.mjs
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

105 lines
4.7 KiB
JavaScript

// 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";
}