// 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 .. 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: { : { 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 }; }