@sema-agent/core
Version:
Stateless, task-oriented AI agent core
443 lines (442 loc) • 21.5 kB
JavaScript
import { escapeForDisclosure, isRuleBehavior, parseRuleText } from "./permission-rule-model.js";
import { applyTombstones, errText, isProjectScopeRoot, joinFrontiers, joinRuleStates, normalizePersistedRule, readRuleWriteOutcome, ruleSyncVector, sameRuleIdentity, screenRuleSyncState, writerOf, } from "./permission-rule-store.js";
import { emitTrace } from "./trace.js";
export const PERMISSION_RULE_SYNC_PATH = "/v1/permission-rules/sync";
export const LOCAL_OWNER_UNSYNCABLE_CODE = "permission_rules.local_owner_unsyncable";
function refuseLocalOwnerSync() {
const e = new Error("a local-owner rule bucket cannot sync — syncing is an authenticated act, and this bucket has no cloud home until it is adopted (adoptFilePermissionRuleStore); this is NOT an authentication failure");
e.code = LOCAL_OWNER_UNSYNCABLE_CODE;
throw e;
}
const SYNC_MAX_ATTEMPTS = 8;
const DISCLOSED_REFUSED_TOMBSTONES = 5;
export async function syncPermissionRules(opts) {
const principal = opts.principal;
const owner = opts.owner;
const provider = opts.provider;
const transport = opts.transport;
if (owner !== undefined) {
if (owner.kind === "local-owner")
refuseLocalOwnerSync();
if (owner.principal !== principal) {
throw new Error(`contradictory identity: principal "${escapeForDisclosure(principal)}" and owner principal "${escapeForDisclosure(owner.principal)}" disagree`);
}
}
if (typeof principal !== "string" || principal === "") {
throw new Error("syncPermissionRules requires a verified principal — an unauthenticated deployment has no cloud bucket to sync");
}
const traceClock = opts.now ?? Date.now;
const disclose = (result) => {
const tracer = opts.tracer;
if (tracer === undefined)
return result;
for (const r of result.resurrected) {
emitTrace(tracer, () => ({
kind: "permission.rule_sync_resurrected",
version: 1,
principal,
rule: r.rule,
scopeKind: r.scope.kind,
ts: traceClock(),
}));
}
for (const d of result.dropped) {
emitTrace(tracer, () => ({
kind: "permission.rule_sync_dropped",
version: 1,
principal,
rule: d.rule,
scopeKind: d.scope.kind,
reason: d.reason,
ts: traceClock(),
}));
}
return result;
};
const store = provider.forPrincipal(principal);
const writer = writerOf(store);
if (writer === undefined) {
throw new Error("the resolved permission-rule store has no write face — a sync round cannot land through it");
}
const raw = await writer.readRaw();
const request = {
principal,
replica: raw.actor,
state: { rules: raw.rules, tombstones: raw.tombstones },
...(raw.observedVector !== undefined ? { observedVector: raw.observedVector } : {}),
};
const rawResponse = await transport(PERMISSION_RULE_SYNC_PATH, request);
const response = parseRuleSyncResponse(rawResponse, principal);
const warnings = [...response.warnings];
const dropped = [];
const inboundTombstones = [];
let tombstoneWithholding = false;
const forgedDot = (d) => d.actor === raw.actor && d.counter > raw.counter;
for (const t of response.merged.tombstones) {
if (t === undefined) {
tombstoneWithholding = true;
continue;
}
const forged = forgedDot(t.deletedBy) ? t.deletedBy : t.removedDots.find(forgedDot);
if (forged !== undefined) {
dropped.push({ behavior: t.behavior, rule: t.rule, scope: t.scope, dot: forged, reason: "own_actor_forged" });
tombstoneWithholding = true;
continue;
}
inboundTombstones.push(t);
}
if (tombstoneWithholding) {
warnings.push("the response carried at least one malformed tombstone — every inbound add is withheld this round (legal tombstones land; the adds re-arrive once the peer repairs the tombstone: full-state exchange has no missed-forever window)");
}
for (const t of response.textRefusedTombstones) {
dropped.push({ behavior: t.behavior, rule: t.rule, scope: t.scope, dot: t.dot, reason: "invalid_rule_text" });
}
for (const t of response.sessionScopeTombstones) {
dropped.push({ behavior: t.behavior, rule: t.rule, scope: t.scope, dot: t.dot, reason: "session_scope_not_durable" });
}
if (response.textRefusedTombstones.length > 0) {
const named = response.textRefusedTombstones
.slice(0, DISCLOSED_REFUSED_TOMBSTONES)
.map((t) => `"${escapeForDisclosure(t.rule)}" (${t.code})`)
.join(", ");
const more = response.textRefusedTombstones.length - DISCLOSED_REFUSED_TOMBSTONES;
warnings.push(`the response carried ${response.textRefusedTombstones.length} tombstone(s) whose rule text this version's validator refuses — ${named}${more > 0 ? ` and ${more} more` : ""}. ` +
"They are refused here and never land; this round's inbound adds are NOT withheld for them (an add under the same text is refused by the same validator, so such a tombstone covers nothing that could go live). The peer still holds them — removing them there is what ends this notice");
}
const inboundRules = [];
if (!tombstoneWithholding) {
for (const r of response.merged.rules) {
const n = normalizePersistedRule(r);
if ("reject" in n) {
for (const a of r.adds)
dropped.push({ behavior: r.behavior, rule: r.rule, scope: r.scope, dot: a.dot, reason: n.reject });
continue;
}
const kept = r.adds.filter((a) => {
if (a.dot.actor === raw.actor && a.dot.counter > raw.counter) {
dropped.push({ behavior: r.behavior, rule: r.rule, scope: r.scope, dot: a.dot, reason: "own_actor_forged" });
return false;
}
return true;
});
if (kept.length > 0)
inboundRules.push({ ...r, adds: kept });
}
}
const inbound = { rules: inboundRules, tombstones: inboundTombstones };
const quarantine = [];
const localScreen = screenRuleSyncState({ rules: raw.rules, tombstones: raw.tombstones });
for (const rej of localScreen.rejected) {
if (rej.reason === "invalid_rule_text" || rej.reason === "metadata_mismatch") {
quarantine.push({ behavior: rej.behavior, rule: rej.rule, scope: rej.scope, dots: [rej.dot], reason: rej.reason });
dropped.push(rej);
}
}
const localDots = new Set(raw.rules.flatMap((r) => r.adds.map((a) => JSON.stringify([a.dot.actor, a.dot.counter]))));
for (const d of response.dropped) {
if (d.scope.kind === "session") {
warnings.push(`the server's dropped list names a session-scope row (${escapeForDisclosure(d.rule)}) — the durable store never holds one, so there is no local row it could mean; ignored`);
continue;
}
if (localDots.has(JSON.stringify([d.dot.actor, d.dot.counter]))) {
const reason = d.reason === "below_gc_frontier" ? "below_gc_frontier" : "server_rejected";
quarantine.push({ behavior: d.behavior, rule: d.rule, scope: d.scope, dots: [d.dot], reason });
dropped.push({ behavior: d.behavior, rule: d.rule, scope: d.scope, dot: d.dot, reason });
}
else {
warnings.push(`the server dropped an inbound-side record (${escapeForDisclosure(d.rule)}, dot ${escapeForDisclosure(d.dot.actor)}#${d.dot.counter}): ${escapeForDisclosure(d.reason)}`);
}
}
const clean = !tombstoneWithholding;
let current = raw;
for (let attempt = 0; attempt < SYNC_MAX_ATTEMPTS; attempt++) {
const preLive = applyTombstones(current.rules, current.tombstones);
const prospective = joinRuleStates({ rules: current.rules, tombstones: current.tombstones }, inbound);
const delta = {
kind: "sync-join",
inbound,
...(response.gcFrontier !== undefined ? { gcFrontier: response.gcFrontier } : {}),
...(clean ? { observedVector: joinFrontiers(current.observedVector ?? {}, ruleSyncVector(prospective)) } : {}),
...(quarantine.length > 0 ? { quarantine } : {}),
};
let res;
try {
res = await writer.apply(delta, { expectedRev: current.rev });
}
catch (err) {
return disclose({
ok: false,
pushed: { addDots: raw.rules.reduce((n, r) => n + r.adds.length, 0), tombstones: raw.tombstones.length },
landed: { newAdds: 0, newTombstones: 0 },
resurrected: [],
dropped,
rev: current.rev,
warnings: [...warnings, `the store refused the sync landing: ${escapeForDisclosure(errText(err))} — nothing landed, the local state is unchanged`],
});
}
const reading = readRuleWriteOutcome(res);
if (reading.kind === "conflict") {
current = await writer.readRaw();
continue;
}
if (reading.kind === "refused" || reading.kind === "unreadable") {
return disclose({
ok: false,
pushed: { addDots: raw.rules.reduce((n, r) => n + r.adds.length, 0), tombstones: raw.tombstones.length },
landed: { newAdds: 0, newTombstones: 0 },
resurrected: [],
dropped,
rev: current.rev,
warnings: [
...warnings,
reading.kind === "refused"
? `the store refused the sync landing (${reading.refused}) — nothing landed, the local state is unchanged`
: `the store answered the sync landing unreadably: ${reading.why} — this round is not reported as landed; re-read before trusting the local state`,
],
});
}
const landedRaw = await writer.readRaw();
const postLive = applyTombstones(landedRaw.rules, landedRaw.tombstones);
const resurrected = [];
for (const r of postLive) {
const pre = preLive.find((p) => sameRuleIdentity(p, r));
if (pre !== undefined)
continue;
const hadAdds = current.rules.some((p) => sameRuleIdentity(p, r));
const covered = current.tombstones.some((t) => sameRuleIdentity(t, r));
if (hadAdds && covered) {
const preRuleDots = new Set(current.rules.filter((p) => sameRuleIdentity(p, r)).flatMap((p) => p.adds.map((a) => JSON.stringify([a.dot.actor, a.dot.counter]))));
const by = r.adds.find((a) => !preRuleDots.has(JSON.stringify([a.dot.actor, a.dot.counter]))) ?? r.adds[0];
if (by !== undefined)
resurrected.push({ behavior: r.behavior, rule: r.rule, scope: r.scope, byDot: by.dot });
}
}
const dotKeyOf = (d) => JSON.stringify([d.actor, d.counter]);
const preDots = new Set(current.rules.flatMap((r) => r.adds.map((a) => dotKeyOf(a.dot))));
const newAdds = landedRaw.rules.flatMap((r) => r.adds).filter((a) => !preDots.has(dotKeyOf(a.dot))).length;
const tombKeyOf = (t) => JSON.stringify([t.behavior, t.rule, t.scope.kind, t.scope.kind === "project" ? t.scope.root : t.scope.kind === "session" ? t.scope.sessionId : null, dotKeyOf(t.deletedBy)]);
const preTombs = new Set(current.tombstones.map(tombKeyOf));
const newTombstones = landedRaw.tombstones.filter((t) => !preTombs.has(tombKeyOf(t))).length;
const landedReport = reading.sync;
if (reading.reportUnreadable !== undefined) {
warnings.push(`${reading.reportUnreadable} — the join landed, but what it dropped or quarantined cannot be reported from this round`);
}
for (const d of landedReport?.droppedInbound ?? []) {
if (!dropped.some((x) => x.dot.actor === d.dot.actor && x.dot.counter === d.dot.counter && x.reason === d.reason))
dropped.push(d);
}
for (const q of landedReport?.quarantined ?? []) {
if (!dropped.some((x) => x.dot.actor === q.dot.actor && x.dot.counter === q.dot.counter && x.reason === q.reason))
dropped.push(q);
}
return disclose({
ok: dropped.length === 0 && warnings.length === 0,
pushed: { addDots: raw.rules.reduce((n, r) => n + r.adds.length, 0), tombstones: raw.tombstones.length },
landed: { newAdds, newTombstones },
resurrected,
dropped,
rev: landedRaw.rev,
...(warnings.length > 0 ? { warnings } : {}),
});
}
return disclose({
ok: false,
pushed: { addDots: raw.rules.reduce((n, r) => n + r.adds.length, 0), tombstones: raw.tombstones.length },
landed: { newAdds: 0, newTombstones: 0 },
resurrected: [],
dropped,
rev: current.rev,
warnings: [...warnings, `optimistic-concurrency retries exhausted after ${SYNC_MAX_ATTEMPTS} attempts — nothing landed, the local state is unchanged`],
});
}
function pickDot(v) {
const d = v;
const actor = d?.actor;
const counter = d?.counter;
return typeof actor === "string" && actor !== "" && typeof counter === "number" && Number.isFinite(counter) ? { actor, counter } : undefined;
}
function pickScopeCaptured(v) {
const s = v;
const kind = s?.kind;
if (kind === "global")
return { kind, scope: { kind: "global" } };
if (kind === "project") {
const root = s?.root;
return { kind, scope: isProjectScopeRoot(root) ? { kind: "project", root } : undefined };
}
if (kind === "session") {
const sessionId = s?.sessionId;
return { kind, scope: typeof sessionId === "string" && sessionId !== "" ? { kind: "session", sessionId } : undefined };
}
return { kind, scope: undefined };
}
function pickScope(v) {
return pickScopeCaptured(v).scope;
}
function pickAdd(v) {
const a = v;
const dot = pickDot(a?.dot);
const origin = a?.origin;
const createdAt = a?.createdAt;
if (dot === undefined)
return undefined;
if (origin !== "user" && origin !== "imported-cc" && origin !== "starter")
return undefined;
if (typeof createdAt !== "string")
return undefined;
return { dot, origin, createdAt };
}
function pickFrontier(v) {
if (v === null || typeof v !== "object" || Array.isArray(v))
return undefined;
const out = {};
for (const [actor, counter] of Object.entries(v)) {
if (typeof counter !== "number" || !Number.isFinite(counter))
return undefined;
out[actor] = counter;
}
return out;
}
export function parseRuleSyncResponse(raw, expectedPrincipal) {
if (raw === null || typeof raw !== "object") {
throw new Error("the sync response is not an object — protocol error, nothing landed");
}
const r = raw;
const echoedPrincipal = r.principal;
if (typeof echoedPrincipal !== "string") {
throw new Error("the sync response carries no principal echo — protocol error, nothing landed");
}
if (echoedPrincipal !== expectedPrincipal) {
throw new Error(`the sync response echoes principal "${escapeForDisclosure(echoedPrincipal)}" but this round synced "${escapeForDisclosure(expectedPrincipal)}" — the transport is wired to the wrong bucket; refusing the whole round`);
}
const merged = r.merged;
const mergedRules = merged?.rules;
const mergedTombstones = merged?.tombstones;
if (merged === null || typeof merged !== "object" || !Array.isArray(mergedRules) || !Array.isArray(mergedTombstones)) {
throw new Error("the sync response carries no merged state — protocol error, nothing landed");
}
const rawGcFrontier = r.gcFrontier;
let gcFrontier;
if (rawGcFrontier !== undefined) {
gcFrontier = pickFrontier(rawGcFrontier);
if (gcFrontier === undefined) {
throw new Error("the sync response carries an unparseable gcFrontier — refusing the whole round (a garbled frontier must not drive the fence arm)");
}
}
const warnings = [];
const rules = [];
for (const entry of mergedRules) {
const e = entry;
const scope = pickScope(e?.scope);
const eRule = e?.rule;
const eCommand = e?.command;
const eTool = e?.tool;
const eMatch = e?.match;
const eAdds = e?.adds;
const eBehavior = isRuleBehavior(e?.behavior) ? e.behavior : undefined;
if (typeof eRule !== "string" || scope === undefined || !Array.isArray(eAdds) || typeof eCommand !== "string" || typeof eTool !== "string" || typeof eMatch !== "string" || eBehavior === undefined) {
warnings.push("the response carried an unshapeable rule entry — skipped (it never reaches the local store)");
continue;
}
const adds = [];
let addDamaged = false;
for (const a of eAdds) {
const picked = pickAdd(a);
if (picked === undefined) {
addDamaged = true;
continue;
}
adds.push(picked);
}
if (addDamaged)
warnings.push(`rule "${escapeForDisclosure(eRule)}" carried at least one unshapeable add — skipped those adds`);
if (adds.length === 0)
continue;
rules.push({
behavior: eBehavior,
rule: eRule,
tool: eTool,
match: eMatch,
command: eCommand,
scope,
adds,
});
}
const tombstones = [];
const textRefusedTombstones = [];
const sessionScopeTombstones = [];
for (const entry of mergedTombstones) {
const t = entry;
const pickedScope = pickScopeCaptured(t?.scope);
const scope = pickedScope.scope;
const ruleText = t?.rule;
const behavior = isRuleBehavior(t?.behavior) ? t.behavior : undefined;
const deletedBy = pickDot(t?.deletedBy);
const removedRaw = t?.removedDots;
const removedDots = [];
if (Array.isArray(removedRaw)) {
for (const d of removedRaw) {
const dot = pickDot(d);
if (dot === undefined) {
removedDots.length = 0;
break;
}
removedDots.push(dot);
}
}
if (pickedScope.kind === "session") {
if (typeof ruleText === "string" && deletedBy !== undefined && scope !== undefined && behavior !== undefined) {
sessionScopeTombstones.push({ behavior, rule: ruleText, scope, dot: deletedBy });
}
else {
warnings.push("the response carried a session-scope tombstone whose identity could not be read — dropped per-row (a session row never lands here, and nothing is withheld for it)");
}
continue;
}
if (typeof ruleText !== "string" || scope === undefined || deletedBy === undefined || removedDots.length === 0 || behavior === undefined) {
tombstones.push(undefined);
continue;
}
const canonicalTomb = parseRuleText(ruleText, behavior);
if ("reject" in canonicalTomb) {
textRefusedTombstones.push({ behavior, rule: ruleText, scope, dot: deletedBy, code: canonicalTomb.reject.code });
continue;
}
if (canonicalTomb.rule.rule !== ruleText) {
tombstones.push(undefined);
continue;
}
tombstones.push({ behavior, rule: ruleText, scope, removedDots, deletedBy });
}
const dropped = [];
const rawDropped = r.dropped;
if (rawDropped !== undefined) {
if (!Array.isArray(rawDropped)) {
warnings.push("the response carried a non-array dropped field — ignored");
}
else {
for (const entry of rawDropped) {
const d = entry;
const scope = pickScope(d?.scope);
const dRule = d?.rule;
const dDot = pickDot(d?.dot);
const dReason = d?.reason;
const dBehavior = isRuleBehavior(d?.behavior) ? d.behavior : undefined;
if (typeof dRule !== "string" || scope === undefined || dDot === undefined || typeof dReason !== "string" || dBehavior === undefined) {
warnings.push("the response carried an unshapeable dropped entry — ignored (the server's own drop discipline is its contract obligation)");
continue;
}
dropped.push({ behavior: dBehavior, rule: dRule, scope, dot: dDot, reason: dReason });
}
}
}
return {
principal: echoedPrincipal,
merged: { rules, tombstones },
...(gcFrontier !== undefined ? { gcFrontier } : {}),
dropped,
textRefusedTombstones,
sessionScopeTombstones,
warnings,
};
}