UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

443 lines (442 loc) • 21.5 kB
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, }; }