// Roles round 2, slice P1 (#1873): the primitives registry and the composition validator. // Design: docs/plans/2026-11-10_roles-and-rails-design.md sections 3, 4 and 7, and the P1 // paragraph of section 13. // // A role is a composition of the primitives in section 4 and nothing else: tools, network, // authority, credentials, messaging, skills, instructions and personality. checkComposition // checks one against a registry (the core set from vocabulary.mjs plus a plugin-declared set) in // the seven steps section 4 lists, in order, and returns the lines a human must see flagged. // // The broker and approved writer validate every hire with this registry. // // A refusal names the field and the position at fault. It quotes a value only when that value is // a registered name (a core or plugin action, tool, network, service or scope), so no role text, // and no secret placed where a name belongs, reaches an error. Free text (instructions, // personality) is never quoted. import { refuse } from "./errors.mjs"; import { ACTIONS, ROUTED, GATED_ONLY, TOOLS, NETWORKS, SERVICES, HOST_SERVICE, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE, ID_PATTERN, } from "./vocabulary.mjs"; import { NAME as PLUGIN_NAME } from "../../plugins/src/kinds.mjs"; import { deepFreeze } from "./util.mjs"; // The primitives (section 4). Every other key refuses (step 1). export const COMPOSITION_KEYS = Object.freeze([ "tools", "network", "authority", "credentials", "messaging", "skills", "instructions", "personality", ]); const REQUIRED = Object.freeze(["tools", "network", "authority", "credentials", "instructions"]); const AUTHORITY_KEYS = Object.freeze(["withinRole", "crossRole"]); const MESSAGING_KEYS = Object.freeze(["sendTo", "receiveFrom"]); const PERSONALITY_KEYS = Object.freeze(["summary", "traits", "voice"]); const CREDENTIAL_KEYS = Object.freeze(["service", "scopes"]); const PLUGIN_KEYS = Object.freeze(["name", "tools", "actions", "services", "roleTemplates"]); const PLUGIN_SERVICE_KEYS = Object.freeze(["name", "scopes"]); // Step 6's length bounds, in characters or entries. The design gives none, so P1 sets them. The // largest shipped contract is about 8 KB; instructions get twice that. T1 stores personality // with these same bounds. export const COMPOSITION_BOUNDS = Object.freeze({ instructions: 16384, summary: 280, traits: 12, trait: 40, voice: 280, messaging: 64, skills: 64, }); // Ruling 8 and R9: a hired agent cannot hire, fire or change another role. export const NEVER_HIRED = Object.freeze(["role.hire", "role.fire", "role.change"]); // The role rails (section 3): an action that lets its holder decide for other agents. export const RESOLVE_PREFIX = "decision.resolve."; export const FLAG_RESOLVE = "resolve-right"; export const FLAG_ABOVE_CHIEF = "above-chief"; const SKILL_NAME = /^[A-Za-z0-9._-]+$/; const GITEA_SCOPE = /^(read|write):([a-z]+)$/; // A plugin's declared bare name: lower-case segments joined by dots. With the prefix it is // .. const DECLARED_NAME = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/; const DECLARED_SCOPE = /^[a-z][a-z0-9_.:-]*$/; const NAME_MAX = 64; const isPlain = (value) => { if (typeof value !== "object" || value === null || Array.isArray(value)) return false; const proto = Object.getPrototypeOf(value); return proto === Object.prototype || proto === null; }; // --------------------------------------------------------------------------------------------- // The registry. const BUILT = new WeakSet(); // The first segment of every core action, tool and service. A plugin may not take one as its // name, so a prefixed plugin name can never read as a core name (git + push.protected). const RESERVED_PLUGIN_NAMES = Object.freeze([...new Set([ ...ACTIONS.map((a) => a.split(".")[0]), ...TOOLS, ...SERVICES, HOST_SERVICE, ...NETWORKS, ])]); function declaredList(value, where, pattern) { if (value === undefined) return []; if (!Array.isArray(value)) refuse(`${where} must be an array`); const seen = new Set(); value.forEach((entry, i) => { if (typeof entry !== "string" || entry.length > NAME_MAX || !pattern.test(entry)) { refuse(`${where}[${i}] must be a name matching ${pattern}`); } if (seen.has(entry)) refuse(`${where}[${i}] repeats an earlier entry`); seen.add(entry); }); return [...seen]; } // One plugin's declared set: { name, tools?: [name], actions?: [name], services?: [{ name, scopes: // [scope] }] }. Names are declared bare and registered with the plugin's prefix. A plugin action is // always routed (section 4), so it may not name a hard wait, and no declared name may reuse a core // name, bare or prefixed. function checkPlugin(plugin, i, core) { const where = `registry plugins[${i}]`; if (!isPlain(plugin)) refuse(`${where} must be a JSON object`); for (const key of Object.keys(plugin)) { if (!PLUGIN_KEYS.includes(key)) refuse(`${where} has a key outside ${PLUGIN_KEYS.join(", ")}`); } const { name } = plugin; if (typeof name !== "string" || name.length > 32 || !PLUGIN_NAME.test(name)) refuse(`${where}.name must match ${PLUGIN_NAME}`); if (RESERVED_PLUGIN_NAMES.includes(name)) refuse(`${where}.name ${JSON.stringify(name)} is a core name`); const full = (bare) => `${name}.${bare}`; const coreName = (bare) => core.has(bare) || core.has(full(bare)); const tools = declaredList(plugin.tools, `${where}.tools`, DECLARED_NAME); tools.forEach((bare, j) => { if (coreName(bare)) refuse(`${where}.tools[${j}] reuses the core name ${JSON.stringify(core.has(bare) ? bare : full(bare))}`); }); const actions = declaredList(plugin.actions, `${where}.actions`, DECLARED_NAME); actions.forEach((bare, j) => { if (GATED_ONLY.includes(bare)) refuse(`${where}.actions[${j}] declares ${bare}, a hard wait; a plugin action is always routed`); if (coreName(bare)) refuse(`${where}.actions[${j}] reuses the core name ${JSON.stringify(core.has(bare) ? bare : full(bare))}`); }); if (plugin.services !== undefined && !Array.isArray(plugin.services)) refuse(`${where}.services must be an array`); const services = (plugin.services ?? []).map((service, j) => { const at = `${where}.services[${j}]`; if (!isPlain(service)) refuse(`${at} must be a JSON object`); for (const key of Object.keys(service)) { if (!PLUGIN_SERVICE_KEYS.includes(key)) refuse(`${at} has a key outside ${PLUGIN_SERVICE_KEYS.join(", ")}`); } const [bare] = declaredList([service.name], `${at}.name`, DECLARED_NAME); if (coreName(bare)) refuse(`${at}.name reuses the core name ${JSON.stringify(core.has(bare) ? bare : full(bare))}`); const scopes = declaredList(service.scopes, `${at}.scopes`, DECLARED_SCOPE); if (scopes.length === 0) refuse(`${at}.scopes must not be empty`); return { name: full(bare), scopes }; }); const names = [...tools, ...actions, ...services.map((s) => s.name.slice(name.length + 1))]; const twice = names.find((n, j) => names.indexOf(n) !== j); if (twice !== undefined) refuse(`${where} declares ${JSON.stringify(full(twice))} twice`); if (plugin.roleTemplates !== undefined && !Array.isArray(plugin.roleTemplates)) refuse("invalid plugin role templates"); const roleTemplates=(plugin.roleTemplates ?? []).map(t=>{ if(!isPlain(t) || Object.keys(t).sort().join(",")!=="file,name" || typeof t.name!=="string" || !DECLARED_NAME.test(t.name) || coreName(t.name) || typeof t.file!=="string" || !/^[a-zA-Z0-9._/-]+$/.test(t.file) || t.file.split("/").some(p=>!p || p==="." || p==="..")) refuse("invalid plugin role template"); return {name:full(t.name),file:t.file}; }); if(new Set(roleTemplates.map(t=>t.name)).size!==roleTemplates.length)refuse("duplicate plugin role template"); return { name, tools: tools.map(full), actions: actions.map(full), services, roleTemplates }; } // The registry checkComposition reads: the core set plus each plugin's declared set. The plugin // set is the caller's input and empty by default (PL1 passes the plugins enabled in the // organization). checkComposition accepts only a registry built here. export function buildRegistry({ plugins = [] } = {}) { if (!Array.isArray(plugins)) refuse("registry plugins must be an array"); const core = new Set([...ACTIONS, ...TOOLS, ...SERVICES, HOST_SERVICE]); const declared = plugins.map((plugin, i) => checkPlugin(plugin, i, core)); const pluginNames = declared.map((p) => p.name); const twice = pluginNames.find((n, j) => pluginNames.indexOf(n) !== j); if (twice !== undefined) refuse(`registry names plugin ${JSON.stringify(twice)} twice`); const services = Object.create(null); for (const name of SERVICES) services[name] = { kind: name }; services[HOST_SERVICE] = { kind: HOST_SERVICE }; for (const p of declared) for (const s of p.services) services[s.name] = { kind: "plugin", scopes: s.scopes }; const pluginActions = declared.flatMap((p) => p.actions); const registry = deepFreeze({ tools: [...TOOLS, ...declared.flatMap((p) => p.tools)], networks: [...NETWORKS], actions: [...ACTIONS, ...pluginActions], routed: [...ROUTED, ...pluginActions], services, plugins: declared, roleTemplates: declared.flatMap(p=>p.roleTemplates), }); BUILT.add(registry); return registry; } // --------------------------------------------------------------------------------------------- // The validator. function keysWithin(object, allowed, where) { Object.keys(object).forEach((key, i) => { if (!allowed.includes(key)) refuse(`composition ${where} has an unknown key (key ${i + 1}; allowed: ${allowed.join(", ")})`); }); } // A list of distinct strings. Entries are named by position only: this runs before anything is // known to be a registered name. function stringList(value, where, { nonEmpty = false } = {}) { if (!Array.isArray(value)) refuse(`composition ${where} must be an array`); if (nonEmpty && value.length === 0) refuse(`composition ${where} must not be empty`); const seen = new Set(); for (let i = 0; i < value.length; i += 1) { if (typeof value[i] !== "string" || value[i].includes("\0")) refuse(`composition ${where}[${i}] must be a string`); if (seen.has(value[i])) refuse(`composition ${where}[${i}] repeats an earlier entry`); seen.add(value[i]); } return [...seen]; } function text(value, where) { if (typeof value !== "string" || value.includes("\0")) refuse(`composition ${where} must be a string`); return value; } // Step 1: the shape, with every unknown key refused. Returns a fresh copy; the input is not read // again after this. function shape(role) { if (!isPlain(role)) refuse("composition must be a JSON object"); keysWithin(role, COMPOSITION_KEYS, "role"); for (const key of REQUIRED) { if (role[key] === undefined) refuse(`composition requires "${key}"`); } const out = {}; out.tools = stringList(role.tools, "tools", { nonEmpty: true }); if (typeof role.network !== "string") refuse("composition network must be a string"); out.network = role.network; if (!isPlain(role.authority)) refuse("composition authority must be a JSON object"); keysWithin(role.authority, AUTHORITY_KEYS, "authority"); out.authority = {}; for (const key of AUTHORITY_KEYS) out.authority[key] = stringList(role.authority[key], `authority.${key}`); out.authority.crossRole.forEach((action, i) => { if (out.authority.withinRole.includes(action)) refuse(`composition authority.crossRole[${i}] is also in authority.withinRole`); }); if (!Array.isArray(role.credentials)) refuse("composition credentials must be an array"); out.credentials = role.credentials.map((entry, i) => { const where = `credentials[${i}]`; if (!isPlain(entry)) refuse(`composition ${where} must be a JSON object`); keysWithin(entry, CREDENTIAL_KEYS, where); if (typeof entry.service !== "string") refuse(`composition ${where}.service must be a string`); const copy = { service: entry.service }; if (entry.scopes !== undefined) { if (Array.isArray(entry.scopes)) { copy.scopes = stringList(entry.scopes, `${where}.scopes`); } else if (isPlain(entry.scopes)) { // No prototype, so a "__proto__" group stays a key and step 2 refuses it. copy.scopes = Object.create(null); Object.keys(entry.scopes).forEach((group, j) => { if (group.includes("\0")) refuse(`composition ${where}.scopes key ${j + 1} must be a string`); copy.scopes[group] = stringList(entry.scopes[group], `${where}.scopes (group ${j + 1})`); }); } else { refuse(`composition ${where}.scopes must be an array or a JSON object`); } } return copy; }); if (role.messaging === undefined) { out.messaging = null; } else { if (!isPlain(role.messaging)) refuse("composition messaging must be a JSON object"); keysWithin(role.messaging, MESSAGING_KEYS, "messaging"); out.messaging = {}; for (const key of MESSAGING_KEYS) { out.messaging[key] = stringList(role.messaging[key], `messaging.${key}`); out.messaging[key].forEach((entry, i) => { if (!ID_PATTERN.test(entry)) refuse(`composition messaging.${key}[${i}] must be a role name matching ${ID_PATTERN}`); if (entry === "tester") refuse(`composition messaging.${key}[${i}] names the tester, which is never a role`); }); } } out.skills = role.skills === undefined ? [] : stringList(role.skills, "skills"); out.skills.forEach((name, i) => { if (!SKILL_NAME.test(name) || name === "." || name === "..") refuse(`composition skills[${i}] must be a skill directory name matching ${SKILL_NAME}`); }); out.instructions = text(role.instructions, "instructions"); if (role.personality === undefined) { out.personality = null; } else { if (!isPlain(role.personality)) refuse("composition personality must be a JSON object"); keysWithin(role.personality, PERSONALITY_KEYS, "personality"); out.personality = {}; if (role.personality.summary !== undefined) out.personality.summary = text(role.personality.summary, "personality.summary"); if (role.personality.traits !== undefined) out.personality.traits = stringList(role.personality.traits, "personality.traits"); if (role.personality.voice !== undefined) out.personality.voice = text(role.personality.voice, "personality.voice"); } return out; } function checkGitea(scopes, where) { if (!Array.isArray(scopes) || scopes.length === 0) refuse(`composition ${where}.scopes must be a non-empty array of read: or write:`); const categories = new Set(); scopes.forEach((scope, i) => { const m = GITEA_SCOPE.exec(scope); if (!m || !GITEA_SCOPE_CATEGORIES.includes(m[2])) { refuse(`composition ${where}.scopes[${i}] is not a grantable gitea scope (categories: ${GITEA_SCOPE_CATEGORIES.join(", ")}; admin and all are never granted)`); } if (categories.has(m[2])) refuse(`composition ${where}.scopes[${i}] names the category ${m[2]} again; a token holds one level per category`); categories.add(m[2]); }); } function checkVikunja(scopes, where) { if (!isPlain(scopes) || Object.keys(scopes).length === 0) refuse(`composition ${where}.scopes must name at least one vikunja route group`); Object.keys(scopes).forEach((group, j) => { if (!Object.hasOwn(VIKUNJA_GRANTABLE, group)) refuse(`composition ${where}.scopes group ${j + 1} is not a grantable vikunja route group`); const verbs = scopes[group]; if (verbs.length === 0) refuse(`composition ${where}.scopes.${group} must not be empty`); verbs.forEach((verb, i) => { if (!VIKUNJA_GRANTABLE[group].includes(verb)) refuse(`composition ${where}.scopes.${group}[${i}] is not a grantable vikunja verb`); }); }); } function checkPluginScopes(scopes, declared, service, where) { if (!Array.isArray(scopes) || scopes.length === 0) refuse(`composition ${where}.scopes must be a non-empty array of ${service} scopes`); scopes.forEach((scope, i) => { if (!declared.includes(scope)) refuse(`composition ${where}.scopes[${i}] is not a scope ${service} declares`); }); } // Step 2: every action, tool, network, service and scope must be in the registry. function registered(c, registry) { c.tools.forEach((tool, i) => { if (!registry.tools.includes(tool)) refuse(`composition tools[${i}] names no registered tool`); }); if (!registry.networks.includes(c.network)) refuse(`composition network must be one of: ${registry.networks.join(", ")}`); for (const key of AUTHORITY_KEYS) { c.authority[key].forEach((action, i) => { if (!registry.actions.includes(action)) refuse(`composition authority.${key}[${i}] names no registered action`); }); } const seen = new Set(); c.credentials.forEach((entry, i) => { const where = `credentials[${i}]`; if (!Object.hasOwn(registry.services, entry.service)) refuse(`composition ${where}.service names no registered credential service`); if (seen.has(entry.service)) refuse(`composition ${where} names ${entry.service} again`); seen.add(entry.service); const { kind, scopes } = registry.services[entry.service]; if (kind === HOST_SERVICE) { if (entry.scopes !== undefined) refuse(`composition ${where} names ${HOST_SERVICE}, which takes no scopes`); } else if (kind === "gitea") { checkGitea(entry.scopes, where); } else if (kind === "vikunja") { checkVikunja(entry.scopes, where); } else { checkPluginScopes(entry.scopes, scopes, entry.service, where); } }); } function eachAction(c, fn) { for (const key of AUTHORITY_KEYS) c.authority[key].forEach((action, i) => fn(action, key, i)); } // Every string in the composition with its path, for step 6. function strings(c) { const out = []; c.tools.forEach((v, i) => out.push([`tools[${i}]`, v])); out.push(["network", c.network]); eachAction(c, (v, key, i) => out.push([`authority.${key}[${i}]`, v])); c.credentials.forEach((entry, i) => { out.push([`credentials[${i}].service`, entry.service]); if (Array.isArray(entry.scopes)) entry.scopes.forEach((v, j) => out.push([`credentials[${i}].scopes[${j}]`, v])); else if (entry.scopes) { for (const [group, verbs] of Object.entries(entry.scopes)) { out.push([`credentials[${i}].scopes (group key)`, group]); verbs.forEach((v, j) => out.push([`credentials[${i}].scopes.${group}[${j}]`, v])); } } }); if (c.messaging) for (const key of MESSAGING_KEYS) c.messaging[key].forEach((v, i) => out.push([`messaging.${key}[${i}]`, v])); c.skills.forEach((v, i) => out.push([`skills[${i}]`, v])); out.push(["instructions", c.instructions]); if (c.personality) { if (c.personality.summary !== undefined) out.push(["personality.summary", c.personality.summary]); (c.personality.traits ?? []).forEach((v, i) => out.push([`personality.traits[${i}]`, v])); if (c.personality.voice !== undefined) out.push(["personality.voice", c.personality.voice]); } return out; } function bounded(value, max, where, { empty = false } = {}) { if ((!empty && value.trim().length === 0) || value.length > max) { refuse(`composition ${where} must be ${empty ? "" : "non-empty and "}at most ${max} characters`); } } // Step 6, the bounds half. function checkBounds(c) { const b = COMPOSITION_BOUNDS; bounded(c.instructions, b.instructions, "instructions"); if (c.personality) { if (c.personality.summary !== undefined) bounded(c.personality.summary, b.summary, "personality.summary"); if (c.personality.voice !== undefined) bounded(c.personality.voice, b.voice, "personality.voice"); const traits = c.personality.traits ?? []; if (traits.length > b.traits) refuse(`composition personality.traits must hold at most ${b.traits} entries`); traits.forEach((trait, i) => bounded(trait, b.trait, `personality.traits[${i}]`)); } if (c.messaging) { for (const key of MESSAGING_KEYS) { if (c.messaging[key].length > b.messaging) refuse(`composition messaging.${key} must hold at most ${b.messaging} entries`); } } if (c.skills.length > b.skills) refuse(`composition skills must hold at most ${b.skills} entries`); } // A plain JSON copy of a caller's object, read once. Getters, proxies and toJSON run here and only // here, and whatever they throw becomes the fixed message: their error text could carry role text. function snapshot(value, message) { let copy; try { copy = isPlain(value) ? JSON.parse(JSON.stringify(value)) : undefined; } catch { copy = undefined; } if (!isPlain(copy)) refuse(message); return copy; } // The chief's resolved role, as loadRole returns it. Read only for step 7; a malformed one refuses. function checkChief(chief) { const bad = () => refuse("checkComposition needs the chief's resolved role"); if (!isPlain(chief) || !Array.isArray(chief.tools) || !NETWORKS.includes(chief.network)) bad(); if (!isPlain(chief.authority) || !AUTHORITY_KEYS.every((k) => Array.isArray(chief.authority[k]))) bad(); if (!Array.isArray(chief.credentials) || !chief.credentials.every((c) => isPlain(c) && typeof c.service === "string")) bad(); if (chief.messaging !== null && chief.messaging !== undefined && !(isPlain(chief.messaging) && MESSAGING_KEYS.every((k) => Array.isArray(chief.messaging[k])))) bad(); return chief; } // One string per credential scope a role holds: service, then scope (a vikunja scope is // group.verb; host has the one line "host"). function credentialLines(credentials) { const out = []; for (const { service, scopes } of credentials) { if (scopes === undefined) out.push([service, service]); else if (Array.isArray(scopes)) for (const scope of scopes) out.push([service, scope]); else for (const [group, verbs] of Object.entries(scopes)) for (const verb of verbs) out.push([service, `${group}.${verb}`]); } return out; } // Validate a composition (section 4's seven steps, in order) against a registry from // buildRegistry, with the chief's resolved role for step 7. options.assertClean is the credential // store's check (credentials.assertClean, as the broker's secretCheck); it throws on a held // secret. Returns a frozen { composition, flagged }, where each flagged line is // { field, value, reasons } and reasons holds "resolve-right" (step 5), "above-chief" (step 7) or // both. A composition that fails a step refuses with an OrganizationConfigError. export function checkComposition(role, registry, chief, { assertClean } = {}) { if (!BUILT.has(registry)) refuse("checkComposition needs a registry from buildRegistry"); if (typeof assertClean !== "function") refuse("checkComposition needs the credential store's assertClean"); const boss = checkChief(snapshot(chief, "checkComposition needs the chief's resolved role")); // 1. Unknown keys, and the shape. const c = shape(snapshot(role, "composition must be a JSON object")); // 2. Every name in the registry. registered(c, registry); // 3. Hard waits stay with the human (R4). From here on an action is a registered name and may be // quoted. eachAction(c, (action, key, i) => { if (!registry.routed.includes(action)) refuse(`composition authority.${key}[${i}] is ${action}, a hard wait the human always decides`); }); // 4. No hire chains (R9). eachAction(c, (action, key, i) => { if (NEVER_HIRED.includes(action)) refuse(`composition authority.${key}[${i}] is ${action}; a hired agent cannot hire or fire`); }); // 5. Every resolve right is a flagged line. const lines = []; const flag = (field, value, reason) => { const line = lines.find((l) => l.field === field && l.value === value); if (line) { if (!line.reasons.includes(reason)) line.reasons.push(reason); } else { lines.push({ field, value, reasons: [reason] }); } }; eachAction(c, (action, key) => { if (action.startsWith(RESOLVE_PREFIX)) flag(`authority.${key}`, action, FLAG_RESOLVE); }); // 6. No secret in any string (R1), and every field within its bound. for (const [where, value] of strings(c)) { let clean = true; try { assertClean(value); } catch { clean = false; } if (!clean) refuse(`composition ${where} holds a credential`); } checkBounds(c); // 7. Everything the chief's own role does not hold (R8). A within-role action is held only // within-role; a cross-role one either way. const chiefWithin = boss.authority.withinRole; const chiefAny = [...chiefWithin, ...boss.authority.crossRole]; c.authority.withinRole.forEach((action) => { if (!chiefWithin.includes(action)) flag("authority.withinRole", action, FLAG_ABOVE_CHIEF); }); c.authority.crossRole.forEach((action) => { if (!chiefAny.includes(action)) flag("authority.crossRole", action, FLAG_ABOVE_CHIEF); }); c.tools.forEach((tool) => { if (!boss.tools.includes(tool)) flag("tools", tool, FLAG_ABOVE_CHIEF); }); if (NETWORKS.indexOf(c.network) > NETWORKS.indexOf(boss.network)) flag("network", c.network, FLAG_ABOVE_CHIEF); const held = new Set(credentialLines(boss.credentials).map(([s, v]) => `${s}\n${v}`)); for (const [service, value] of credentialLines(c.credentials)) { if (!held.has(`${service}\n${value}`)) flag(`credentials.${service}`, value, FLAG_ABOVE_CHIEF); } // Both directions: the broker checks the sender's sendTo and the recipient's receiveFrom. A role // with no messaging key may message and hear anyone; "*" stands for that. if (boss.messaging) { for (const key of MESSAGING_KEYS) { const chiefList = boss.messaging[key]; if (c.messaging === null) flag(`messaging.${key}`, "*", FLAG_ABOVE_CHIEF); else c.messaging[key].forEach((target) => { if (!chiefList.includes(target)) flag(`messaging.${key}`, target, FLAG_ABOVE_CHIEF); }); } } for (const line of lines) line.reasons.sort(); return deepFreeze({ composition: c, flagged: lines }); }