// The variable key registry (REQ-VAR-1, note section 2, addendum A section // 7). Every key is declared here once, with its type, the layers allowed to // set it and its merge rule. A key that isn't here refuses, and so does a // key set at a layer it isn't allowed in. // // Layers, least to most specific: // system ~/.config/mosaic-dev/config.json, resolved by mosaic-config.mjs // business vars in businesses/.json // project vars in /.mosaic/project.json, then that file's // roles..vars (still the project layer, applied after) // agent roles..vars in the business file // // Merge rules: // replace the most specific layer that sets the key wins // intersect every layer that sets the key narrows it; nothing widens import { refuse } from "./errors.mjs"; import { ACTIONS, NETWORKS, TOOLS } from "./vocabulary.mjs"; import { isPlainObject, requireDistinctList, requirePositiveInt, requireString } from "./util.mjs"; export const LAYERS = Object.freeze(["system", "business", "project", "agent"]); const BRANCH = /^(?!-)(?!.*\.\.)(?!.*\/\/)(?!.*\.lock$)[A-Za-z0-9._/-]{1,100}(? requireString(v, `variable ${key}`, { max }); } function oneOf(values) { return (v, key) => { if (!values.includes(v)) refuse(`variable ${key} must be one of: ${values.join(", ")} (got ${JSON.stringify(v)})`); return v; }; } function integer(min) { return (v, key) => { if (!Number.isSafeInteger(v) || v < min) refuse(`variable ${key} must be an integer of at least ${min} (got ${JSON.stringify(v)})`); return v; }; } // http or https, no user info, query or fragment, no trailing slash. function baseUrl(v, key) { requireString(v, `variable ${key}`, { max: 300 }); let url; try { url = new URL(v); } catch { refuse(`variable ${key} must be an http or https URL (got ${JSON.stringify(v)})`); } if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash || v.endsWith("/")) { refuse(`variable ${key} must be an http or https base URL with no credentials, query, fragment or trailing slash`); } return v; } function branch(v, key) { if (typeof v !== "string" || !BRANCH.test(v)) refuse(`variable ${key} must be a git branch name (got ${JSON.stringify(v)})`); return v; } function list(check, { nonEmpty = true } = {}) { return (v, key) => requireDistinctList(v, `variable ${key}`, (item) => check(item, `${key} entry`), { nonEmpty }); } function boolean(v, key) { if (typeof v !== "boolean") refuse(`variable ${key} must be true or false (got ${JSON.stringify(v)})`); return v; } function absolutePath(v, key) { if (typeof v !== "string" || !v.startsWith("/") || v.includes("\0")) refuse(`variable ${key} must be an absolute path`); return v; } const def = (layers, check, extra = {}) => Object.freeze({ layers: Object.freeze(layers), merge: "replace", check, default: undefined, ...extra }); export const REGISTRY = Object.freeze({ // System. mosaic-config.mjs owns their validation; these checks only // keep the resolver honest about types. environment: def(["system"], string(64)), dataRoot: def(["system"], absolutePath), "execution.backend": def(["system"], string(64)), "execution.provider": def(["system"], string(64)), "execution.model": def(["system"], string(200)), "execution.adapter": def(["system"], string(64)), // Business. "tracker.kind": def(["business"], oneOf(["vikunja"]), { default: "vikunja" }), "tracker.baseUrl": def(["business"], baseUrl), "tracker.reconcileMinutes": def(["business"], integer(1), { default: 60 }), "tracker.pollSeconds": def(["business", "project"], integer(10), { default: 30 }), "human.discordUserId": def(["business"], (v, key) => { if (typeof v !== "string" || !/^[1-9][0-9]{16,19}$/.test(v)) refuse(`variable ${key} must be a Discord user id, 17 to 20 digits as a string`); return v; }), "gitea.baseUrl": def(["business"], baseUrl), // On: the bus binds a tester from tester-cli.mjs for PM tests (row 110). Read at bus start. "bus.tester": def(["business"], boolean, { default: false }), // Project. "tracker.project": def(["project"], (v, key) => requirePositiveInt(v, `variable ${key}`)), // On: the tracker project is the root of a tree, and sync reads its descendants too. "tracker.descendants": def(["project"], boolean, { default: false }), "git.workingBranch": def(["project"], branch), "git.protectedBranches": def(["project"], list(branch)), suites: def(["project"], list(string(300))), "issues.repo": def(["project"], (v, key) => { if (typeof v !== "string" || !/^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/.test(v)) refuse(`variable ${key} must be owner/name`); return v; }), // Agent: one role instance. harness: def(["agent"], oneOf(["pi", "claude-code"])), model: def(["agent"], string(200)), thinking: def(["agent"], oneOf(["off", "minimal", "low", "medium", "high", "xhigh"])), // Limits narrow the role definition's ceilings. limits.authority is an // allowlist: a role action it doesn't name becomes gated. "limits.tools": def(["business", "project", "agent"], list((v, key) => { if (!TOOLS.includes(v)) refuse(`variable ${key} names an unsupported tool: ${JSON.stringify(v)}`); }, { nonEmpty: false }), { merge: "intersect" }), "limits.network": def(["business", "project", "agent"], oneOf(NETWORKS), { merge: "intersect" }), "limits.authority": def(["business", "project", "agent"], list((v, key) => { if (!ACTIONS.includes(v)) refuse(`variable ${key} names an unknown action: ${JSON.stringify(v)}`); }, { nonEmpty: false }), { merge: "intersect" }), }); // Check one layer's vars object. `where` names the file and path for the // message. Returns a frozen copy with checked values. export function checkVars(vars, layer, where) { if (!LAYERS.includes(layer)) throw new Error(`unknown layer ${layer}`); if (!isPlainObject(vars)) refuse(`${where} must be a JSON object`); const out = {}; for (const [key, value] of Object.entries(vars)) { const entry = Object.hasOwn(REGISTRY, key) ? REGISTRY[key] : null; if (!entry) refuse(`${where}: unknown variable ${JSON.stringify(key)}`); if (!entry.layers.includes(layer)) refuse(`${where}: variable ${key} can't be set at the ${layer} layer (allowed: ${entry.layers.join(", ")})`); out[key] = entry.check(value, key); } return Object.freeze(out); } function narrower(a, b) { return NETWORKS.indexOf(a) <= NETWORKS.indexOf(b) ? a : b; } // Merge checked layers, given least specific first as [{ layer, source, // vars }]. Returns { vars, provenance }. provenance[key] is the source // that set a replace key, or the list of sources that narrowed a limit. export function mergeVars(layers) { const vars = {}; const provenance = {}; for (const [key, entry] of Object.entries(REGISTRY)) { if (entry.default !== undefined) { vars[key] = entry.default; provenance[key] = "default"; } } for (const { source, vars: layerVars } of layers) { for (const [key, value] of Object.entries(layerVars)) { const entry = REGISTRY[key]; if (entry.merge === "replace" || vars[key] === undefined) { vars[key] = value; provenance[key] = entry.merge === "replace" ? source : [source]; } else if (key === "limits.network") { vars[key] = narrower(vars[key], value); provenance[key].push(source); } else { vars[key] = vars[key].filter((item) => value.includes(item)); provenance[key].push(source); } } } return { vars, provenance }; }