Files
jason.woltje 967025f749 fix(routes): fixture routes, thinking and limits save without a model catalogue (P0-4)
A fixture-only organization reads a ready catalogue with no model proxy
client: the catalogue asks the proxy only when a module calls it.
A thinking-only change, and a limits patch that keeps harness, model and
entry, no longer asks the catalogue. A change that truly needs it still
refuses, and the WebUI names the next step: keep the model and change
thinking, or press Disable model proxy catalogue. The Routing switch
follows whether cliproxy is listed and removes or adds only cliproxy.
2026-11-11 13:35:55 -06:00

415 lines
27 KiB
JavaScript

// Shared human settings projection/update (REQ-LIMIT-1), used by the trusted broker.
import { organizationWriter, OrganizationError } from "./organization.mjs";
import { validateBusinessDocument, businessFilePath } from "./organization-config.mjs";
import { resolveInstance } from "./resolve.mjs";
import { usageCounts } from "./launch.mjs";
import { canonicalJson, isPlainObject, requireObject, rejectUnknownKeys } from "./util.mjs";
import { FALLBACK_VARS } from "./vars.mjs";
import { THREAD_DEFINITION, CREATIVE_DEFINITIONS } from "./vocabulary.mjs";
import { isDailyModelQuota } from "../../plugins/src/model-quota.mjs";
// Row 213 (C2-08, Case 2 design 4, M10): a creative instance, its persona included, is the
// human's. A write that touches one is accepted only when its actor is the business file's
// human; the broker sets the actor to the human's id, "tester" or <definition>.<instance>.
export function creativeHumanOnly(document, actor, instance, definition) {
if (CREATIVE_DEFINITIONS.includes(definition) && actor !== document.human) {
throw new OrganizationError("organization-creative-human-only", `${instance} is a ${definition} instance, which only the human changes`);
}
}
// Row 194 (D2.4 B7, webby-0094 N3): only the human changes a kept instance's model, through the
// instance's own key or by re-pointing the launch.models entry that key names (the chief and the
// tester are refused, as row 213 refuses them a creative instance's). The check reads the file, so a
// keep the broker's copy hasn't loaded holds too.
function keptHumanOnly(document, actor, instance) {
if (actor !== document.human && (document.launch?.keep ?? []).includes(instance)) {
throw new OrganizationError("instance-kept", `${instance} is kept; only the human changes its model`);
}
}
// Row 155 (#1649, Jason's ruling Q169): the fields of an assistant instance's entry the human and
// the chief of staff may change. holder and credentials stay hand edits.
const ASSISTANT_FIELDS = ["vars", "tracker", "definition"];
// Every key a limits update takes. The broker's and the harness tool's
// ORGANIZATION_KEYS must match it (packages/harness/tests/tools.test.mjs).
export const SETTINGS_KEYS = Object.freeze(["revision", "limits", "models", "instances", "reset", "assistants"]);
const own = (value, key) => value !== null && typeof value === "object" && !Array.isArray(value) && Object.hasOwn(value, key);
const plain = value => value !== null && typeof value === "object" && !Array.isArray(value);
// Row 125 (#1619, REQ-LIMIT-3): token limits sit only at their schema depth, tokens.instances.X,
// tokens.providers.X, tokens.keys.X, tokens.models.P.M and (row 126) tokens.daily.X. Nothing else in launch.limits is one,
// so a concurrency provider or model named `limit`, or a token limit's subject named `limit`, is
// never mistaken for a leaf. The bus core's limitLeaves walks the same depths.
const DEPTH = { instances: 1, providers: 1, keys: 1, models: 2, daily: 1 };
function tokenLeaves(limits) {
const out = [];
const walk = (value, depth, path) => {
if (!plain(value)) return;
for (const [key, entry] of Object.entries(value)) {
if (depth > 1) walk(entry, depth - 1, [...path, key]);
else out.push({ path: [...path, key], entry });
}
};
const tokens = own(limits, "tokens") ? limits.tokens : undefined;
if (plain(tokens)) {
for (const [kind, value] of Object.entries(tokens)) if (Object.hasOwn(DEPTH, kind)) walk(value, DEPTH[kind], ["tokens", kind]);
}
return out;
}
const at = (value, path) => path.reduce((v, part) => (own(v, part) ? v[part] : undefined), value);
// The writer, not the caller, sets a token limit's `since`. A new limit gets now; a changed or
// unchanged one keeps its stored time; a caller-supplied time that differs from the stored one is
// refused, so only a reset moves it. An entry that isn't an object is left for the schema to refuse.
// Row 333 R4 (#1831): a new daily key quota carries its agent's count (carried, below).
function stamp(next, old, now, lineage) {
for (const { path, entry } of tokenLeaves(next)) {
if (!plain(entry)) continue;
const before = at(old, path);
const kept = plain(before) ? before.since : undefined;
if (own(entry, "since") && entry.since !== kept) throw new OrganizationError("invalid-request");
entry.since = kept ?? carried(path, entry, old, now, lineage) ?? now;
}
}
// Row 333 R4 (#1831): a new tokens.keys leaf in the daily quota shape is a quota on one agent's model
// key. It takes the earliest stored `since` of the old document's daily key quotas whose ids are in
// that key's lineage, so moving a quota from A to B after a rotation or re-issue keeps the day's
// count, whether or not the same write removes A. With none it is new and gets now (undefined
// here). lineage(id) answers the ids of the agent holding id, read from the bus's own credential
// records, or [] when no agent holds it. A lookup that throws or answers malformed refuses the write.
// A quota removed in one write and added back in a later one finds no old leaf, so it gets now.
const KEY_ID = /^[a-f0-9]{12}$/;
function carried(path, entry, old, now, lineage) {
const [, kind, id] = path;
if (kind !== "keys" || !isDailyModelQuota(id, { ...entry, since: now })) return undefined;
let ids;
try { ids = lineage(id); } catch { throw new OrganizationError("proxy-key-refused"); }
if (!Array.isArray(ids) || ids.some(x => typeof x !== "string" || !KEY_ID.test(x)) || (ids.length && !ids.includes(id))) {
throw new OrganizationError("proxy-key-refused");
}
const keys = at(old, ["tokens", "keys"]);
const times = Object.entries(plain(keys) ? keys : {})
.filter(([key, value]) => ids.includes(key) && isDailyModelQuota(key, value)).map(([, value]) => value.since);
return times.sort((a, b) => Date.parse(a) - Date.parse(b))[0];
}
// A reset names token limit paths, e.g. ["tokens", "instances", "coder-1"]; each gets since = now.
// Any other path, a concurrency limit's included, is refused.
function reset(limits, paths, now) {
if (!Array.isArray(paths) || !paths.length) throw new OrganizationError("invalid-request");
const leaves = tokenLeaves(limits);
for (const path of paths) {
if (!Array.isArray(path) || !path.length || path.some(part => typeof part !== "string")) {
throw new OrganizationError("invalid-request");
}
const leaf = leaves.find(l => l.path.length === path.length && l.path.every((part, i) => part === path[i]));
if (!leaf || !plain(leaf.entry)) throw new OrganizationError("invalid-request");
leaf.entry.since = now;
}
}
// Row 65 (REQ-PLUG-2): the checks every write of an instance's route vars runs: the routes table's,
// instances.set's and limits.set's. Each instance's model, and its fallback model when it states a
// fallback, must be a launch.models key. A route the write changes must name a harness that is
// switched on in plugins.harness (harness-off) and whose module applies its thinking level
// (thinking-unsupported). levels(name) answers the levels a harness module applies, or null when
// the module didn't load, which refuses harness-refused. An unchanged route isn't checked, so a
// model change still saves while plugins.harness is unset; that instance's launch refuses instead.
// A route var the instance doesn't set is left to the launch, which refuses no-route.
const ROUTES = Object.freeze([["harness", "model", "thinking"], FALLBACK_VARS]);
export function checkRoutes({ proposed, old = null, levels, catalogue = null }) {
const on = Array.isArray(proposed.plugins?.harness) ? proposed.plugins.harness : [];
for (const [instance, entry] of Object.entries(proposed.roles)) {
const vars = entry.vars;
const before = old && Object.hasOwn(old.roles, instance) ? old.roles[instance].vars : {};
for (const [harness, model, thinking] of ROUTES) {
// A missing model refuses no-route at launch, not here.
if (proposed.launch && Object.hasOwn(vars, model) && !Object.hasOwn(proposed.launch.models, vars[model])) {
throw new OrganizationError("unknown-model", `${instance} variable ${model} isn't a launch.models key`);
}
const currentEntry = proposed.launch?.models?.[vars[model]];
const previousEntry = old?.launch?.models?.[before[model]];
// D3 (P0-4): the catalogue answers which harness a model runs on, not its thinking level, so
// a change that keeps the harness, the model key and its entry needs no catalogue; the
// harness checks below still run on the new thinking level.
const routeChanged = [harness, model].some(field => vars[field] !== before[field]) || canonicalJson(currentEntry ?? null) !== canonicalJson(previousEntry ?? null);
if (routeChanged && previousEntry?.module && !currentEntry?.module && vars[model] === before[model]) throw new OrganizationError('model-provider-unavailable');
if (routeChanged && currentEntry?.module) {
if (catalogue?.status !== 'ready') throw new OrganizationError('model-provider-unavailable');
const offered = catalogue.models?.find(m => m.key === vars[model]);
if (!offered || !offered.available || !proposed.plugins?.['model-provider']?.includes(currentEntry.module) || canonicalJson(currentEntry) !== canonicalJson({ provider: offered.provider, model: offered.model, module: offered.module, upstream: offered.upstream })) throw new OrganizationError('model-provider-unavailable');
if (!offered.harnesses.includes(vars[harness])) throw new OrganizationError('model-harness-incompatible');
}
if (routeChanged && currentEntry && !currentEntry.module && proposed.plugins?.['model-provider']?.length) {
if (catalogue?.status !== 'ready') throw new OrganizationError('model-provider-unavailable');
const mapping = catalogue.resolveLegacy ? catalogue.resolveLegacy(currentEntry) : catalogue.legacy?.[vars[model]];
const offered = mapping && catalogue.models?.find(m => m.module === mapping.module && m.upstream === mapping.upstream && m.model === currentEntry.model);
if (!offered?.available) throw new OrganizationError('model-provider-unavailable');
if (!offered.harnesses.includes(vars[harness])) throw new OrganizationError('model-harness-incompatible');
}
if (!Object.hasOwn(vars, harness) || (vars[harness] === before[harness] && vars[thinking] === before[thinking])) continue;
const name = vars[harness];
if (!on.includes(name)) throw new OrganizationError("harness-off", `${instance} variable ${harness} isn't switched on in plugins.harness`);
if (vars[thinking] === undefined) continue;
const applies = levels(name);
if (!Array.isArray(applies)) throw new OrganizationError("harness-refused", `${instance} variable ${harness}: its harness module didn't load, so its thinking levels are unknown`);
if (!applies.includes(vars[thinking])) {
throw new OrganizationError("thinking-unsupported", `${instance} variable ${thinking}: its harness doesn't apply this thinking level; it applies ${applies.join(", ")}`);
}
}
}
}
const ROUTE_FIELDS = Object.freeze(["harness", "model", "thinking"]);
// levels(name): the thinking levels harness module `name` applies, or null (checkRoutes).
// Row 333 R4 (#1831): keyLineage(business, keyId) answers the key ids in the lineage of the agent
// that holds keyId (carried, above). The bus runtime passes one read from its credential records.
// Without it, a write that adds a daily key quota refuses proxy-key-refused; no other write asks.
export function launchSettings({ dir, rolesDir, system, sessions = () => [], now = () => new Date().toISOString(), levels = () => null,
keyLineage = () => { throw new OrganizationError("proxy-key-refused"); } }) {
const writer = organizationWriter({ dir, rolesDir, dataRoot:system?.dataRoot ?? null });
// The validated business as the file stands now (row 125: the limit checks read it at each ask).
function current(id) {
return validateBusinessDocument(writer.read(id).document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
}
// The file before a write, validated, for checkRoutes; null when it no longer validates, so every
// route is checked.
function before(id, document) {
try {
return validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
} catch (e) {
if (e.name === "BusinessError") return null;
throw e;
}
}
function view(id) {
const { document, revision } = writer.read(id);
const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
if (!business.launch) throw new OrganizationError("launch-not-configured");
const instances = Object.fromEntries(Object.keys(business.roles).map(instance => {
const vars = resolveInstance({ business, system, instance }).vars;
const key = vars.model;
// No model var reads as unset; the launch refuses it no-route.
if (key === undefined) return [instance, { harness: vars.harness ?? null, provider: null, model: null, modelKey: null }];
const entry = Object.hasOwn(business.launch.models, key) ? business.launch.models[key] : null;
if (!entry) throw new OrganizationError("unknown-model");
return [instance, { harness: vars.harness ?? null, ...entry, modelKey: key }];
}));
// Each assistant instance's entry fields as the file has them, so a change replaces what it shows.
const assistants = Object.fromEntries(Object.entries(document.roles)
.filter(([, entry]) => entry.definition === THREAD_DEFINITION)
.map(([instance, entry]) => [instance, {
definition: entry.definition, vars: entry.vars ?? {}, tracker: entry.tracker ?? null,
}]));
// model is the external id; modelKey is the table selection.
return {
business: id, revision, models: business.launch.models,
limits: business.launch.limits, instances, assistants, usage: usageCounts(sessions(), id),
};
}
function set(id, args, actor, catalogue = null) {
requireObject(args, "limits update");
rejectUnknownKeys(args, SETTINGS_KEYS, "limits update");
if (typeof args.revision !== "string" || !/^[a-f0-9]{64}$/.test(args.revision)) {
throw new OrganizationError("invalid-request");
}
if (!SETTINGS_KEYS.some(key => key !== "revision" && Object.hasOwn(args, key))) {
throw new OrganizationError("invalid-request");
}
const old = writer.read(id);
const next = structuredClone(old.document);
if (!next.launch) throw new OrganizationError("launch-not-configured");
const at = now();
if (Object.hasOwn(args, "limits")) {
next.launch.limits = structuredClone(args.limits);
stamp(next.launch.limits, old.document.launch.limits, at, key => keyLineage(id, key));
}
if (Object.hasOwn(args, "reset")) reset(next.launch.limits, args.reset, at);
if (Object.hasOwn(args, "models")) {
if (actor !== old.document.human && plain(args.models)) {
for (const [key, entry] of Object.entries(args.models)) {
if (entry?.module && canonicalJson(entry) !== canonicalJson(old.document.launch.models[key] ?? null)) throw new OrganizationError('organization-model-provider-human-only');
// Resolve changed legacy entries with the trusted module resolver; a table key
// alone does not authorize introducing a different catalogue identity.
const previous = old.document.launch.models[key];
if (canonicalJson(entry) !== canonicalJson(previous ?? null) && plain(entry) && !entry.module && old.document.plugins?.['model-provider']?.length) {
if (catalogue?.status !== 'ready' || typeof catalogue.resolveLegacy !== 'function' || !Array.isArray(catalogue.models)) throw new OrganizationError('model-provider-unavailable');
const mapping = catalogue.resolveLegacy(entry);
const prior = previous?.module
? { module: previous.module, upstream: previous.upstream, model: previous.model }
: plain(previous) ? catalogue.resolveLegacy(previous) : null;
if (mapping && canonicalJson(mapping) !== canonicalJson(prior) && catalogue.models.some(model => model.module === mapping.module && model.upstream === mapping.upstream && model.model === entry.model)) {
throw new OrganizationError('organization-model-provider-human-only');
}
}
}
}
// M10: replacing launch.models can change the model behind a key a creative instance names
// without touching the instance, so a change to or removal of such a key is the human's.
for (const [instance, entry] of Object.entries(old.document.roles)) {
const vars = entry.vars ?? {};
for (const key of [vars.model, vars["fallback.model"]]) {
if (typeof key !== "string") continue;
const was = own(old.document.launch.models, key) ? canonicalJson(old.document.launch.models[key]) : null;
const is = own(args.models, key) ? canonicalJson(args.models[key]) : null;
if (was !== is) {
creativeHumanOnly(old.document, actor, instance, entry.definition);
keptHumanOnly(old.document, actor, instance);
}
}
}
next.launch.models = args.models;
}
if (Object.hasOwn(args, "instances")) {
requireObject(args.instances, "instance model selections");
for (const [instance, key] of Object.entries(args.instances)) {
if (!Object.hasOwn(next.roles, instance) || typeof key !== "string") {
throw new OrganizationError("invalid-request");
}
// M10: a creative instance's model changes through instances.set or routes.set, whoever asks.
if (CREATIVE_DEFINITIONS.includes(next.roles[instance].definition)) {
throw new OrganizationError("organization-creative-instance", `${instance}'s model changes through instances.set or routes.set`);
}
keptHumanOnly(old.document, actor, instance);
next.roles[instance].vars = { ...next.roles[instance].vars, model: key };
}
}
// Each field given replaces that field; tracker null removes it. Only an assistant instance is a
// target, and its definition stays one the broker treats as the assistant, so the change can't
// strip END_PROTECTED's protection.
if (Object.hasOwn(args, "assistants")) {
requireObject(args.assistants, "assistant settings");
for (const [instance, entry] of Object.entries(args.assistants)) {
if (!Object.hasOwn(next.roles, instance)) throw new OrganizationError("invalid-request");
if (next.roles[instance].definition !== THREAD_DEFINITION) throw new OrganizationError("organization-not-assistant");
requireObject(entry, `assistant settings ${instance}`);
rejectUnknownKeys(entry, ASSISTANT_FIELDS, `assistant settings ${instance}`);
if (Object.keys(entry).length === 0) throw new OrganizationError("invalid-request");
if (Object.hasOwn(entry, "definition")) {
if (entry.definition !== THREAD_DEFINITION) throw new OrganizationError("organization-definition-refused");
next.roles[instance].definition = entry.definition;
}
if (Object.hasOwn(entry, "vars")) next.roles[instance].vars = requireObject(entry.vars, `assistant settings ${instance}.vars`);
if (entry.tracker === null) delete next.roles[instance].tracker;
else if (Object.hasOwn(entry, "tracker")) next.roles[instance].tracker = entry.tracker;
}
}
// No key may change another instance's definition, so none gives an instance an END_PROTECTED one
// after a decision about it was resolved (dewey-0546).
for (const [instance, entry] of Object.entries(old.document.roles)) {
if (next.roles[instance]?.definition !== entry.definition) throw new OrganizationError("organization-definition-refused");
}
// Check every instance's route before publishing the complete document.
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
checkRoutes({ proposed, old: before(id, old.document), levels, catalogue });
const receipt = writer.write(id, next, { revision: args.revision, actor });
return { ...view(id), transaction: receipt.transaction };
}
// Row 65 (mos-0840): the routing table. One row per instance with its harness, model, thinking
// and fallback, and what each cell offers: the switched-on harnesses, the launch.models keys and
// the levels each harness applies (null for a module that didn't load). A route var the instance
// doesn't set reads null; its launch refuses no-route.
function routes(id) {
const { document, revision } = writer.read(id);
const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
if (!business.launch) throw new OrganizationError("launch-not-configured");
const harnesses = Array.isArray(business.plugins?.harness) ? [...business.plugins.harness] : [];
const named = new Set(harnesses);
const instances = Object.fromEntries(Object.entries(business.roles).map(([instance, { vars }]) => {
for (const key of ["harness", "fallback.harness"]) if (Object.hasOwn(vars, key)) named.add(vars[key]);
return [instance, {
harness: vars.harness ?? null, model: vars.model ?? null, thinking: vars.thinking ?? null,
fallback: Object.hasOwn(vars, "fallback.harness")
? { harness: vars["fallback.harness"], model: vars["fallback.model"], thinking: vars["fallback.thinking"] } : null,
}];
}));
return {
business: id, revision, harnesses, models: Object.keys(business.launch.models),
providerModels: Object.fromEntries(Object.entries(business.launch.models).filter(([, entry]) => entry.module).map(([key, entry]) => [key, { module: entry.module, upstream: entry.upstream, model: entry.model }])),
levels: Object.fromEntries([...named].sort().map(name => [name, levels(name)])), instances,
};
}
// args: { revision, routes: { <instance>: { harness, model, thinking, fallback } } }, fallback
// being null or { harness, model, thinking }. Each named instance's route vars are replaced in one
// organization write; a null fallback clears all three. It is human-only and outside
// ORGANIZATION_KEYS (the broker's routes.set). The chief of staff's tool changes the model key,
// and the assistant's whole vars (Q169), through limits.set, which runs the same checkRoutes.
function setRoutes(id, args, actor, catalogue = null) {
requireObject(args, "routes update");
rejectUnknownKeys(args, ["revision", "routes"], "routes update");
if (typeof args.revision !== "string" || !/^[a-f0-9]{64}$/.test(args.revision)) {
throw new OrganizationError("invalid-request");
}
if (!isPlainObject(args.routes) || Object.keys(args.routes).length === 0) throw new OrganizationError("invalid-request");
const old = writer.read(id);
const next = structuredClone(old.document);
if (!next.launch) throw new OrganizationError("launch-not-configured");
const exact = (value, keys) => isPlainObject(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
for (const [instance, route] of Object.entries(args.routes)) {
if (!Object.hasOwn(next.roles, instance)) throw new OrganizationError("organization-unknown-instance");
creativeHumanOnly(old.document, actor, instance, next.roles[instance].definition);
// webby-0095 N1, sage-2248: a route is a second write of a kept instance's model.
keptHumanOnly(old.document, actor, instance);
if (!exact(route, [...ROUTE_FIELDS, "fallback"]) || ROUTE_FIELDS.some(key => typeof route[key] !== "string")) {
throw new OrganizationError("invalid-request");
}
const { fallback } = route;
if (fallback !== null && (!exact(fallback, ROUTE_FIELDS) || ROUTE_FIELDS.some(key => typeof fallback[key] !== "string"))) {
throw new OrganizationError("organization-partial-fallback", `${instance}'s fallback sets all of harness, model and thinking or is cleared`);
}
// The client supplies only a key. New entries come from the trusted snapshot.
const priorVars = old.document.roles[instance].vars ?? {};
for (const [choice, prefix] of [[route, ''], [fallback, 'fallback.']]) {
if (!choice) continue;
const unchanged = ROUTE_FIELDS.every(field => priorVars[prefix + field] === choice[field]);
// D3 (P0-4): a choice that keeps the harness and model key needs no catalogue, as in
// checkRoutes; only its thinking level may differ. A key the catalogue offers as a
// different model still refuses model-key-collision below on any change.
const sameRoute = ['harness', 'model'].every(field => priorVars[prefix + field] === choice[field]);
const offered = catalogue?.models?.find(m => m.key === choice.model);
const existing = next.launch.models[choice.model];
const entry = offered ? { provider: offered.provider, model: offered.model, module: offered.module, upstream: offered.upstream } : null;
if (!existing && !offered && catalogue?.reason === 'model-key-collision') throw new OrganizationError('model-key-collision');
if (!existing && !offered && catalogue?.status === 'unavailable') throw new OrganizationError('model-catalogue-unavailable');
if (!existing && !offered) throw new OrganizationError('unknown-model', `${instance} variable ${prefix}model isn't a launch.models key`);
if (existing && offered && canonicalJson(existing) !== canonicalJson(entry) && !unchanged) throw new OrganizationError('model-key-collision');
if (!existing || existing.module) {
if (sameRoute) continue;
if (catalogue?.status !== 'ready') throw new OrganizationError('model-catalogue-unavailable');
if (!offered || !offered.available || !Array.isArray(next.plugins?.['model-provider']) || !next.plugins['model-provider'].includes(offered.module)) throw new OrganizationError('model-provider-unavailable');
if (!offered.harnesses.includes(choice.harness)) throw new OrganizationError('model-harness-incompatible');
if (existing && canonicalJson(existing) !== canonicalJson(entry)) throw new OrganizationError('model-key-collision');
if (!existing) {
if (actor !== old.document.human) throw new OrganizationError('organization-model-provider-human-only');
next.launch.models[choice.model] = entry;
}
}
}
const vars = { ...next.roles[instance].vars };
for (const key of FALLBACK_VARS) delete vars[key];
for (const key of ROUTE_FIELDS) {
vars[key] = route[key];
if (fallback !== null) vars[`fallback.${key}`] = fallback[key];
}
next.roles[instance].vars = vars;
}
if (canonicalJson(next) === canonicalJson(old.document)) throw new OrganizationError("organization-no-change");
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
checkRoutes({ proposed, old: before(id, old.document), levels, catalogue });
const receipt = writer.write(id, next, { revision: args.revision, actor });
return { ...routes(id), transaction: receipt.transaction };
}
return { writer, read: view, set, current, routes, setRoutes };
}