Files
stack/packages/organization/src/instance-settings.mjs
jason.woltje a8d9eecf65 organization: move packages/business to packages/organization (rename r2 stage B1)
git mv packages/business -> packages/organization (@mosaic/organization). The
old package stays as one-line re-export shims of each moved module, with
src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs
forwarding to the moved executable. BusinessError is OrganizationConfigError
(one constructor, name kept BusinessError); OrganizationError stays distinct.
The bus adapter gains organization-context.mjs (busOrganization), with
business.mjs (busBusiness) as the same function.

Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh
COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure
walker (organization-imports.mjs, both roots required) move to the new path.
v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage
A contract tests stay where they were and pass as before.
2026-11-09 11:47:48 -06:00

478 lines
29 KiB
JavaScript

// Row 165 (#1662, Q171): the human adds a role instance and changes an existing one's definition,
// vars and launch membership. It is human-only: the broker's instances and instances.set verbs
// reach it, and neither is in ORGANIZATION_KEYS, so the chief of staff's tool can't (sage-0918).
// holder, credentials and tracker stay hand edits. Row 194 (#1692) adds delete, the human's veto
// lists and the chief's hire and fire. Every write goes through the organization writer, which
// logs the actor and the old and new values.
import { existsSync, readdirSync } from "node:fs";
import { organizationWriter, OrganizationError } from "./organization.mjs";
import { validateBusinessDocument, businessFilePath, checkBot } from "./organization-config.mjs";
import { DATE_KEY } from "./credentials.mjs";
import { checkRoutes, creativeHumanOnly } from "./launch-settings.mjs";
import { loadProject, projectFilePath } from "./project.mjs";
import { resolveInstance } from "./resolve.mjs";
import { loadRole } from "./role.mjs";
import { refuse } from "./errors.mjs";
import { isPlainObject, canonicalJson } from "./util.mjs";
import { CREATIVE_DEFINITIONS, CREATIVE_SERVICE, END_PROTECTED, HOST_SERVICE, ID_PATTERN, checkGrantScope, grantScopeOf } from "./vocabulary.mjs";
const SET_KEYS = ["revision", "instance", "op", "definition", "vars", "launch", "tracker"];
const VETO_KEYS = ["revision", "op", "name"];
const VETO_OPS = ["keep", "unkeep", "nohire", "hire"];
const SCOPE_KEYS = ["revision", "scope"];
const id = value => typeof value === "string" && ID_PATTERN.test(value);
const revisionOk = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
// Row 194 (D2.2, N3): the vars a hire may set, the ones the chief can already set on an instance.
// Every limits.* key merges by intersection, so it can only narrow.
const HIRE_VAR = key => key === "model" || key.startsWith("limits.");
// Row 256 (#1761, REQ-COS-3): the END_PROTECTED part of a delete, for one actor. A creative
// instance is the human's to delete, and any other actor is refused, as row 213 refuses it a
// change. The assistant is refused for every actor, the human included. The listing's
// endProtected is this same check for its reader, so the Agents page offers no Delete it would refuse.
function protectedRefusal(document, instance, actor) {
const { definition } = document.roles[instance];
if (!END_PROTECTED.includes(definition)) return null;
if (!CREATIVE_DEFINITIONS.includes(definition)) return { code: "organization-definition-refused" };
if (actor === document.human) return null;
return { code: "organization-creative-human-only", message: `${instance} is a ${definition} instance, which only the human deletes` };
}
// Row 194 (#1692, V5): why an instance can't be deleted by actor, checked in the design's order, or
// null. The last-listed check runs when the proposed file is validated (deletedDocument).
export function deleteRefusal(document, rolesDir, instance, actor) {
if (!Object.hasOwn(document.roles, instance)) return { code: "organization-unknown-instance" };
const entry = document.roles[instance];
const refused = protectedRefusal(document, instance, actor);
if (refused) return refused;
const references = [];
if (document.launch?.by === instance) references.push("launch.by");
for (const kind of ["delivery", "technical"]) if (document.arbiters?.[kind] === instance) references.push(`arbiters.${kind}`);
if (references.length > 0) {
return { code: "organization-instance-referenced", message: `${instance} is ${references.join(" and ")}; change that first` };
}
if (document.launch?.keep?.includes(instance)) {
return { code: "organization-instance-kept", message: `${instance} is kept; run veto unkeep first` };
}
// Row 194 (brief item 8): delete revokes the agent's store credentials before the write, so an
// instance whose every reference is `store` can go, its tracker bot included (sage-1610 b). A
// file or env reference stays a hand edit.
const refs = Object.values(entry.credentials ?? {});
if (refs.some(ref => ref?.store !== true)) {
return { code: "organization-credentials-out-of-scope", message: `${instance} holds a file or env credential; deleting it is a hand edit` };
}
return null;
}
// The document with `instance` deleted (V5 step 2). It removes roles.<instance> and the references
// that exist only for it, launch.instances and launch.limits.tokens.instances, and adds the name to
// retired. Every other reference (launch.by, the arbiters, launch.keep) refuses in deleteRefusal.
// A business whose only listed instance is this one refuses with a BusinessError.
export function deletedDocument(document, instance) {
const next = structuredClone(document);
delete next.roles[instance];
if (next.launch) {
if (next.launch.instances.length === 1 && next.launch.instances[0] === instance) {
refuse(`${instance} is the last name in launch.instances; a business needs one instance the chief may launch`);
}
next.launch.instances = next.launch.instances.filter(name => name !== instance);
const tokens = next.launch.limits?.tokens?.instances;
if (tokens && Object.hasOwn(tokens, instance)) delete tokens[instance];
}
next.retired = [...(next.retired ?? []), instance];
return next;
}
// A definition this writer can give an instance: version 2, not END_PROTECTED, and declaring no
// credential. An instance of one that declares credentials needs credentials and a tracker bot,
// which stay out of this row (sage-0918); the refusal names that, so a later row lifts it alone.
// Row 213 (C2-08, M10): a creative definition is END_PROTECTED, yet this writer is how the human
// adds and changes one, so it isn't closed here. The caller runs creativeHumanOnly first.
const closed = name => END_PROTECTED.includes(name) && !CREATIVE_DEFINITIONS.includes(name);
// Row 285 (mos-1586): derive creative from the reviewed authority data, not another role-name list.
const creativeDefinition = definition => definition.authority.withinRole.includes("library.write");
const credentialServices = definition => definition.credentials.map(c => c.service).sort();
const sameCredentialServices = (before, after) => credentialServices(before).join(",") === credentialServices(after).join(",");
// Row 194 (brief item 7): the human's add passes credentials true and lifts it. The tester's add,
// change and hire keep it.
function definitionFor(rolesDir, name, { credentials = false } = {}) {
if (closed(name)) throw new OrganizationError("organization-definition-refused");
const definition = loadRole(rolesDir, name);
if (definition.roleVersion !== 2) throw new OrganizationError("invalid-request", `role definition ${name} is version 1`);
if (!credentials && definition.credentials.length > 0) {
throw new OrganizationError("organization-credentials-out-of-scope",
`${name} declares ${definition.credentials.map(c => c.service).join(" and ")} credentials; binding them is a hand edit`);
}
return definition;
}
// Row 194 (brief item 7, sage-1605, sage-1610 a): the human's add of a credentialed definition.
// Each declared service gets a store reference dated the create day (UTC), and no file: the agent
// needs a bus restart, then row 199's issue. A vikunja definition needs args.tracker, an existing
// Vikunja bot the human names. The add makes no Vikunja account. Any other definition refuses it.
function createdCredentials(old, instance, definition, args) {
// webby-0094 N2: a host grant is no token reference (organization-config.mjs checkRoles drops it too).
const services = definition.credentials.map(c => c.service).filter(service => service !== HOST_SERVICE);
const out = {};
if (services.includes("vikunja")) {
if (!Object.hasOwn(args, "tracker")) {
throw new OrganizationError("organization-tracker-required", `${definition.name} uses vikunja; add needs tracker {bot, botId}, an existing Vikunja bot`);
}
let tracker;
try {
tracker = checkBot(args.tracker, "tracker");
} catch (e) {
if (e.name === "BusinessError") throw new OrganizationError("invalid-request", e.message);
throw e;
}
// organization-config.mjs checks the same after the write: no two instances and not the sync bot.
const taken = Object.entries(old.roles).filter(([name, entry]) => name !== instance && entry.tracker).map(([name, entry]) => [name, entry.tracker]);
if (old.tracker?.sync) taken.push(["tracker.sync", old.tracker.sync]);
for (const [name, other] of taken) {
if (other.bot === tracker.bot || other.botId === tracker.botId) {
throw new OrganizationError("organization-tracker-bot-taken", `${name} already uses bot ${other.bot} (botId ${other.botId})`);
}
}
out.tracker = tracker;
} else if (Object.hasOwn(args, "tracker")) {
throw new OrganizationError("invalid-request", `${definition.name} uses no vikunja credential, so it takes no tracker`);
}
if (services.length > 0) {
const today = new Date().toISOString().slice(0, 10);
out.credentials = Object.fromEntries(services.map(service => [service, { store: true, [DATE_KEY[service]]: today }]));
}
return out;
}
// levels(name): the thinking levels harness module `name` applies, or null (launch-settings.mjs
// checkRoutes, row 65). Row 194 (brief item 8, row 199 D13): revoke(business, instance, how,
// services) revokes the instance's credentials for a fire or a delete and throws
// credential-revoke-failed when it can't. Null in a writer with no bus (the CLI's), which revokes
// nothing. Row 239 (#1745): writer is the organization writer, injected only by tests.
export function instanceSettings({ dir, rolesDir, system, levels = () => null, revoke = null, writer = organizationWriter({ dir, rolesDir }) }) {
// The definitions in rolesDir and whether add and set accept each one.
function definitions() {
const out = {};
for (const file of readdirSync(rolesDir).filter(f => f.endsWith(".json")).sort()) {
const name = file.slice(0, -5);
let definition;
try {
definition = loadRole(rolesDir, name);
} catch (e) {
if (e.name === "BusinessError") continue;
throw e;
}
if (definition.roleVersion !== 2) continue;
const services = definition.credentials.map(c => c.service);
// mos-1586: only creative gitea-store adds join the base no-credential listing.
const creativeAdd = creativeDefinition(definition) && services.every(service => service === CREATIVE_SERVICE);
out[name] = { services, accepted: !closed(name) && (services.length === 0 || creativeAdd) };
}
return out;
}
// Row 256: actor is the reader, so endProtected is the delete check for that reader. A read with
// no actor (the bus's reloads) answers as for any non-human.
function read(business, actor) {
const { document, revision } = writer.read(business);
validateBusinessDocument(document, businessFilePath(business, dir), { rolesDir });
const launch = document.launch ?? null;
const instances = Object.fromEntries(Object.entries(document.roles).map(([name, entry]) => [name, {
definition: entry.definition,
vars: entry.vars ?? {},
launched: launch ? launch.instances.includes(name) : false,
launcher: launch ? launch.by === name : false,
protected: closed(entry.definition),
kept: (launch?.keep ?? []).includes(name),
endProtected: protectedRefusal(document, name, actor) !== null,
}]));
// Row 194 (D1): the human's forbid lists and the retired names. The bus adds running runs.
return {
business, revision, launch: launch !== null, instances, definitions: definitions(),
noHire: [...(launch?.noHire ?? [])], retired: [...(document.retired ?? [])],
};
}
// args: { revision, instance, op: "add" | "change" | "delete", definition?, vars?, launch?: boolean }.
// add needs definition; change needs at least one of definition, vars and launch. vars replaces
// the instance's vars whole. Row 194 (D3): the writer's restart is false. The bus reloads the
// business after the write and sets restart from that reload (runtime.mjs).
function set(business, args, actor, catalogue = null) {
if (!isPlainObject(args) || Object.keys(args).some(key => !SET_KEYS.includes(key))) {
throw new OrganizationError("invalid-request");
}
if (typeof args.revision !== "string" || !/^[a-f0-9]{64}$/.test(args.revision)) {
throw new OrganizationError("invalid-request");
}
const { instance, op } = args;
if (!id(instance) || !["add", "change", "delete"].includes(op)) throw new OrganizationError("invalid-request");
// The broker refuses a business whose roles name human, tester or user (broker.mjs, the load
// check), so an instance under any of them would stop the bus at its next start (row 165 r2,
// Dewey F1; FED R3, dewey-0777).
if (instance === "human" || instance === "tester" || instance === "user") {
throw new OrganizationError("invalid-request", `${instance} names the bus's own sessions, never a role instance`);
}
if (Object.hasOwn(args, "definition") && !id(args.definition)) throw new OrganizationError("invalid-request");
if (Object.hasOwn(args, "vars") && !isPlainObject(args.vars)) throw new OrganizationError("invalid-request");
if (Object.hasOwn(args, "launch") && typeof args.launch !== "boolean") throw new OrganizationError("invalid-request");
const old = writer.read(business).document;
if (op === "delete") {
if (Object.keys(args).some(key => !["revision", "instance", "op"].includes(key))) throw new OrganizationError("invalid-request");
const services = Object.keys(old.roles[instance]?.credentials ?? {});
const beforeWrite = revoke ? () => revoke(business, instance, "delete", services) : null;
const result = commit(business, old, planDelete(old, instance, actor), { revision: args.revision, actor, beforeWrite, catalogue });
// sage-1610 (b): the bot user and any token it holds live in Vikunja, which this write
// can't reach (row 199 A5).
const bot = old.roles[instance]?.tracker?.bot;
return bot ? { ...result, tracker: { bot, removeByHand: `remove the Vikunja bot user ${bot} and any token it holds by hand in Vikunja` } } : result;
}
const next = structuredClone(old);
let creativeChange = null;
const exists = Object.hasOwn(next.roles, instance);
if (op === "add") {
if (!Object.hasOwn(args, "definition")) throw new OrganizationError("invalid-request");
if (exists) throw new OrganizationError("organization-instance-exists");
creativeHumanOnly(old, actor, instance, args.definition);
if ((old.retired ?? []).includes(instance)) throw new OrganizationError("organization-instance-retired");
// Only the human's add binds credentials. The tester keeps the refusal (row 175 W2): the bus
// names the tester's actor "tester", which no human or role may be called.
const definition = definitionFor(rolesDir, args.definition, { credentials: actor !== "tester" });
next.roles[instance] = { definition: args.definition, ...(Object.hasOwn(args, "vars") ? { vars: structuredClone(args.vars) } : {}) };
Object.assign(next.roles[instance], createdCredentials(old, instance, definition, args));
} else {
if (Object.hasOwn(args, "tracker")) throw new OrganizationError("invalid-request", "tracker is set only by add");
if (!["definition", "vars", "launch"].some(key => Object.hasOwn(args, key))) throw new OrganizationError("invalid-request");
if (!exists) throw new OrganizationError("organization-unknown-instance");
const entry = next.roles[instance];
// An assistant's settings change only through limits.set's assistants key (row 155), so
// nothing here touches a protected instance, its vars and launch membership included. A
// creative instance is the exception: the human changes it here, and only the human (M10).
creativeHumanOnly(old, actor, instance, entry.definition);
if (closed(entry.definition)) throw new OrganizationError("organization-definition-refused");
if (Object.hasOwn(args, "definition") && args.definition !== entry.definition) {
creativeHumanOnly(old, actor, instance, args.definition);
const before = loadRole(rolesDir, entry.definition);
// mos-1585: a declaration alone is no binding (the chief without a library holds none).
const holdsCredentials = Object.keys(entry.credentials ?? {}).length > 0;
const creative = holdsCredentials && creativeDefinition(before);
const after = definitionFor(rolesDir, args.definition, { credentials: creative });
if (holdsCredentials && !creative) {
throw new OrganizationError("organization-credentials-out-of-scope",
`${instance} holds ${entry.definition} credentials; moving them to another definition is a hand edit`);
}
if (creative) creativeChange = { before, after };
entry.definition = args.definition;
}
if (Object.hasOwn(args, "vars")) entry.vars = structuredClone(args.vars);
}
if (Object.hasOwn(args, "launch")) {
if (!next.launch) throw new OrganizationError("organization-launch-not-configured");
const listed = next.launch.instances;
if (!args.launch) next.launch.instances = listed.filter(name => name !== instance);
else if (!listed.includes(instance)) listed.push(instance);
}
if (creativeChange) {
// mos-1586: validate the complete proposal first, preserving base BusinessError refusals.
validateBusinessDocument(next, businessFilePath(business, dir), { rolesDir });
if (!creativeDefinition(creativeChange.after) || !sameCredentialServices(creativeChange.before, creativeChange.after)) {
throw new OrganizationError("organization-credentials-out-of-scope",
`${instance} may retain credentials only between creative definitions with exactly the same services`);
}
}
return commit(business, old, next, { revision: args.revision, actor, catalogue });
}
// Row 194 (V5): the delete's checks against the file as it stands, and its document. The broker
// runs this at the verb with no write, and the launcher's pass runs set op delete after the take.
function planDelete(document, instance, actor) {
const refusal = deleteRefusal(document, rolesDir, instance, actor);
if (refusal) throw new OrganizationError(refusal.code, refusal.message);
return deletedDocument(document, instance);
}
function checkDelete(business, instance, actor) {
const { document, revision } = writer.read(business);
const next = planDelete(document, instance, actor);
validateBusinessDocument(next, businessFilePath(business, dir), { rolesDir });
return { revision };
}
// Row 194 (V6): args { revision, op: keep | unkeep | nohire | hire, name }. keep needs a declared
// instance that isn't END_PROTECTED. nohire needs only the id pattern. Human-only at the broker.
function veto(business, args, actor) {
if (!isPlainObject(args) || Object.keys(args).some(key => !VETO_KEYS.includes(key))) throw new OrganizationError("invalid-request");
if (!revisionOk(args.revision) || !VETO_OPS.includes(args.op) || !id(args.name)) throw new OrganizationError("invalid-request");
const old = writer.read(business).document;
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
const next = structuredClone(old);
const { op, name } = args;
const key = op === "keep" || op === "unkeep" ? "keep" : "noHire";
const list = next.launch[key] ?? [];
if (op === "keep") {
if (!Object.hasOwn(next.roles, name)) throw new OrganizationError("organization-unknown-instance");
if (END_PROTECTED.includes(next.roles[name].definition)) throw new OrganizationError("organization-definition-refused");
}
const add = op === "keep" || op === "nohire";
if (add === list.includes(name)) throw new OrganizationError("organization-no-change");
next.launch[key] = add ? [...list, name] : list.filter(entry => entry !== name);
return commit(business, old, next, { revision: args.revision, actor });
}
// Row 298 (#1804, Q228): the chief's grant scope as the file stands. scope is the one the broker
// applies, so a bad value reads as {} with fallback true.
function grantScopeRead(business) {
const { document, revision } = writer.read(business);
if (!document.launch) throw new OrganizationError("organization-launch-not-configured");
return { business, revision, ...grantScopeOf(document.launch.grantScope) };
}
// Row 298: args { revision, scope }. Human-only at the broker (grants.scope.set), and the only
// writer of launch.grantScope: SET_KEYS and the organization.set keys don't name it. The scope is
// checked strictly here; {} removes the key. old is what the broker applied before the write,
// the fallback included.
function grantScope(business, args, actor) {
if (!isPlainObject(args) || Object.keys(args).some(key => !SCOPE_KEYS.includes(key))) throw new OrganizationError("invalid-request");
if (!revisionOk(args.revision)) throw new OrganizationError("invalid-request");
const scope = checkGrantScope(args.scope);
if (scope === null) throw new OrganizationError("invalid-request", "scope is not a grant scope");
const old = writer.read(business).document;
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
const next = structuredClone(old);
if (Object.keys(scope).length === 0) delete next.launch.grantScope;
else next.launch.grantScope = scope;
const before = grantScopeOf(old.launch.grantScope).scope;
return { ...commit(business, old, next, { revision: args.revision, actor }), scope, old: before };
}
// Row 194 (#1692, D2.2): the chief's hire, after the broker's identity, grant, allowlist and
// hireable checks. args { name, definition, vars? }. A new name is added and listed. A fired name
// (declared, not listed) is listed again only with its own definition and no vars. The writer
// reads the file as it stands, so a veto or retire written since the broker's copy still counts.
function prepareHire(business, args) {
const { document: old, revision } = writer.read(business);
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
const { name, definition } = args;
// FED R3 r4 (sage-2557): and user, the granted user's principal, which the broker's load check refuses.
if (!id(name) || !id(definition) || name === "human" || name === "tester" || name === "user") throw new OrganizationError("invalid-request");
if (Object.hasOwn(args, "vars") && (!isPlainObject(args.vars) || !Object.keys(args.vars).every(HIRE_VAR))) {
throw new OrganizationError("invalid-request", "hire vars may set only model and limits keys");
}
if ((old.launch.noHire ?? []).includes(definition)) throw new OrganizationError("definition-not-hireable");
// Row 213 (M10): definitionFor lets a creative definition through for the human's add and
// change. A hire is the chief's, so it refuses one here.
if (CREATIVE_DEFINITIONS.includes(definition)) {
throw new OrganizationError("definition-not-hireable", `${definition} is a creative definition, which only the human adds`);
}
if ((old.retired ?? []).includes(name)) throw new OrganizationError("organization-instance-retired");
if ((old.launch.keep ?? []).includes(name)) throw new OrganizationError("instance-kept");
const next = structuredClone(old);
if (Object.hasOwn(old.roles, name)) {
const fired = !old.launch.instances.includes(name) && old.launch.by !== name;
if (!fired || old.roles[name].definition !== definition || Object.hasOwn(args, "vars")) {
throw new OrganizationError("organization-instance-exists");
}
} else {
next.roles[name] = { definition, ...(Object.hasOwn(args, "vars") ? { vars: structuredClone(args.vars) } : {}) };
}
// mos-1585: duplicates refuse before definition eligibility, with no file or credential effect.
definitionFor(rolesDir, definition);
next.launch.instances.push(name);
return { old, next, revision };
}
// Trusted read-only lifecycle preparation; the writer uses the same checks again.
function checkHire(business, args) {
return { revision: prepareHire(business, args).revision };
}
function hire(business, args, { actor, context }) {
const { old, next, revision } = prepareHire(business, args);
return commit(business, old, next, { revision, actor, context });
}
// Row 194 (D2.2 step B): the fire's delist. An instance already delisted is done, so a repeated
// fire after a failed step C goes on to consume (webby-0011 B2). The checks repeat against the file.
function fire(business, args, { actor, context }) {
const { document: old, revision } = writer.read(business);
const { name } = args;
fireChecks(old, name);
// Item 8: fire revokes each service of the entry and keeps the store directory (sage-1593).
const services = Object.keys(old.roles[name].credentials ?? {});
// webby-0094 N1: an instance already delisted (a hand edit, or a repeat after step B) still has
// its tokens revoked, with no write. A revoke is safe to repeat.
if (!old.launch.instances.includes(name)) {
const revoked = revoke ? revoke(business, name, "fire", services) : null;
return { ...read(business, actor), transaction: null, delisted: false, ...(revoked ? { credentials: revoked } : {}) };
}
const next = structuredClone(old);
next.launch.instances = old.launch.instances.filter(entry => entry !== name);
let revoked = null;
const beforeWrite = revoke ? () => { revoked = revoke(business, name, "fire", services); } : null;
const result = commit(business, old, next, { revision, actor, context, beforeWrite });
return { ...result, delisted: true, ...(revoked ? { credentials: revoked } : {}) };
}
// Row 194 b6 (webby-0094 B1): the broker runs fire's checks against the file before step A stops
// anything, so a keep its own copy hasn't loaded (a refused reload) refuses there and not at
// step B, which would leave the instance firing. No write.
function checkFire(business, name) {
const { document, revision } = writer.read(business);
fireChecks(document, name);
return { revision };
}
function fireChecks(old, name) {
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
if (!id(name) || !Object.hasOwn(old.roles, name)) throw new OrganizationError("organization-unknown-instance");
if ((old.launch.keep ?? []).includes(name)) throw new OrganizationError("instance-kept");
if (END_PROTECTED.includes(old.roles[name].definition)) throw new OrganizationError("organization-definition-refused");
if (old.launch.by === name) throw new OrganizationError("organization-instance-referenced", `${name} is launch.by`);
if (old.launch.instances.includes(name) && old.launch.instances.length === 1) {
refuse(`${name} is the last name in launch.instances; a business needs one instance the chief may launch`);
}
}
// The checks every write runs, then the write, which runs beforeWrite (a fire's or a delete's
// revoke) under its lock (row 239). A check that refuses revokes nothing, and a revoke that throws
// writes nothing. beforeWrite must not write this business: the writer holds its lock.
function commit(business, old, next, { revision, actor, context, beforeWrite = null, catalogue = null }) {
if (canonicalJson(next) === canonicalJson(old)) throw new OrganizationError("organization-no-change");
// The checks mosaic business validate runs, before the writer publishes anything, then the
// route checks every write of the route vars runs (row 65). A file that no longer validates
// has every route checked.
const proposed = validateBusinessDocument(next, businessFilePath(business, dir), { rolesDir });
let before = null;
try {
before = validateBusinessDocument(old, businessFilePath(business, dir), { rolesDir });
} catch (e) {
if (e.name !== "BusinessError") throw e;
}
checkRoutes({ proposed, old: before, levels, catalogue });
for (const [project, entry] of Object.entries(proposed.projects)) {
if (entry.kind === 'library') continue;
if (!existsSync(projectFilePath(entry.root))) continue;
const loaded = loadProject(entry.root);
if (loaded.id !== project) throw new OrganizationError("organization-project-mismatch");
for (const name of Object.keys(proposed.roles)) {
try {
resolveInstance({ system, business: proposed, project: loaded, instance: name });
} catch (e) {
if (e.name === "BusinessError") throw new OrganizationError("organization-unresolvable-instance", e.message);
throw e;
}
}
}
// Row 194 N1 (sage-1634): a delete writes on the revision its request read when it was raised,
// so a write since then makes it stale. This early check refuses it without taking the lock. The
// writer checks again under its lock, before the revoke (row 239), and that check is what keeps
// a stale fire or delete from revoking.
if (writer.read(business).revision !== revision) throw new OrganizationError("revision-conflict", "organization revision changed; read again");
const receipt = writer.write(business, next, { revision, actor, context, beforeWrite });
return { ...read(business, actor), transaction: receipt.transaction, restart: false };
}
return { writer, read, set, checkDelete, checkFire, checkHire, veto, hire, fire, grantScope, grantScopeRead };
}