// 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 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. 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, dataRoot:system?.dataRoot ?? null }) }) { // 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 (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); if(args.revision!==undefined && args.revision!==revision)throw new OrganizationError("revision-conflict"); // 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)) { context?.beforeWrite?.(); 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 = () => { context?.beforeWrite?.(); if(revoke)revoked = revoke(business, name, "fire", services); context?.beforeWrite?.(); }; 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, beforePublish:context?.beforePublish }); return { ...read(business, actor), transaction: receipt.transaction, restart: false }; } return { writer, read, set, checkDelete, checkFire, veto, fire, grantScope, grantScopeRead }; }