Files
jason.woltje 1d07df0d61 feat(organization): org definitions, template hash and diff reads, definition verbs (#1873)
Roles round 2, slice T1 (roles-and-rails design sections 4, 5 and 13).

- The org file's definitions key: each entry {rev, from?, role, instructions,
  personality?} passes checkComposition; a bad entry refuses the whole file,
  naming the definition and key, never a value.
- org:<name> instance references resolve to the file's definition; a missing
  one refuses definition-missing, naming the instance. Template names are
  unchanged.
- Template identity is the name plus a sha256 over the template JSON and its
  contract; templateHashes reads the current hash for each template.
- The writer's setDefinition is the only path that changes definitions: it
  checks the composition, bumps rev, refuses agent actors, and refuses
  definition-in-use for a definition an instance names (A4 adds the change
  decision). write() refuses organization-definitions-protected.
- Definition verbs import, edit, invent and remove need org.hire
  (definition.*.console in VERB_RIGHTS) and refuse every agent caller.
- The diff read lists the primitives that changed and a line diff of the
  instructions. It applies nothing.
- CLI: mosaic org definitions templates|list|show|diff|import|invent|edit|remove.
2026-11-11 01:15:12 -06:00

134 lines
7.0 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 { organizationPlugins, assertPluginCapabilities } from "../../plugins/src/organization.mjs";
import { ownRails } from "./rails.mjs";
import { refuse } from "./errors.mjs";
import { ACTIONS, NETWORKS, isHostKind } 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, enforcePlugins = false, pluginRoot = system?.dataRoot ?? null }) {
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 pluginView = organizationPlugins(pluginRoot,business.plugins);
let disabledPlugin = null;
try { assertPluginCapabilities(definition,pluginView); } catch(e) {
if(enforcePlugins)throw e;
disabledPlugin=e.plugin;
}
const pluginTools = pluginView.manifests.flatMap(m=>m.tools).filter(t=>definition.tools.includes(t.name));
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]) => p.kind !== 'library' && 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 = {
...(pluginTools.length ? {pluginTools} : {}),
...(pluginView.registry.plugins.length ? {pluginActions:pluginView.registry.plugins.flatMap(p=>p.actions)} : {}),
...(disabledPlugin ? {disabledPlugin} : {}),
business: business.id,
project: projectId,
instance,
definition: definition.name,
holder: entry.holder,
contract: definition.contractPath,
vars,
provenance,
limits: { tools, network, authority: { withinRole, crossRole } },
// Row 210 (#1707, design D5): a host key is used only by the bus child, never by the session,
// so it stays out of the session's credentials and the harness's needs.
rails: ownRails({ instance, authority: { withinRole, crossRole }, network, credentials: definition.credentials, messaging: definition.messaging, arbiters: business.arbiters, launcher: business.launch?.by }),
credentials: Object.fromEntries(Object.entries(entry.credentials).filter(([kind]) => !isHostKind(kind))),
tracker: entry.tracker,
launch,
// #797 relay R2: an owner-started assistant cannot infer its coordinator
// from launchedBy or running claims. Only the validated business chooses
// this address; it grants no launch authority or messaging exception.
...(definition.name === "assistant" ? { coordinator: business.launch ? {
instance: business.launch.by,
name: business.roles[business.launch.by].identity?.name ?? null,
} : null } : {}),
// REQ-HARN-2: the search-source plug-in the organization file switches on, by name.
webSource: business.plugins?.["search-source"] ?? null,
// The role file's skill names. The bundle resolves each against <dataRoot>/skills-enabled.
skills: [...definition.skills],
// #1873 T1: an org definition has no contract file; its instructions are the contract. Added
// only then, so a template instance resolves, and digests, exactly as before.
...(definition.contractPath === null && typeof definition.instructions === "string" ? { instructions: definition.instructions } : {}),
};
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) && !resolved.pluginActions?.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";
}