Files
jason.woltje d23673cd3e feat(org): copy credential binding CB0 and CB1 (#1873)
CB0: registry incarnation minted at reserve (legacy entries get one at
the first binding proposal, under the registry lock); copyId, source
and definition pin, binding generation; a pure resolver where
effective = role intersect approved and only a bound, matching,
unshadowed record resolves.

CB1: copyBindings in the organization file (ids, scope names, project
ids and integers only). applyCopyBindingDecision is its only writer,
behind a module-private capability every other commit is fenced
against. A binding is an owner-only subtype of the hire decision:
only the chief raises it, no autonomy level, resolver agent, member
right, tester or reader resolves it. Digest, expiry, single
consumption, revision, generation, incarnation and definition pin are
rechecked before any effect; a bind writes pending, an unbind the
unbound tombstone, receipts are paths only. Nothing writes bound, so
nothing launches on a binding yet (CB2/CB3). The org service and the
Console update path keep copyBindings server-held. Eight new refusal
codes with next steps.
2026-11-11 14:27:08 -06:00

704 lines
39 KiB
JavaScript

// Trusted host write path (REQ-ROLE-3). Callers supply a verified actor;
// neither this module nor migration authenticates a new channel.
import {
constants, openSync, closeSync, fstatSync, readFileSync, writeFileSync,
fsyncSync, lstatSync, mkdirSync, renameSync, rmSync,
} from "node:fs";
import { join, dirname } from "node:path";
import { randomUUID } from "node:crypto";
import { businessFilePath, validateBusinessDocument } from "./organization-config.mjs";
import { requireId, sha256, canonicalJson, parseJsonFileText } from "./util.mjs";
import { isDailyModelQuota } from "../../plugins/src/model-quota.mjs";
import { checkReplyLimits } from "../../library/src/limits.mjs";
import { ADDITION_VERB, additionProblem } from "./project-bootstrap.mjs";
import { remoteProblem, remoteTarget } from "./project-remotes.mjs";
import { orgIncarnation, withRegistryWriter } from "./org-registry.mjs";
import { bindingText, definitionPin, samePin } from "./copy-bindings.mjs";
import { entryForm, projectAgentProblem, rootForProjectCheck } from "./agent-levels-write.mjs";
import { checkDefinition, checkDefinitionName, instancesNaming, readTemplate } from "./definitions.mjs";
export class OrganizationError extends Error {
constructor(code, message = code) {
super(message);
this.code = code;
}
}
const fail = (code, message) => {
throw new OrganizationError(code, message);
};
// #1873 CB1: the capability only applyCopyBindingDecision holds. Every other commit refuses a change
// to `copyBindings`, whatever options its caller spreads in (write() passes its caller's options).
const BINDING_WRITE = Symbol("copy-binding-write");
const bindingsFence = (old, document, capability) => {
if (capability !== BINDING_WRITE && canonicalJson(document?.copyBindings ?? null) !== canonicalJson(old.copyBindings ?? null)) {
fail("organization-bindings-protected", "copyBindings change only through an owner-approved binding decision");
}
};
function regular(file) {
let fd;
try {
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o022)) {
fail("organization-refused", "organization file must be regular, owned and not group/world writable");
}
return readFileSync(fd, "utf8");
} finally {
if (fd !== undefined) closeSync(fd);
}
}
function directory(path) {
try {
mkdirSync(path, { mode: 0o700 });
syncDir(dirname(path));
} catch (e) {
if (e.code !== "EEXIST") throw e;
}
const st = lstatSync(path);
if (!st.isDirectory() || st.uid !== process.getuid() || (st.mode & 0o077)) {
fail("organization-refused", "organization evidence directory is unsafe");
}
}
function syncDir(dir) {
const fd = openSync(dir, constants.O_RDONLY | constants.O_DIRECTORY);
try {
fsyncSync(fd);
} finally {
closeSync(fd);
}
}
function durable(file, bytes) {
const flags = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW;
const fd = openSync(file, flags, 0o600);
try {
writeFileSync(fd, bytes);
fsyncSync(fd);
} finally {
closeSync(fd);
}
}
function changes(old, next, path = "") {
if (canonicalJson(old) === canonicalJson(next)) return [];
const objects = old && next && typeof old === "object" && typeof next === "object";
if (objects && !Array.isArray(old) && !Array.isArray(next)) {
const keys = [...new Set([...Object.keys(old), ...Object.keys(next)])];
return keys.flatMap(k => changes(old[k], next[k], path ? path + "." + k : k));
}
return [{
path, old: old ?? null, new: next ?? null,
oldPresent: old !== undefined, newPresent: next !== undefined,
}];
}
// P5/Q222: every writer protects per-model-key quotas, including removal/reset.
function modelQuotaFence(old, document, actor) {
const keys = doc => Object.fromEntries(Object.entries(doc?.launch?.limits?.tokens?.keys ?? {})
.filter(([key, entry]) => isDailyModelQuota(key, entry)));
if (canonicalJson(keys(old)) === canonicalJson(keys(document))) return;
const agents = Object.entries(old.roles ?? {}).flatMap(([instance, entry]) => [instance, `${entry?.definition}.${instance}`]);
if (actor === old.human && actor !== "tester" && !agents.includes(actor)) return;
fail("organization-model-quota-human-only");
}
// P5: the optional queue reader and polling setting are human changes even on
// the tester organization path. This guards every writer, not only the UI verb.
function usageFence(old, document, actor) {
const setting = doc => ({ plugin: doc?.plugins?.usage ?? null, usage: doc?.usage ?? null });
if (canonicalJson(setting(old)) === canonicalJson(setting(document))) return;
const agents = Object.entries(old.roles ?? {}).flatMap(([instance, entry]) => [instance, `${entry?.definition}.${instance}`]);
if (actor === old.human && actor !== "tester" && !agents.includes(actor)) return;
fail("organization-usage-human-only");
}
function autonomyFence(old, document, actor) {
if(canonicalJson(old.autonomy)===canonicalJson(document.autonomy))return;
const agents=Object.entries(old.roles??{}).flatMap(([n,e])=>[n,`${e.definition}.${n}`]);
if(actor!==old.human || actor==='tester' || agents.includes(actor))fail('human-required');
}
function systemTypesFence(old, document, actor) {
if (canonicalJson(old.plugins?.['system-type'] ?? []) === canonicalJson(document?.plugins?.['system-type'] ?? [])) return;
const agents = Object.entries(old.roles ?? {}).flatMap(([name, role]) => [name, `${role?.definition}.${name}`]);
if (actor === old.human && actor !== 'tester' && !agents.includes(actor)) return;
fail('organization-system-types-human-only');
}
// C2-06 M11: replyLimits is the human's alone. Any setter that changes it refuses unless the actor is
// the business's human as the file stood before the write, and no agent or the tester under that
// name. Compared as M9 compares projects, on the document and again on what lands on disk.
function replyLimitsFence(old, document, actor) {
if (canonicalJson(document?.replyLimits) === canonicalJson(old.replyLimits)) return;
const agents = Object.entries(old.roles ?? {}).map(([instance, entry]) => `${entry?.definition}.${instance}`);
if (actor === old.human && actor !== "tester" && !agents.includes(actor)) return;
// The reason names each changed key and its value in force (the brief's M11).
const plain = v => v !== null && typeof v === "object" && !Array.isArray(v);
const before = old.replyLimits, after = document?.replyLimits;
const named = plain(before) && plain(after)
? [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(k => canonicalJson(before[k]) !== canonicalJson(after[k]))
.map(k => `replyLimits.${k} is ${canonicalJson(before[k]) ?? "unset"}`)
: [`replyLimits is ${canonicalJson(before) ?? "unset"}`];
fail("organization-reply-limits-human-only", `${named.join(", ").slice(0, 240)}; only the human changes it`);
}
// C2-06: library.limits.set, the human's verb. It replaces the whole replyLimits object; a key left out
// is its default. An unknown key or a value outside the table refuses invalid-request, and the write's
// M11 fence refuses any actor but the human.
export function setReplyLimits(writer, id, args, actor) {
const replyLimits = checkReplyLimits(args?.replyLimits);
if (replyLimits === null || typeof args?.revision !== "string") fail("invalid-request", "replyLimits takes requireCitation and citationsPerReply only");
const { document } = writer.read(id);
return writer.write(id, { ...document, replyLimits }, { revision: args.revision, actor });
}
export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = () => {} }) {
const evidence = join(dir, "businesses", ".history");
const logFile = id => join(evidence, id, "changes.jsonl");
// Keep the writer's established busy refusal when the shared registry lock
// is held, including when another document hold is already in progress.
function withWriterRegistry(fn) {
try { return withRegistryWriter(dir, fn); }
catch (e) { if (e.code === "org-locked") fail("organization-busy", "organization writer is busy"); throw e; }
}
// Reads and launches depend on the whole document, not transaction evidence.
function read(id) {
const file = businessFilePath(id, dir);
const bytes = regular(file);
const document = parseJsonFileText(bytes, file, "organization file");
validateBusinessDocument(document, file, { rolesDir, dataRoot });
return { document, revision: sha256(bytes) };
}
// C2-06: the citation guard reads replyLimits fresh on every check, from the raw file, so a business
// file that fails validation still yields its limits. { readable: false } when the file is missing,
// unreadable, not JSON or not an object.
function readRaw(id) {
try {
const document = JSON.parse(regular(businessFilePath(id, dir)));
return document !== null && typeof document === "object" && !Array.isArray(document)
? { readable: true, document } : { readable: false };
} catch {
return { readable: false };
}
}
function withLock(id, fn) {
const parent = join(dir, "businesses");
directory(evidence);
const home = join(evidence, id);
directory(home);
const lock = join(home, "write.lock");
try {
mkdirSync(lock, { mode: 0o700 });
durable(join(lock, "owner.json"), JSON.stringify({ pid: process.pid }));
syncDir(home);
} catch (e) {
if (e.code !== "EEXIST") throw e;
const st = lstatSync(lock);
if (!st.isDirectory() || st.uid !== process.getuid() || (st.mode & 0o077)) {
fail("organization-refused", "unsafe writer lock");
}
let owner;
try {
owner = JSON.parse(regular(join(lock, "owner.json")));
} catch {
fail("organization-uncertain", "writer lock has no verifiable owner");
}
if (!Number.isSafeInteger(owner.pid) || owner.pid < 1) fail("organization-uncertain");
try {
process.kill(owner.pid, 0);
fail("organization-busy");
} catch (x) {
if (x.code !== "ESRCH") throw x;
}
fail("organization-uncertain", "stale writer lock: confirm the old writer is gone before removing it");
}
const release = () => {
rmSync(lock, { recursive: true });
syncDir(home);
};
let pending = false;
try {
const out = fn({ home, parent });
if (out !== null && typeof out === "object" && typeof out.then === "function") {
pending = true;
return Promise.resolve(out).finally(release);
}
return out;
} finally {
if (!pending) release();
}
}
function log(id, event) {
let fd;
try {
const flags = constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW;
fd = openSync(logFile(id), flags, 0o600);
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail("organization-refused");
writeFileSync(fd, JSON.stringify(event) + "\n");
fsyncSync(fd);
} finally {
if (fd !== undefined) closeSync(fd);
}
}
// Only writes reconcile receipts. Uncertain evidence cannot stop a bus start or launch.
function recover(id) {
let text;
try {
text = regular(logFile(id));
} catch (e) {
if (e.code === "ENOENT") return;
throw e;
}
if (text && !text.endsWith("\n")) fail("organization-uncertain", "torn organization receipt");
const entries = text.trim() ? text.trim().split("\n").map(line => parseJsonFileText(line, logFile(id), "organization receipt file")) : [];
const pending = new Map();
for (const e of entries) {
if (e.state === "prepared") pending.set(e.transaction, e);
else pending.delete(e.transaction);
}
for (const e of pending.values()) {
const hash = sha256(regular(businessFilePath(id, dir)));
if (hash !== e.before && hash !== e.after) {
fail("organization-uncertain", "organization bytes do not match prepared receipt");
}
log(id, {
...e, state: hash === e.after ? "committed" : "aborted",
recovered: true, at: new Date().toISOString(),
});
}
}
// Row 199 (#1697, design section 6): `detail` says what a credential verb did (verb, agent, kind,
// date, reason), never a value; the other setters pass none. context, when given, is logged with
// the change: row 194's hire and fire carry the chief's run and the decision there (D2.2).
// Row 239 (#1745): beforeWrite (a fire's or a delete's credential revoke) runs under the lock,
// after the revision check and the validations, so a refusal there revokes nothing. An error
// after it returns carries revoked: true, the lock's release included (duckworth-0165 C2), and the
// bus names it so the human reads that the tokens may already be revoked. beforeWrite must not
// write this business itself: it would find its own lock and refuse organization-busy.
// The one commit path. `guard(old, document)` is what a caller may do to `projects`: write() keeps
// them whole (C2-04 M9) and addProject() allows one exact addition. `document` may be a function of
// the old document, for a writer that builds what it publishes from what it read under the lock.
// #1873 A2: `pathsOnly` logs each change's path and presence without its values, for a verb whose
// values can hold a var a person typed (a secret pasted in the wrong place must not reach evidence).
function commit(id, document, { revision, actor, detail, context, beforeWrite = null, beforePublish = null, guard, pathsOnly = false, capability = null, incarnation = undefined }) {
requireId(actor, "authenticated actor");
requireId(id, "organization id");
let revoked = false;
try {
return withWriterRegistry((registry, publishRegistry) => withLock(id, ({ home, parent }) => {
recover(id);
const file = businessFilePath(id, dir);
const oldBytes = regular(file);
const before = sha256(oldBytes);
const entry = registry?.orgs?.[id];
if (entry && (entry.state !== 'active' || entry.digest !== before || !Number.isSafeInteger(entry.revision + 1))) {
fail('org-mismatch', 'organization document and registry disagree');
}
if (revision !== before) fail("revision-conflict", "organization revision changed; read again");
// #1873 CB1: a binding names the registry incarnation it was approved in. A removed and
// recreated organization with the same id, or a legacy entry with none, never matches.
if (incarnation !== undefined && (!entry || orgIncarnation(registry, id) === null || orgIncarnation(registry, id) !== incarnation)) fail("decision-stale", "organization incarnation changed; raise the decision again");
const old = parseJsonFileText(oldBytes, file, "organization file");
if (typeof document === "function") document = document(old);
guard(old, document);
bindingsFence(old, document, capability);
replyLimitsFence(old, document, actor);
modelQuotaFence(old, document, actor);
usageFence(old, document, actor);
systemTypesFence(old, document, actor);
autonomyFence(old, document, actor);
validateBusinessDocument(document, file, { rolesDir, dataRoot });
const bytes = JSON.stringify(document, null, 2) + "\n";
// Check and log exactly what lands on disk, for every setter: a value JSON
// can't carry (a Date, a Map) serializes as something else (row 131, sage-0314).
const written = JSON.parse(bytes);
guard(old, written);
bindingsFence(old, written, capability);
replyLimitsFence(old, written, actor);
modelQuotaFence(old, written, actor);
usageFence(old, written, actor);
systemTypesFence(old, written, actor);
autonomyFence(old, written, actor);
validateBusinessDocument(written, file, { rolesDir, dataRoot });
if (beforeWrite) {
beforeWrite();
revoked = true;
}
const after = sha256(bytes);
const transaction = randomUUID();
const tx = join(home, transaction);
directory(tx);
const previous = join(tx, "previous.json");
durable(previous, oldBytes);
const candidate = join(parent, "." + id + "." + transaction + ".tmp");
durable(candidate, bytes);
syncDir(tx);
const event = {
transaction, actor, ...(context === undefined ? {} : { context }), business: id, before, after,
changes: pathsOnly ? changes(old, written).map(({ path, oldPresent, newPresent }) => ({ path, oldPresent, newPresent })) : changes(old, written),
...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
};
log(id, { ...event, state: "prepared" });
syncDir(home);
barrier("prepared");
if (sha256(regular(file)) !== before) {
log(id, { ...event, state: "aborted" });
rmSync(candidate);
fail("revision-conflict", "organization revision changed before publication");
}
// Pure admission check after candidate IO, immediately before publication.
// A refused prepared candidate is never rolled forward by recovery.
try { beforePublish?.(); } catch (error) {
log(id, { ...event, state: 'aborted' });
rmSync(candidate);
throw error;
}
renameSync(candidate, file);
syncDir(parent);
if (entry) { entry.digest = after; entry.revision++; publishRegistry(); }
barrier("renamed");
log(id, { ...event, state: "committed" });
return { revision: after, transaction, previous };
}));
} catch (e) {
if (revoked && e instanceof Error) e.revoked = true;
throw e;
}
}
// C2-04 M9: every organization setter preserves the whole project mapping, including library
// playgrounds and hybrid universes.
const projectsProtected = (old, document) => {
if (canonicalJson(document?.projects) !== canonicalJson(old.projects)) fail("organization-projects-protected");
};
// #1873 T1: definitions change only through setDefinition, which bumps rev and checks the entry.
const definitionsProtected = (old, document) => {
if (canonicalJson(document?.definitions ?? {}) !== canonicalJson(old.definitions ?? {})) fail("organization-definitions-protected");
};
function write(id, document, options) {
return commit(id, document, { ...options, capability: null, incarnation: undefined, guard: (old, next) => { projectsProtected(old, next); definitionsProtected(old, next); } });
}
// #1873 T1: the only writer of `definitions`. `next(current)` returns the new entry without its
// rev, or null to remove it; current is the entry or null. Nothing else in the document may change.
// An agent or the tester never writes one. A write bumps rev (1 for a new name); `rev`, when given,
// must be the current one (definition-rev-conflict). Editing or removing a definition an instance
// names refuses definition-in-use: changing a hired agent's role is a change decision (A4).
// assertClean is the credential store's held-secret check (the bus's credentials.assertClean). A
// write that adds or changes an entry needs it: with no check to run, the write refuses (R1).
function setDefinition(id, name, next, { revision, rev, actor, detail, assertClean }) {
checkDefinitionName(name, "definition name");
const logged = { ...detail, definition: name, rev: null };
const guard = (old, document) => {
const agents = Object.entries(old.roles ?? {}).flatMap(([n, role]) => [n, `${role?.definition}.${n}`]);
if (actor === "tester" || agents.includes(actor) || (old.retired ?? []).includes(actor)) fail("human-required");
const { definitions: a = {}, ...restOld } = old;
const { definitions: b = {}, ...restNew } = document ?? {};
if (canonicalJson(restNew) !== canonicalJson(restOld)) fail("organization-definitions-only");
const others = (d) => canonicalJson(Object.fromEntries(Object.entries(d).filter(([k]) => k !== name)));
if (others(a) !== others(b)) fail("organization-definitions-only");
const current = Object.hasOwn(a, name) ? a[name] : null;
const sansRev = (e) => canonicalJson({ ...e, rev: 0 });
if (current && Object.hasOwn(b, name) && sansRev(current) === sansRev(b[name])) fail("definition-unchanged", `definition ${name} is unchanged`);
if (current) {
const users = instancesNaming(old, name);
if (users.length) fail("definition-in-use", `definition ${name} is named by ${users.join(", ")}; a change to a hired agent's role is a change decision`);
}
if (Object.hasOwn(b, name)) {
const want = current ? current.rev + 1 : 1;
if (b[name]?.rev !== want) fail("definition-rev-conflict", `definition ${name} must be written at rev ${want}`);
if (typeof assertClean !== "function") fail("definition-secret-check-required", "a definition write needs the credential store's secret check; write it through the bus");
checkDefinition(name, b[name], { assertClean });
}
};
const out = commit(id, (old) => {
const definitions = { ...(old.definitions ?? {}) };
const current = Object.hasOwn(definitions, name) ? definitions[name] : null;
if (rev !== undefined && (current?.rev ?? null) !== rev) fail("definition-rev-conflict", `definition ${name} is not at rev ${rev}; read again`);
const entry = next(current);
if (entry === null) {
if (!current) fail("definition-missing", `definitions declares no ${name}`);
delete definitions[name];
} else {
definitions[name] = { ...entry, rev: current ? current.rev + 1 : 1 };
logged.rev = definitions[name].rev;
}
const { definitions: _, ...rest } = old;
return Object.keys(definitions).length ? { ...rest, definitions } : rest;
}, { revision, actor, guard: (old, document) => { projectsProtected(old, document); guard(old, document); }, detail: logged });
return { ...out, rev: logged.rev };
}
// Lifecycle Task 2: the only writer that adds a project mapping, and only the exact bootstrap
// addition: one new key under the managed product layout, a receipt naming the request and plan, and
// no other change. It compares both the document it built and the bytes it will publish. A stale
// revision refuses revision-conflict: the owner confirms a fresh preview, never a silent rebase.
function addProject(id, { revision, project, entry, receipt }, actor) {
const guard = (old, document) => {
const problem = additionProblem({ old, next: document, project, entry, receipt });
if (problem) fail("organization-project-add-refused", problem);
};
return commit(id, old => ({ ...old, projects: { ...old.projects, [project]: entry } }), {
revision, actor, guard,
detail: { verb: ADDITION_VERB, request: receipt?.request, plan_digest: receipt?.plan_digest, project },
});
}
// The only exception to the projects fence for an existing software mapping.
// Build from the locked document and check both the candidate and serialized bytes.
function setProjectRemote(id, { revision, project, remote }, actor) {
const guard = (old, document) => {
const agents = Object.entries(old.roles ?? {}).flatMap(([name, role]) => [name, `${role?.definition}.${name}`]);
if (actor !== old.human || actor === "tester" || agents.includes(actor)) fail("human-required");
const problem = remoteProblem(old, document, project, remote);
if (problem) fail(problem);
};
return commit(id, old => {
remoteTarget(old, project, remote);
return { ...old, projects: { ...old.projects, [project]: { ...old.projects[project], remote } } };
}, { revision, actor, guard, detail: { verb: "project.remote.set", project } });
}
// #1873 A2 (design section 7): the only writer of `projects.<p>.agents`, one entry per call. `entry`
// is a define ({definition, vars?}), a narrow ({vars}), an exclude ({exclude: true}) or null to
// remove the entry. The human owner only: every agent actor, the tester included, refuses
// human-required, and so does any actor that is not the organization's human. The guard checks the
// candidate and the serialized bytes: nothing but that entry changes, the project exists and is not
// a library, and the project's entries and union pass A1's checks against the current root level.
// The change log names paths only; the detail names the project, the agent and the form.
function setProjectAgent(id, { revision, project, name, entry }, actor, approval = null) {
requireId(id, "organization id");
requireId(project, "project id");
requireId(name, "agent name");
if (entry !== null && (entry === undefined || typeof entry !== "object" || Array.isArray(entry))) fail("project-agents-invalid", "an entry is an object, or null to remove it");
const root = rootForProjectCheck(dir, rolesDir);
const guard = (old, document) => {
const agents = Object.entries(old.roles ?? {}).flatMap(([n, role]) => [n, `${role?.definition}.${n}`]);
if ((!approval && actor !== old.human) || actor === "tester" || (approval?.authorityDecision!==approval?.decision && agents.includes(actor))) fail("human-required");
if (entry === null && old.projects?.[project]?.agents?.[name] === undefined) fail("agent-not-found", `projects.${project}.agents.${name} does not exist`);
const checked = structuredClone(document);
if (approval?.approvedDefinition) {
const { name: def, entry: value } = approval.approvedDefinition;
checkDefinition(def, value, { assertClean: approval.assertClean });
const expected = { ...(old.definitions ?? {}), [def]: value };
if (canonicalJson(document.definitions) !== canonicalJson(expected)) fail("organization-definitions-only");
if (old.definitions === undefined) delete checked.definitions; else checked.definitions = old.definitions;
}
const problem = projectAgentProblem(old, checked, { orgId: id, project, name, entry, rolesDir, root, definitions: document.definitions });
if (problem) fail(...problem);
};
return commit(id, old => {
const target = old.projects?.[project];
if (target === null || typeof target !== "object" || Array.isArray(target)) return old;
const agents = { ...(target.agents ?? {}) };
if (entry === null) delete agents[name]; else agents[name] = entry;
const { agents: _drop, ...rest } = target;
return { ...old, ...(approval?.approvedDefinition ? {definitions:{...(old.definitions??{}),[approval.approvedDefinition.name]:approval.approvedDefinition.entry}} : {}), projects: { ...old.projects, [project]: Object.keys(agents).length ? { ...rest, agents } : rest } };
}, { revision, actor, guard, beforeWrite:approval?.beforeWrite, beforePublish:approval?.beforePublish, pathsOnly: true, detail: { verb: "project.agents.set", project, agent: name, form: entryForm(entry), ...(approval?.decision ? {decision:approval.decision}: {}) } });
}
// A4 trusted decision executor: one locked publication of the role, route and definition.
// The broker has consumed the matching digest before entering this host-only writer.
function applyHireDecision(id, proposal, { actor, assertClean, beforeWrite = null, beforePublish = null, authorityDecision = null }) {
const { request: r, definition, entry, revision } = proposal;
if (!proposal.id || r.level !== 'org') fail('invalid-request');
const build = old => {
if (actor === 'tester' || (authorityDecision!==proposal.id && Object.entries(old.roles).some(([n,e]) => actor === n || actor === `${e.definition}.${n}`))) fail('human-required');
if ((old.launch?.keep ?? []).includes(r.name)) fail('instance-kept');
if ((old.retired ?? []).includes(r.name)) fail('organization-instance-retired');
if (r.name === old.launch?.by || Object.values(old.arbiters).includes(r.name)) fail('organization-instance-referenced');
const exists = Object.hasOwn(old.roles,r.name);
if ((r.op === 'hire') === exists) fail(r.op === 'hire' ? 'organization-instance-exists' : 'organization-unknown-instance');
const next = structuredClone(old);
if (r.op === 'fire') {
next.launch.instances = next.launch.instances.filter(n => n !== r.name);
} else {
if (typeof assertClean !== 'function') fail('definition-secret-check-required');
const current=old.definitions?.[definition];
const value={...entry,rev:current?current.rev+1:1};
checkDefinition(definition,value,{assertClean});
next.definitions={...(old.definitions??{}),[definition]:value};
const {harness:_h,model:_m,thinking:_t,...otherVars}=old.roles[r.name]?.vars??{};
const vars={...otherVars,harness:r.route.harness,model:r.route.model,...(r.route.thinking?{thinking:r.route.thinking}:{})};
next.roles[r.name]={...(old.roles[r.name]??{}),definition:`org:${definition}`,vars,hireDecision:proposal.id};
// Bindings never move between roles. A role change can retain only services it still declares.
const services=entry.role.credentials.map(c=>c.service);
if(next.roles[r.name].credentials)next.roles[r.name].credentials=Object.fromEntries(Object.entries(next.roles[r.name].credentials).filter(([s])=>services.includes(s)));
if(!services.includes('vikunja'))delete next.roles[r.name].tracker;
if(!next.launch.instances.includes(r.name))next.launch.instances.push(r.name);
}
return next;
};
return commit(id, build, {revision,actor,beforeWrite,beforePublish,pathsOnly:true,guard:(old,next)=>{
if(canonicalJson(next)!==canonicalJson(build(old)))fail('organization-definitions-only');
projectsProtected(old,next);
},context:{decision:proposal.id,digest:proposal.digest??null},detail:{verb:'hire.apply',decision:proposal.id,op:r.op,agent:r.name}});
}
// #1873 CB1 (design sections 3 and 4): the only writer of `copyBindings`, one copy per call. The
// broker has consumed the owner's approval of this exact digest before it gets here; the writer
// still refuses the tester, every agent identity and a caller that is not the owner, and rechecks
// under the locks the registry incarnation, the document revision, the copy's binding generation
// and copyId, and its definition pin. A bind writes `pending` (approved, not verified: nothing
// resolves or launches it until a verifier exists); an unbind writes the `unbound` tombstone. The
// record holds ids, scope names and integers only; the receipt holds paths only.
// `definitions` is the boot snapshot's merged definition map, for a root copy whose definition
// is not in this file.
function applyCopyBindingDecision(id, proposal, { actor, owner = false, definitions = {}, beforeWrite = null, beforePublish = null }) {
const { binding: b, revision } = proposal ?? {};
if (typeof proposal?.id !== "string" || proposal.kind !== "binding" || !b || typeof b.instance !== "string") fail("invalid-request");
const humanOnly = () => fail("human-required", bindingText("human-required"));
// Before the lock: no owner, no write. The agent identities are checked again under it.
if (owner !== true || actor === "tester") humanOnly();
const build = old => {
const agents = [
...Object.entries(old.roles ?? {}).flatMap(([n, e]) => [n, `${e?.definition}.${n}`]),
...Object.entries(old.projects ?? {}).flatMap(([p, v]) => Object.entries(v?.agents ?? {}).flatMap(([n, e]) => [n, `${n}.${p}`, `${e?.definition}.${n}.${p}`])),
...Object.keys(old.copyBindings ?? {}), ...Object.keys(definitions ?? {}), b.instance, b.source?.name,
];
if (owner !== true || actor === "tester" || agents.includes(actor)) humanOnly();
const current = old.copyBindings?.[b.instance];
if ((current?.generation ?? 0) !== b.generation || (current ? current.copyId !== b.copyId : b.fresh !== true)
|| (current && canonicalJson(current.source) !== canonicalJson(b.source))) fail("credential-binding-changed", bindingText("credential-binding-changed", Object.keys(b.after ?? b.before ?? {})[0] ?? null));
if (b.after === null && (!current || current.state === "unbound")) fail("credential-binding-changed", bindingText("credential-binding-changed"));
const pin = definitionPin(b.definition.ref, { definitions: { ...(definitions ?? {}), ...(old.definitions ?? {}) }, rolesDir, readTemplate });
if (!samePin(pin, b.definition)) fail("credential-role-changed", bindingText("credential-role-changed", Object.keys(b.after ?? b.before ?? {})[0] ?? null));
const record = {
copyId: b.copyId, orgIncarnation: b.orgIncarnation, source: structuredClone(b.source), definition: structuredClone(b.definition),
generation: b.generation + 1, state: b.after === null ? "unbound" : "pending",
services: b.after === null ? {} : structuredClone(b.after), decision: proposal.id,
};
return { ...structuredClone(old), copyBindings: { ...(old.copyBindings ?? {}), [b.instance]: record } };
};
return commit(id, build, {
revision, actor, beforeWrite, beforePublish, pathsOnly: true, capability: BINDING_WRITE, incarnation: b.orgIncarnation,
guard: (old, next) => {
if (canonicalJson(next) !== canonicalJson(build(old))) fail("organization-bindings-protected", "copyBindings change only through an owner-approved binding decision");
projectsProtected(old, next);
definitionsProtected(old, next);
},
context: { decision: proposal.id, digest: proposal.digest ?? null },
detail: { verb: b.after === null ? "binding.unbind" : "binding.apply", decision: proposal.id, agent: b.instance, generation: b.generation + 1 },
});
}
// AU inverse, from an immutable private proposal. It touches only the named instance and
// definition; writer validation, fresh revision and owner fence still apply. Bindings never restore.
function reverseHireDecision(id,p,{actor,beforeWrite,beforePublish}) {
const {document:old,revision}=read(id),r=p.request,prior=p.prior;
if(actor!==old.human || !prior || r.level==='root')fail('human-required');
if(r.op==='hire')fail('invalid-request'); // Hire reverses through the stop/revoke/fire executor.
const build=current=>{
const live=r.level==='org'?current.roles[r.name]:current.projects[r.project]?.agents?.[r.name];
if(r.op==='fire') {
if(r.level==='project'?live!==undefined:current.launch.instances.includes(r.name))fail('decision-stale');
if(r.level==='org') {
const strip=x=>{const {credentials,tracker,...rest}=x??{};return rest;};
if(canonicalJson(strip(live))!==canonicalJson(strip(prior.instance)))fail('decision-stale');
}
if(prior.definition && canonicalJson(current.definitions?.[prior.instance.definition.slice(4)])!==canonicalJson(prior.definition))fail('decision-stale');
} else if(live?.hireDecision!==p.id)fail('decision-stale');
const next=structuredClone(current);
if(r.level==='project') {
const entries=next.projects[r.project].agents??{};
if(prior.instance)entries[r.name]=structuredClone(prior.instance);else delete entries[r.name];
delete entries[r.name]?.credentials;delete entries[r.name]?.tracker;
next.projects[r.project].agents=entries;
} else {
if(!prior.instance)fail('decision-stale');
next.roles[r.name]=structuredClone(prior.instance);
delete next.roles[r.name].credentials;delete next.roles[r.name].tracker;
next.launch.instances=next.launch.instances.filter(n=>n!==r.name);
if(prior.launched)next.launch.instances.push(r.name);
}
if(prior.definition) {
const name=prior.instance.definition.slice(4);
next.definitions={...(next.definitions??{}),[name]:{...prior.definition,rev:(current.definitions?.[name]?.rev??0)+1}};
}
return next;
};
return commit(id,build,{revision,actor,beforeWrite,beforePublish,pathsOnly:true,guard:(current,next)=>{if(actor!==current.human || canonicalJson(build(current))!==canonicalJson(next))fail('human-required');},detail:{verb:'hire.reverse',decision:p.id,agent:r.name}});
}
// Runs `fn({ revision, document })` under the writer lock, after recovery, and nothing else may
// write this business meanwhile. `fn` must be synchronous: the lock is a directory held for its
// duration, and an awaited call would leave a half-checked world to whoever finds the lock gone.
function hold(id, fn) {
requireId(id, "organization id");
return withWriterRegistry(() => withLock(id, () => {
recover(id);
const { document, revision } = read(id);
const out = fn({ document, revision });
if (out !== null && typeof out === "object" && typeof out.then === "function") {
out.catch?.(() => {});
fail("organization-hold-sync", "a held check must be synchronous");
}
return out;
}));
}
// Like hold, but the lock stays on disk until an asynchronous effect settles, including on failure.
// The effect must not call an organization writer: it would find this same lock and refuse busy.
function holdAsync(id, fn) {
requireId(id, "organization id");
return withWriterRegistry(() => withLock(id, () => {
recover(id);
return fn(read(id));
}));
}
// The committed addition of this request and plan, if there is one. A prepared receipt whose
// `after` is the file's current hash counts: the rename landed and the commit line did not.
function findAddition(id, { request, plan_digest, project }) {
let text;
try {
text = regular(logFile(id));
} catch (e) {
if (e.code === "ENOENT") return null;
throw e;
}
if (text && !text.endsWith("\n")) fail("organization-uncertain", "torn organization receipt");
const last = new Map();
for (const e of text.trim() ? text.trim().split("\n").map(line => parseJsonFileText(line, logFile(id), "organization receipt file")) : []) last.set(e.transaction, e);
const hash = sha256(regular(businessFilePath(id, dir)));
for (const e of last.values()) {
const d = e.detail;
if (d?.verb !== ADDITION_VERB || d.request !== request || d.plan_digest !== plan_digest || d.project !== project) continue;
if (e.state === "committed" || (e.state === "prepared" && e.after === hash))
return { after: e.after, before: e.before, transaction: e.transaction };
}
return null;
}
// Row 199: a receipt with no document change, for a credential verb that changes only the agents'
// store (a revoke or delete of store references). Committed at once; there is nothing to recover.
function record(id, { actor, detail }) {
requireId(actor, "authenticated actor");
requireId(id, "organization id");
return withLock(id, () => {
recover(id);
const hash = sha256(regular(businessFilePath(id, dir)));
const transaction = randomUUID();
log(id, {
transaction, actor, business: id, before: hash, after: hash, changes: [], detail,
at: new Date().toISOString(), state: "committed",
});
return { revision: hash, transaction };
});
}
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, setDefinition, applyHireDecision, reverseHireDecision, applyCopyBindingDecision, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
}