/*
RFC-1259: the content-authority resolver — one pure function answering
"may subject S mutate field F of object O at required level L". Consults
the CAM `authority:` block when present; falls back to the legacy
projections (clientEditable[] for studio-gate-client, pseo autonomy scopes
for pseo-generator, agent-primary posture for agent-session) when absent.
RFC-1263: hoisted from packages/werkstatt-site/src/domain/authority/resolve.ts
so the engine's mission.content-edit eligibility check evaluates the same
model — one resolver, one semantic.
- Do not emit validator diagnostics — CAM-01..05 live in
werkstatt-site checks/authority-model-validate.ts; this module returns
verdicts only.
- Do not read files — callers pass the parsed SystemManifest and any
resolved pseo scope state; the resolver is pure.
- RFC-1259: resolver + projections — CAM grants -> pseo scopes ->
clientEditable precedence with explicit source; Q-1 conservative pseo
ceiling (L2); Q-25 legacy agent-session passthrough (source
"legacy-agent").
- RFC-1263: hoisted site -> shared (domain/authority/resolve.ts re-exports
this module) so engine-side fast-lane eligibility consults the same
resolver.
- RFC-1263
*/
import picomatch from "picomatch";
import type {
CamAuthority,
CamFieldClass,
CamGate,
CamGrant,
CamLevel,
SystemManifest,
} from "../ontology/schemas/index.ts";
import type { AutonomyState } from "../surface/index.ts";
export const CAM_LEVEL_ORDER: Record = {
L0: 0,
L1: 1,
L2: 2,
L3: 3,
L4: 4,
};
const LEVEL_NAMES: CamLevel[] = ["L0", "L1", "L2", "L3", "L4"];
export function camLevelToNumber(level: CamLevel): number {
return CAM_LEVEL_ORDER[level];
}
export function numberToCamLevel(level: number): CamLevel {
return LEVEL_NAMES[Math.max(0, Math.min(4, Math.floor(level)))];
}
/** Channel-derived subject ids — never caller-supplied (RFC-1259 contract). */
export type CamSubjectId = "agent-session" | "studio-gate-client" | "pseo-generator";
export interface ContentAuthorityQuery {
subject: CamSubjectId;
fieldClass: CamFieldClass;
/** Workspace-relative content path (e.g. "src/content/pages/de/notausgang.md"). */
path: string;
locale?: string;
/** The mutation-scope level the attempted operation requires (0–4). */
level: number;
}
export interface ContentAuthorityVerdict {
allowed: boolean;
/** Effective level for subject×fieldClass×path (numeric 0–4). */
level: number;
gates: Array<{
id: string;
require: "human-approval" | "mission-digest-signoff" | "provenance";
}>;
/**
* Which projection answered. "legacy-agent" extends the RFC enum: the
* agent-primary posture (ADR-0108) grants agent-session within the content
* tree when no CAM block exists — no whitelist is consulted, so the verdict
* needs its own honest source label.
*/
source: "cam" | "legacy-clientEditable" | "legacy-pseo-scopes" | "legacy-agent" | "default-deny";
}
/** Optional resolved inputs the caller supplies (pure-resolver contract). */
export interface ContentAuthorityInputs {
/** Parsed pseo autonomy states (autonomy.state.yaml entries). */
pseoScopes?: AutonomyState[];
}
// ─── pseo projection (Q-1: conservative ceiling) ────────────────────────────
/** pseo approval-authority levels never confer CAM mutation rights above L2. */
export const PSEO_CAM_CEILING = 2;
function pseoEffectiveLevel(state: AutonomyState): number {
return Math.min(CAM_LEVEL_ORDER[state.level], CAM_LEVEL_ORDER[state.ceiling], PSEO_CAM_CEILING);
}
// ─── glob helpers ────────────────────────────────────────────────────────────
function pathMatches(path: string, patterns: string[] | undefined): boolean {
if (!patterns || patterns.length === 0) return true;
return patterns.some((p) => picomatch.isMatch(path, p, { dot: true }));
}
/**
* Q-2 rule: two glob lists overlap if any pattern pair intersects. Segment-wise
* approximation — a pair counts as intersecting unless a concrete (non-glob)
* segment provably differs at the same position.
*/
export function globsOverlap(a: string[], b: string[]): boolean {
for (const pa of a) {
for (const pb of b) {
if (globPairIntersects(pa, pb)) return true;
}
}
return false;
}
function globPairIntersects(a: string, b: string): boolean {
const segsA = a.split("/");
const segsB = b.split("/");
const len = Math.max(segsA.length, segsB.length);
for (let i = 0; i < len; i++) {
const sa = segsA[i];
const sb = segsB[i];
if (sa === undefined || sb === undefined) {
// One side ran out: the shorter side must end in ** to still intersect.
const shorter = sa === undefined ? segsA : segsB;
if (shorter[shorter.length - 1] !== "**") return false;
continue;
}
if (sa === "**" || sb === "**") return true;
if (sa === sb) continue;
if (sa.includes("*") || sb.includes("*") || sa.includes("{") || sb.includes("{")) {
// Glob on at least one side at a differing position — counts as
// intersecting (conservative per Q-2).
return true;
}
// Two differing concrete segments at the same position — disjoint.
return false;
}
return true;
}
// ─── clientEditable projection ───────────────────────────────────────────────
/**
* Derives the collection-key whitelist the `studio-gate-client` grants imply:
* first path segment under `src/content/` per grant glob (DNA-22 keys).
*/
export function projectClientEditable(authority: CamAuthority): string[] {
const out = new Set();
for (const grant of authority.grants) {
if (grant.subject !== "studio-gate-client") continue;
for (const p of grant.paths ?? []) {
const m = /^src\/content\/([^/]+)\//.exec(p) ?? /^src\/content\/([^/]+)$/.exec(p);
if (m) out.add(m[1]);
}
}
return [...out].sort();
}
// ─── resolver ────────────────────────────────────────────────────────────────
interface GateEntry {
id: string;
require: CamGate["require"];
}
function resolveCamVerdict(
authority: CamAuthority,
query: ContentAuthorityQuery,
): ContentAuthorityVerdict {
const subject = authority.subjects.find((s) => s.id === query.subject);
if (!subject) {
return { allowed: false, level: 0, gates: [], source: "default-deny" };
}
const matched: CamGrant[] = authority.grants.filter(
(g) =>
g.subject === query.subject &&
g.fieldClasses.includes(query.fieldClass) &&
pathMatches(query.path, g.paths),
);
if (matched.length === 0) {
return { allowed: false, level: 0, gates: [], source: "default-deny" };
}
let effective = Math.min(
...matched.map((g) =>
g.maxLevel ? CAM_LEVEL_ORDER[g.maxLevel] : CAM_LEVEL_ORDER[subject.defaultLevel],
),
);
// Gates: grant-referenced ids + gates whose whenFieldClass covers the query.
const gates = new Map();
const gateById = new Map(authority.gates.map((g) => [g.id, g]));
for (const grant of matched) {
for (const id of grant.gates ?? []) {
const g = gateById.get(id);
if (g) gates.set(id, { id, require: g.require });
}
}
for (const g of authority.gates) {
const classes = Array.isArray(g.whenFieldClass) ? g.whenFieldClass : [g.whenFieldClass];
if (classes.includes(query.fieldClass)) {
gates.set(g.id, { id: g.id, require: g.require });
if (g.ceiling) effective = Math.min(effective, CAM_LEVEL_ORDER[g.ceiling]);
}
}
return {
allowed: query.level <= effective,
level: effective,
gates: [...gates.values()],
source: "cam",
};
}
function resolveLegacyVerdict(
manifest: SystemManifest,
query: ContentAuthorityQuery,
inputs?: ContentAuthorityInputs,
): ContentAuthorityVerdict {
if (query.subject === "studio-gate-client") {
const whitelist = manifest.clientEditable ?? [];
const allowed = whitelist.some(
(key) =>
picomatch.isMatch(query.path, `src/content/${key}/**`, { dot: true }) ||
picomatch.isMatch(query.path, `src/content/${key}`, { dot: true }),
);
return {
allowed,
level: allowed ? 2 : 0,
gates: [],
source: allowed ? "legacy-clientEditable" : "default-deny",
};
}
if (query.subject === "pseo-generator") {
const scopes = (inputs?.pseoScopes ?? []).filter(
(s) =>
s.scope.fieldClass === query.fieldClass &&
(!query.locale || s.scope.locale === query.locale),
);
if (scopes.length === 0) {
return { allowed: false, level: 0, gates: [], source: "default-deny" };
}
const effective = Math.max(...scopes.map(pseoEffectiveLevel));
return {
allowed: query.level <= effective,
level: effective,
gates: [],
source: "legacy-pseo-scopes",
};
}
// agent-session: agent-primary posture — allowed within the content tree
// only, bounded by the selector/collection reach of the write path itself.
const inContentTree = query.path === "src/content" || query.path.startsWith("src/content/");
return {
allowed: inContentTree,
level: inContentTree ? 4 : 0,
gates: [],
source: inContentTree ? "legacy-agent" : "default-deny",
};
}
/**
* RFC-1259 core: answers subject×fieldClass×path×required-level queries.
* CAM block present → CAM verdict; absent → legacy projections. Pure.
*/
export function resolveContentAuthority(
manifest: SystemManifest,
query: ContentAuthorityQuery,
inputs?: ContentAuthorityInputs,
): ContentAuthorityVerdict {
if (manifest.authority) {
return resolveCamVerdict(manifest.authority, query);
}
return resolveLegacyVerdict(manifest, query, inputs);
}