Rocko-authored, Filbert-reviewed inspector (r6 manifest a4a44930...) with full review/build/verdict evidence under docs/plans/reviews. 43/0 selftests, oracle zero-disagreement, foundation checker PASS. Owner A9 acceptance recorded separately.
1861 lines
86 KiB
JavaScript
1861 lines
86 KiB
JavaScript
/**
|
||
* Foundation synthetic inspector — pure evaluation core.
|
||
*
|
||
* Implements charter candidate 3 (docs/plans/2026-09-06_foundation-inspector-charter.md,
|
||
* sha256 19b67211…) §§3–7 and §10 over an already-parsed bundle value. This module
|
||
* never touches the filesystem, process, environment, clock or network; it imports
|
||
* only ./canonical.mjs (which imports node:crypto for SHA-256).
|
||
*
|
||
* Global stage order (§10.4):
|
||
* 2 closed shapes / profile / supported kinds
|
||
* 3 identity index and duplicate checks
|
||
* 4 structural reference existence
|
||
* 5 cycles: task.dependencies, mission.parentMissionRef, supersedes
|
||
* 6 revision-chain continuity
|
||
* 7 per-record ownership / parent-scope
|
||
* 8 supported operation selection
|
||
* 9 context binding, current-head, work-scope guards, ordered admission (L1–L9)
|
||
* (Stage 1, bounds/lexical parsing, lives in strict-json.mjs and the CLI.)
|
||
*
|
||
* Every refusal names a FIXED rule identifier from RULES; rules are never derived
|
||
* from input text. Internal `detail` locators exist for unit tests only and are
|
||
* never part of the closed result.
|
||
*/
|
||
|
||
import { canonicalize, digestOf, CanonicalError } from "./canonical.mjs";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Constants
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export const DISCLAIMER = "SYNTHETIC PREVIEW — NO LIVE EFFECTS";
|
||
export const PREVIEW = "preview: no live registrations or permission grants";
|
||
|
||
export const OPERATION_CATALOG = Object.freeze([
|
||
"work.read", "file.read", "file.change", "command.execute", "work.propose",
|
||
"task.create", "assignment.change", "result.submit", "task.accept",
|
||
"registration.manage", "conversation.read", "conversation.observe",
|
||
"execution.launch", "execution.control", "execution.transfer",
|
||
"workspace.retire", "workspace.reopen", "message.send", "audit.read",
|
||
"project.create", "project.change", "workspace.create", "workspace.change",
|
||
"mission.create", "mission.change", "mission.accept", "task.change",
|
||
"decision.issue", "session.adopt",
|
||
]);
|
||
export const SUPPORTED_OPERATIONS = Object.freeze(["work.read", "file.read", "file.change", "assignment.change"]);
|
||
export const TASKLESS_OPERATIONS = Object.freeze(["work.read", "file.read"]);
|
||
|
||
export const RECORD_KINDS = Object.freeze([
|
||
"agent-definition", "project", "workspace", "registration", "mission", "task",
|
||
"assignment", "decision", "session", "context-source", "adapter-capability",
|
||
]);
|
||
export const SUPPORTED_KINDS = Object.freeze([
|
||
"agent-definition", "project", "workspace", "registration", "mission", "task", "assignment", "decision",
|
||
]);
|
||
export const REGISTRIES = Object.freeze([
|
||
"harness", "settings", "context-content", "scope-role", "agent-policy", "project-policy", "endpoint",
|
||
]);
|
||
export const CONTENT_REGISTRIES = Object.freeze(["scope-role", "agent-policy", "project-policy"]);
|
||
|
||
export const RESULTS = Object.freeze(["allowed", "refused", "unresolved", "invalid"]);
|
||
export const REASONS = Object.freeze([
|
||
"allowed", "invalid-request", "missing-state", "stale-revision", "not-authorized",
|
||
"unsupported-capability", "unsupported-kind", "unknown-effects", "io-failure",
|
||
]);
|
||
|
||
/** Fixed, documented rule identifiers. Nothing else is ever emitted as `rule`. */
|
||
export const RULES = Object.freeze([
|
||
// CLI / lexical (stage 1)
|
||
"usage-invalid", "input-too-large", "input-parse-failed",
|
||
"open-flags-unavailable", "input-open-failed", "input-not-regular", "input-size-changed",
|
||
// shapes / profile (stage 2)
|
||
"shape-unknown-field", "shape-missing-field", "shape-type-mismatch", "shape-enum-mismatch",
|
||
"shape-pattern-mismatch", "shape-bound-exceeded", "shape-unique-violation",
|
||
"shape-conditional-mismatch", "calendar-invalid", "path-invalid",
|
||
"record-kind-unsupported", "profile-pattern-mismatch", "registry-digest-mismatch", "mock-content-unsupported",
|
||
// identity (stage 3)
|
||
"duplicate-record-identity", "duplicate-registry-identity", "duplicate-artifact-identity",
|
||
"duplicate-authorization-id", "duplicate-delegation-input",
|
||
// structure (stage 4)
|
||
"record-reference-missing",
|
||
// cycles (stage 5)
|
||
"dependency-cycle", "mission-parent-cycle", "supersedes-cycle",
|
||
// continuity (stage 6)
|
||
"revision-chain-gap", "supersedes-mismatch",
|
||
// ownership (stage 7)
|
||
"project-id-scope-mismatch", "workspace-id-scope-mismatch", "workspace-project-missing",
|
||
"registration-parent-mismatch", "mission-parent-scope-mismatch",
|
||
"mission-owning-project-mismatch", "assignment-task-scope-mismatch",
|
||
// operation selection (stage 8)
|
||
"operation-unknown", "operation-unsupported", "operation-target-mismatch",
|
||
"proposal-required", "proposal-not-applicable",
|
||
// admission (stage 9)
|
||
"selected-agent-missing", "selected-project-missing", "selected-workspace-missing",
|
||
"authorization-undeclared", "artifact-undeclared", "registry-declaration-missing",
|
||
"agent-disabled", "agent-policy-missing", "project-not-active", "project-policy-missing",
|
||
"workspace-not-active", "workspace-policy-missing", "policy-ceiling-stale",
|
||
"registration-ambiguous", "registration-revoked", "project-registration-missing",
|
||
"workspace-registration-missing", "registration-parent-stale", "registration-parent-not-current",
|
||
"scope-role-missing",
|
||
"registration-delegation-stale", "registration-delegation-not-approved",
|
||
"registration-delegation-scope-not-modelled", "registration-delegation-subject-form",
|
||
"registration-delegation-bounds", "delegation-input-missing", "delegation-exceeds-issuer-ceiling",
|
||
"assignment-required", "assignment-stale", "assignment-not-selected", "assignment-agent-mismatch",
|
||
"assignment-workspace-mismatch", "task-ref-stale", "assignment-intent-stale",
|
||
"assignment-intent-not-applicable", "task-not-ready", "task-intent-stale", "task-intent-not-approved",
|
||
"cross-workspace-work-reference-not-modelled", "cross-project-work-reference-not-modelled",
|
||
"mission-stale", "mission-not-active", "dependency-stale",
|
||
"consulted-work-not-readable", "dependency-not-accepted",
|
||
"operation-not-permitted", "path-not-permitted",
|
||
// proposal (§5, r2 §8)
|
||
"proposal-subject-mismatch", "proposal-selection-mismatch", "requester-agent-missing",
|
||
"requester-context-mismatch",
|
||
"requester-lacks-original-scope-authority", "requester-lacks-target-scope-authority",
|
||
"message-is-not-authority", "delegation-missing", "delegation-stale", "delegation-not-approved",
|
||
"delegation-operation-not-covered", "delegation-bounds", "delegation-not-applicable",
|
||
"change-not-recorded", "change-decision-stale", "change-not-approved", "change-subjects-mismatch",
|
||
"intent-not-current", "runtime-reconciliation-required",
|
||
]);
|
||
const RULE_SET = new Set(RULES);
|
||
|
||
/** Proposal step identifiers and their only possible results (unit-tested: no "allowed"). */
|
||
export const PROPOSAL_STEPS = Object.freeze({
|
||
"requester-lacks-original-scope-authority": "refused",
|
||
"requester-lacks-target-scope-authority": "refused",
|
||
"delegation-not-applicable": "refused",
|
||
"message-is-not-authority": "refused",
|
||
"change-not-recorded": "refused",
|
||
"intent-not-current": "refused",
|
||
"runtime-reconciliation-required": "unresolved",
|
||
});
|
||
|
||
const MAX_INVENTORY = 256;
|
||
const MAX_ARRAY = 1024;
|
||
|
||
const ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||
const RUNTIME_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||
const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
|
||
const TIME_RE = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})\.[0-9]{3}Z$/;
|
||
const FORBIDDEN_CATEGORY_RE = /[\p{Cc}\p{Cf}\p{Cs}]/u;
|
||
const MAX_SAFE = 9007199254740991;
|
||
|
||
/** Typed string grammars shared with the pinned records.schema.json ($defs/id, $defs/runtimeId, $defs/digest). */
|
||
export const PINNED_PATTERNS = Object.freeze({ id: ID_RE, runtimeId: RUNTIME_ID_RE, digest: DIGEST_RE });
|
||
|
||
/**
|
||
* Schema column (pinned-checker compatibility). The pinned checker evaluates "pattern"
|
||
* through Python re.search, whose "$" (no MULTILINE) also matches immediately before
|
||
* exactly one final "\n"; the inspector's schema verdict reproduces that verdict for the
|
||
* three typed grammars. The value itself is never trimmed or normalized: the second test
|
||
* runs on a copy and only the verdict is derived from it.
|
||
*/
|
||
export function pinnedPatternMatches(re, v) {
|
||
return re.test(v) || (v.endsWith("\n") && re.test(v.slice(0, -1)));
|
||
}
|
||
|
||
/** Profile column (addendum FI-C2-1): ECMAScript "$" — the whole string must satisfy the grammar. */
|
||
export function strictPatternMatches(re, v) {
|
||
return re.test(v);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Refusal
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export class Refusal extends Error {
|
||
constructor(exit, reason, rule, detail) {
|
||
super(`${reason}/${rule}${detail ? ` at ${detail}` : ""}`);
|
||
this.name = "Refusal";
|
||
if (!RULE_SET.has(rule)) throw new Error(`internal: unknown rule ${rule}`);
|
||
if (!REASONS.includes(reason)) throw new Error(`internal: unknown reason ${reason}`);
|
||
this.exit = exit;
|
||
this.reason = reason;
|
||
this.rule = rule;
|
||
this.detail = detail || null;
|
||
this.step = null;
|
||
}
|
||
}
|
||
|
||
function refuse(exit, reason, rule, detail) {
|
||
throw new Refusal(exit, reason, rule, detail);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Generic helpers (prototype-agnostic: values may be null-prototype objects)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function isObject(v) {
|
||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||
}
|
||
|
||
function hasKey(o, k) {
|
||
return Object.prototype.hasOwnProperty.call(o, k);
|
||
}
|
||
|
||
function codePointLength(s) {
|
||
let n = 0;
|
||
for (const _ of s) n += 1; // eslint-disable-line no-unused-vars
|
||
return n;
|
||
}
|
||
|
||
function utf8ByteLength(s) {
|
||
let n = 0;
|
||
for (const ch of s) {
|
||
const cp = ch.codePointAt(0);
|
||
if (cp >= 0xd800 && cp <= 0xdfff) return -1; // lone surrogate: not encodable
|
||
n += cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
/** JSON-value deep equality; objects compare as unordered key sets (jsonschema uniqueItems semantics). */
|
||
export function deepEqual(a, b) {
|
||
if (a === b) return true;
|
||
if (typeof a !== typeof b) return false;
|
||
if (typeof a !== "object" || a === null || b === null) return false;
|
||
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
||
if (Array.isArray(a)) {
|
||
if (a.length !== b.length) return false;
|
||
for (let i = 0; i < a.length; i += 1) if (!deepEqual(a[i], b[i])) return false;
|
||
return true;
|
||
}
|
||
const ka = Object.keys(a);
|
||
const kb = Object.keys(b);
|
||
if (ka.length !== kb.length) return false;
|
||
for (const k of ka) {
|
||
if (!hasKey(b, k)) return false;
|
||
if (!deepEqual(a[k], b[k])) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function hasDuplicates(items) {
|
||
for (let i = 0; i < items.length; i += 1) {
|
||
for (let j = i + 1; j < items.length; j += 1) {
|
||
if (deepEqual(items[i], items[j])) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Format checks (pinned check.py semantics, §8)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function daysInMonth(year, month) {
|
||
if (month === 2) {
|
||
const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
||
return leap ? 29 : 28;
|
||
}
|
||
return [4, 6, 9, 11].includes(month) ? 30 : 31;
|
||
}
|
||
|
||
/**
|
||
* $defs/time: pattern plus the pinned checker's calendar semantics (check.py:
|
||
* strptime then strftime round-trip). On the measured platform (CPython 3.12.8,
|
||
* glibc 2.44) strftime("%Y") is not zero-padded below year 1000, so the pinned
|
||
* checker refuses years 0001..0999; this validator refuses them identically.
|
||
* verify-schema.py witnesses that platform behaviour before comparing.
|
||
*/
|
||
export function isValidTime(value) {
|
||
if (typeof value !== "string") return false;
|
||
const m = TIME_RE.exec(value);
|
||
if (!m) return false;
|
||
const [year, month, day, hour, minute, second] = m.slice(1, 7).map(Number);
|
||
if (year < 1000 || year > 9999) return false;
|
||
if (month < 1 || month > 12) return false;
|
||
if (day < 1 || day > daysInMonth(year, month)) return false;
|
||
if (hour > 23 || minute > 59 || second > 59) return false;
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* $defs/relativePath: schema pattern + minLength/maxLength + mosaic-relative-path
|
||
* format (check.py), implemented procedurally: no leading "/", no backslash,
|
||
* no empty/"."/".." segment, 1..4096 UTF-8 bytes, no Cc/Cf/Cs code points.
|
||
*/
|
||
export function isValidRelativePath(value) {
|
||
if (typeof value !== "string") return false;
|
||
const bytes = utf8ByteLength(value);
|
||
if (bytes < 1 || bytes > 4096) return false;
|
||
if (value.startsWith("/")) return false;
|
||
if (value.includes("\\")) return false;
|
||
if (FORBIDDEN_CATEGORY_RE.test(value)) return false;
|
||
for (const segment of value.split("/")) {
|
||
if (segment === "" || segment === "." || segment === "..") return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Shape validator (stage 2). Throws ShapeError; first failure wins.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export class ShapeError extends Error {
|
||
constructor(rule, path) {
|
||
super(`${rule} at ${path}`);
|
||
this.name = "ShapeError";
|
||
this.rule = rule;
|
||
this.path = path;
|
||
}
|
||
}
|
||
|
||
function shapeFail(rule, path) {
|
||
throw new ShapeError(rule, path);
|
||
}
|
||
|
||
function checkObject(v, path, allowed) {
|
||
if (!isObject(v)) shapeFail("shape-type-mismatch", path);
|
||
for (const k of Object.keys(v)) {
|
||
if (!allowed.includes(k)) shapeFail("shape-unknown-field", `${path}.${k}`);
|
||
}
|
||
return v;
|
||
}
|
||
|
||
function requireKeys(v, path, keys) {
|
||
for (const k of keys) if (!hasKey(v, k)) shapeFail("shape-missing-field", `${path}.${k}`);
|
||
}
|
||
|
||
function checkString(v, path, min, max) {
|
||
if (typeof v !== "string") shapeFail("shape-type-mismatch", path);
|
||
const n = codePointLength(v);
|
||
if (n < min || n > max) shapeFail("shape-bound-exceeded", path);
|
||
return v;
|
||
}
|
||
|
||
// Profile violation sink (addendum FI-C2-1). Inside a validation scope
|
||
// (validateRecordShape / validateBundleShape) every schema-valid typed value that fails
|
||
// the strict end-of-string grammar is recorded by path, in validation order, and refused
|
||
// as profile-pattern-mismatch after the shape pass and before identity, graph or
|
||
// admission. Outside a scope the same value is refused immediately (fail closed).
|
||
let profileSink = null;
|
||
|
||
function checkPattern(v, path, re) {
|
||
if (typeof v !== "string") shapeFail("shape-type-mismatch", path);
|
||
if (!pinnedPatternMatches(re, v)) shapeFail("shape-pattern-mismatch", path);
|
||
if (!strictPatternMatches(re, v)) {
|
||
if (profileSink === null) shapeFail("profile-pattern-mismatch", path);
|
||
profileSink.push(path);
|
||
}
|
||
return v;
|
||
}
|
||
|
||
function checkId(v, path) { return checkPattern(v, path, ID_RE); }
|
||
function checkRuntimeId(v, path) { return checkPattern(v, path, RUNTIME_ID_RE); }
|
||
function checkDigest(v, path) { return checkPattern(v, path, DIGEST_RE); }
|
||
|
||
function checkEnum(v, path, values) {
|
||
if (!values.includes(v)) shapeFail("shape-enum-mismatch", path);
|
||
return v;
|
||
}
|
||
|
||
function checkConst(v, path, c) {
|
||
if (v !== c) shapeFail("shape-enum-mismatch", path);
|
||
return v;
|
||
}
|
||
|
||
function checkInteger(v, path, min, max) {
|
||
if (typeof v !== "number" || !Number.isInteger(v)) shapeFail("shape-type-mismatch", path);
|
||
if (v < min || v > max) shapeFail("shape-bound-exceeded", path);
|
||
return v;
|
||
}
|
||
|
||
function checkRevision(v, path) { return checkInteger(v, path, 1, MAX_SAFE); }
|
||
|
||
function checkArray(v, path, min, max, unique, itemCheck) {
|
||
if (!Array.isArray(v)) shapeFail("shape-type-mismatch", path);
|
||
if (v.length < min || v.length > max) shapeFail("shape-bound-exceeded", path);
|
||
v.forEach((item, i) => itemCheck(item, `${path}[${i}]`));
|
||
if (unique && hasDuplicates(v)) shapeFail("shape-unique-violation", path);
|
||
return v;
|
||
}
|
||
|
||
function checkTime(v, path) {
|
||
if (typeof v !== "string") shapeFail("shape-type-mismatch", path);
|
||
if (!TIME_RE.test(v)) shapeFail("shape-pattern-mismatch", path);
|
||
if (!isValidTime(v)) shapeFail("calendar-invalid", path);
|
||
return v;
|
||
}
|
||
|
||
function checkRelativePath(v, path) {
|
||
if (typeof v !== "string") shapeFail("shape-type-mismatch", path);
|
||
if (!isValidRelativePath(v)) shapeFail("path-invalid", path);
|
||
return v;
|
||
}
|
||
|
||
function checkScope(v, path, kinds) {
|
||
if (!isObject(v)) shapeFail("shape-type-mismatch", path);
|
||
if (!hasKey(v, "kind")) shapeFail("shape-missing-field", `${path}.kind`);
|
||
const kind = v.kind;
|
||
if (!["system", "project", "workspace"].includes(kind)) shapeFail("shape-enum-mismatch", `${path}.kind`);
|
||
if (kind === "system") {
|
||
checkObject(v, path, ["kind"]);
|
||
} else if (kind === "project") {
|
||
checkObject(v, path, ["kind", "projectId"]);
|
||
requireKeys(v, path, ["projectId"]);
|
||
checkId(v.projectId, `${path}.projectId`);
|
||
} else {
|
||
checkObject(v, path, ["kind", "projectId", "workspaceId"]);
|
||
requireKeys(v, path, ["projectId", "workspaceId"]);
|
||
checkId(v.projectId, `${path}.projectId`);
|
||
checkId(v.workspaceId, `${path}.workspaceId`);
|
||
}
|
||
if (kinds && !kinds.includes(kind)) shapeFail("shape-enum-mismatch", `${path}.kind`);
|
||
return v;
|
||
}
|
||
|
||
function checkRecordRef(v, path, kind) {
|
||
checkObject(v, path, ["kind", "id", "scope", "revision"]);
|
||
requireKeys(v, path, ["kind", "id", "scope", "revision"]);
|
||
checkEnum(v.kind, `${path}.kind`, RECORD_KINDS);
|
||
if (kind && v.kind !== kind) shapeFail("shape-enum-mismatch", `${path}.kind`);
|
||
checkId(v.id, `${path}.id`);
|
||
checkScope(v.scope, `${path}.scope`, null);
|
||
checkRevision(v.revision, `${path}.revision`);
|
||
return v;
|
||
}
|
||
|
||
function checkRegistryRef(v, path, registry) {
|
||
checkObject(v, path, ["registry", "id", "revision", "digest"]);
|
||
requireKeys(v, path, ["registry", "id", "revision", "digest"]);
|
||
checkEnum(v.registry, `${path}.registry`, REGISTRIES);
|
||
if (registry && v.registry !== registry) shapeFail("shape-enum-mismatch", `${path}.registry`);
|
||
checkId(v.id, `${path}.id`);
|
||
checkRevision(v.revision, `${path}.revision`);
|
||
checkDigest(v.digest, `${path}.digest`);
|
||
return v;
|
||
}
|
||
|
||
function checkArtifactRef(v, path) {
|
||
checkObject(v, path, ["runId", "artifactId", "digest"]);
|
||
requireKeys(v, path, ["runId", "artifactId", "digest"]);
|
||
checkId(v.runId, `${path}.runId`);
|
||
checkId(v.artifactId, `${path}.artifactId`);
|
||
checkDigest(v.digest, `${path}.digest`);
|
||
return v;
|
||
}
|
||
|
||
function checkActor(v, path) {
|
||
checkObject(v, path, ["kind", "principalId", "executionId"]);
|
||
requireKeys(v, path, ["kind", "principalId", "executionId"]);
|
||
checkEnum(v.kind, `${path}.kind`, ["human", "service", "agent"]);
|
||
checkId(v.principalId, `${path}.principalId`);
|
||
if (v.kind === "agent") {
|
||
if (v.executionId === null) shapeFail("shape-conditional-mismatch", `${path}.executionId`);
|
||
checkRuntimeId(v.executionId, `${path}.executionId`);
|
||
} else if (v.executionId !== null) {
|
||
shapeFail("shape-conditional-mismatch", `${path}.executionId`);
|
||
}
|
||
return v;
|
||
}
|
||
|
||
function checkPathGrant(v, path) {
|
||
checkObject(v, path, ["root", "path"]);
|
||
requireKeys(v, path, ["root", "path"]);
|
||
checkConst(v.root, `${path}.root`, "workspace");
|
||
if (v.path !== null) checkRelativePath(v.path, `${path}.path`);
|
||
return v;
|
||
}
|
||
|
||
export function checkRestrictions(v, path) {
|
||
checkObject(v, path, ["operations", "readPaths", "writePaths", "network", "endpointRefs"]);
|
||
requireKeys(v, path, ["operations", "readPaths", "writePaths", "network", "endpointRefs"]);
|
||
checkArray(v.operations, `${path}.operations`, 0, MAX_ARRAY, true, (op, p) => checkEnum(op, p, OPERATION_CATALOG));
|
||
checkArray(v.readPaths, `${path}.readPaths`, 0, MAX_ARRAY, true, checkPathGrant);
|
||
checkArray(v.writePaths, `${path}.writePaths`, 0, MAX_ARRAY, true, checkPathGrant);
|
||
checkEnum(v.network, `${path}.network`, ["none", "approved-endpoints"]);
|
||
checkArray(v.endpointRefs, `${path}.endpointRefs`, 0, MAX_ARRAY, true, (r, p) => checkRegistryRef(r, p, "endpoint"));
|
||
if (v.network === "none") {
|
||
if (v.endpointRefs.length !== 0) shapeFail("shape-conditional-mismatch", `${path}.endpointRefs`);
|
||
} else if (v.endpointRefs.length < 1) {
|
||
shapeFail("shape-conditional-mismatch", `${path}.endpointRefs`);
|
||
}
|
||
return v;
|
||
}
|
||
|
||
function checkNullable(v, path, check) {
|
||
if (v === null) return v;
|
||
return check(v, path);
|
||
}
|
||
|
||
function checkCriteria(v, path) {
|
||
return checkArray(v, path, 1, MAX_ARRAY, true, (c, p) => {
|
||
checkObject(c, p, ["id", "text"]);
|
||
requireKeys(c, p, ["id", "text"]);
|
||
checkId(c.id, `${p}.id`);
|
||
checkString(c.text, `${p}.text`, 1, 4000);
|
||
});
|
||
}
|
||
|
||
function checkContentRefs(v, path) {
|
||
return checkArray(v, path, 0, MAX_ARRAY, true, (r, p) => checkRegistryRef(r, p, "context-content"));
|
||
}
|
||
|
||
/**
|
||
* The envelope pass has already validated rec.scope in full (including its typed ids);
|
||
* payload checks only constrain which scope kinds the record kind admits. Re-validating
|
||
* the scope here would double-count profile violations under a bare path.
|
||
*/
|
||
function requireScopeKind(rec, payloadPath, kinds) {
|
||
if (!kinds.includes(rec.scope.kind)) shapeFail("shape-enum-mismatch", `${payloadPath.slice(0, -".payload".length)}.scope.kind`);
|
||
}
|
||
|
||
const PAYLOAD_CHECKS = {
|
||
"agent-definition": (p, path, rec) => {
|
||
requireScopeKind(rec, path, ["system"]);
|
||
const keys = ["displayName", "agentType", "harnessRef", "settingsRef", "soulRef", "instructionRefs", "skillRefs", "policyRef", "status"];
|
||
checkObject(p, path, keys);
|
||
requireKeys(p, path, keys);
|
||
checkString(p.displayName, `${path}.displayName`, 1, 128);
|
||
checkId(p.agentType, `${path}.agentType`);
|
||
checkRegistryRef(p.harnessRef, `${path}.harnessRef`, "harness");
|
||
checkRegistryRef(p.settingsRef, `${path}.settingsRef`, "settings");
|
||
checkRegistryRef(p.soulRef, `${path}.soulRef`, "context-content");
|
||
checkContentRefs(p.instructionRefs, `${path}.instructionRefs`);
|
||
checkContentRefs(p.skillRefs, `${path}.skillRefs`);
|
||
checkRegistryRef(p.policyRef, `${path}.policyRef`, "agent-policy");
|
||
checkEnum(p.status, `${path}.status`, ["enabled", "disabled"]);
|
||
},
|
||
project: (p, path, rec) => {
|
||
requireScopeKind(rec, path, ["project"]);
|
||
const keys = ["displayName", "policyRef", "status"];
|
||
checkObject(p, path, keys);
|
||
requireKeys(p, path, keys);
|
||
checkString(p.displayName, `${path}.displayName`, 1, 128);
|
||
checkRegistryRef(p.policyRef, `${path}.policyRef`, "project-policy");
|
||
checkEnum(p.status, `${path}.status`, ["active", "retired"]);
|
||
},
|
||
workspace: (p, path, rec) => {
|
||
requireScopeKind(rec, path, ["workspace"]);
|
||
const keys = ["displayName", "policyRef", "status", "fileRootId"];
|
||
checkObject(p, path, keys);
|
||
requireKeys(p, path, keys);
|
||
checkString(p.displayName, `${path}.displayName`, 1, 128);
|
||
checkRegistryRef(p.policyRef, `${path}.policyRef`, "project-policy");
|
||
checkEnum(p.status, `${path}.status`, ["active", "retiring", "retired"]);
|
||
checkId(p.fileRootId, `${path}.fileRootId`);
|
||
},
|
||
registration: (p, path, rec) => {
|
||
requireScopeKind(rec, path, ["project", "workspace"]);
|
||
const keys = ["agentId", "scopeRoleRef", "restrictions", "projectRegistrationRef", "status", "delegationRef"];
|
||
checkObject(p, path, keys);
|
||
requireKeys(p, path, keys);
|
||
checkId(p.agentId, `${path}.agentId`);
|
||
checkRegistryRef(p.scopeRoleRef, `${path}.scopeRoleRef`, "scope-role");
|
||
checkNullable(p.restrictions, `${path}.restrictions`, checkRestrictions);
|
||
checkNullable(p.projectRegistrationRef, `${path}.projectRegistrationRef`, (v, q) => checkRecordRef(v, q, "registration"));
|
||
checkEnum(p.status, `${path}.status`, ["active", "revoked"]);
|
||
checkNullable(p.delegationRef, `${path}.delegationRef`, (v, q) => checkRecordRef(v, q, "decision"));
|
||
if (rec.scope.kind === "project") {
|
||
if (p.projectRegistrationRef !== null) shapeFail("shape-conditional-mismatch", `${path}.projectRegistrationRef`);
|
||
} else if (p.projectRegistrationRef === null) {
|
||
shapeFail("shape-conditional-mismatch", `${path}.projectRegistrationRef`);
|
||
}
|
||
},
|
||
mission: (p, path, rec) => {
|
||
requireScopeKind(rec, path, ["project", "workspace"]);
|
||
const keys = ["objective", "criteria", "parentMissionRef", "restrictions", "status"];
|
||
checkObject(p, path, keys);
|
||
requireKeys(p, path, keys);
|
||
checkString(p.objective, `${path}.objective`, 1, 4000);
|
||
checkCriteria(p.criteria, `${path}.criteria`);
|
||
checkNullable(p.parentMissionRef, `${path}.parentMissionRef`, (v, q) => checkRecordRef(v, q, "mission"));
|
||
checkNullable(p.restrictions, `${path}.restrictions`, checkRestrictions);
|
||
checkEnum(p.status, `${path}.status`, ["proposed", "active", "blocked", "ready-for-review", "accepted", "canceled"]);
|
||
if (rec.scope.kind === "project" && p.parentMissionRef !== null) {
|
||
shapeFail("shape-conditional-mismatch", `${path}.parentMissionRef`);
|
||
}
|
||
},
|
||
task: (p, path, rec) => {
|
||
requireScopeKind(rec, path, ["workspace"]);
|
||
const keys = ["purpose", "criteria", "missionRef", "intentRef", "dependencies", "restrictions", "status"];
|
||
checkObject(p, path, keys);
|
||
requireKeys(p, path, keys);
|
||
checkString(p.purpose, `${path}.purpose`, 1, 4000);
|
||
checkCriteria(p.criteria, `${path}.criteria`);
|
||
checkNullable(p.missionRef, `${path}.missionRef`, (v, q) => checkRecordRef(v, q, "mission"));
|
||
checkRecordRef(p.intentRef, `${path}.intentRef`, "decision");
|
||
checkArray(p.dependencies, `${path}.dependencies`, 0, MAX_ARRAY, true, (v, q) => checkRecordRef(v, q, "task"));
|
||
checkNullable(p.restrictions, `${path}.restrictions`, checkRestrictions);
|
||
checkEnum(p.status, `${path}.status`, ["proposed", "ready", "active", "blocked", "ready-for-review", "accepted", "canceled"]);
|
||
},
|
||
assignment: (p, path, rec) => {
|
||
requireScopeKind(rec, path, ["workspace"]);
|
||
const keys = ["taskRef", "agentId", "intentRef", "status", "endReason"];
|
||
checkObject(p, path, keys);
|
||
requireKeys(p, path, keys);
|
||
checkRecordRef(p.taskRef, `${path}.taskRef`, "task");
|
||
checkId(p.agentId, `${path}.agentId`);
|
||
checkRecordRef(p.intentRef, `${path}.intentRef`, "decision");
|
||
checkEnum(p.status, `${path}.status`, ["selected", "paused", "ended"]);
|
||
checkEnum(p.endReason, `${path}.endReason`, [null, "completed", "abandoned", "revoked", "reassigned", "canceled"]);
|
||
if (p.status === "ended") {
|
||
if (typeof p.endReason !== "string") shapeFail("shape-conditional-mismatch", `${path}.endReason`);
|
||
} else if (p.endReason !== null) {
|
||
shapeFail("shape-conditional-mismatch", `${path}.endReason`);
|
||
}
|
||
},
|
||
decision: (p, path, rec) => {
|
||
requireScopeKind(rec, path, ["system", "project", "workspace"]);
|
||
const keys = ["decisionKind", "subjectRefs", "outcome", "basisRef", "evidenceRefs", "delegatedOperations"];
|
||
checkObject(p, path, keys);
|
||
requireKeys(p, path, keys);
|
||
checkEnum(p.decisionKind, `${path}.decisionKind`, ["plan-approval", "delegation", "assignment-change", "acceptance", "owner-checkpoint", "reconciliation"]);
|
||
checkArray(p.subjectRefs, `${path}.subjectRefs`, 1, MAX_ARRAY, true, (v, q) => checkRecordRef(v, q, null));
|
||
checkEnum(p.outcome, `${path}.outcome`, ["approved", "rejected"]);
|
||
checkArtifactRef(p.basisRef, `${path}.basisRef`);
|
||
checkArray(p.evidenceRefs, `${path}.evidenceRefs`, 0, MAX_ARRAY, true, checkArtifactRef);
|
||
checkArray(p.delegatedOperations, `${path}.delegatedOperations`, 0, MAX_ARRAY, true, (op, q) => checkEnum(op, q, OPERATION_CATALOG));
|
||
if (p.decisionKind === "delegation" && p.outcome === "approved") {
|
||
if (p.delegatedOperations.length < 1) shapeFail("shape-conditional-mismatch", `${path}.delegatedOperations`);
|
||
} else if (p.delegatedOperations.length !== 0) {
|
||
shapeFail("shape-conditional-mismatch", `${path}.delegatedOperations`);
|
||
}
|
||
},
|
||
};
|
||
|
||
const ENVELOPE_KEYS = ["schemaVersion", "kind", "id", "scope", "revision", "createdAt", "createdBy", "supersedes", "authorizationRef", "payload"];
|
||
|
||
/**
|
||
* Validate one candidate record against the pinned records.schema.json for the
|
||
* eight supported kinds. Returns {verdict: "valid", profileViolations: [path, ...]}
|
||
* | {verdict: "invalid", rule, path} | {verdict: "unsupported-kind", rule, path}
|
||
* (schema-known kind not modelled here). The verdict is the schema column; the
|
||
* profileViolations list (empty when the strict profile holds) is the independent
|
||
* profile column: typed id/runtimeId/digest values the pinned checker accepts only
|
||
* through its end-of-string newline tolerance, in validation order.
|
||
*/
|
||
export function validateRecordShape(rec, path = "record") {
|
||
const outer = profileSink;
|
||
profileSink = [];
|
||
try {
|
||
checkObject(rec, path, ENVELOPE_KEYS);
|
||
requireKeys(rec, path, ENVELOPE_KEYS);
|
||
checkConst(rec.schemaVersion, `${path}.schemaVersion`, 1);
|
||
checkEnum(rec.kind, `${path}.kind`, RECORD_KINDS);
|
||
if (!SUPPORTED_KINDS.includes(rec.kind)) return { verdict: "unsupported-kind", rule: "record-kind-unsupported", path: `${path}.kind` };
|
||
checkId(rec.id, `${path}.id`);
|
||
checkScope(rec.scope, `${path}.scope`, null);
|
||
checkRevision(rec.revision, `${path}.revision`);
|
||
checkTime(rec.createdAt, `${path}.createdAt`);
|
||
checkActor(rec.createdBy, `${path}.createdBy`);
|
||
if (rec.revision === 1) {
|
||
if (rec.supersedes !== null) shapeFail("shape-conditional-mismatch", `${path}.supersedes`);
|
||
} else {
|
||
if (rec.supersedes === null) shapeFail("shape-conditional-mismatch", `${path}.supersedes`);
|
||
checkRecordRef(rec.supersedes, `${path}.supersedes`, null);
|
||
}
|
||
checkRuntimeId(rec.authorizationRef, `${path}.authorizationRef`);
|
||
if (!isObject(rec.payload)) shapeFail("shape-type-mismatch", `${path}.payload`);
|
||
PAYLOAD_CHECKS[rec.kind](rec.payload, `${path}.payload`, rec);
|
||
return { verdict: "valid", profileViolations: profileSink };
|
||
} catch (e) {
|
||
if (e instanceof ShapeError) return { verdict: "invalid", rule: e.rule, path: e.path };
|
||
throw e;
|
||
} finally {
|
||
profileSink = outer;
|
||
}
|
||
}
|
||
|
||
function checkExecution(v, path) {
|
||
if (!isObject(v)) shapeFail("shape-type-mismatch", path);
|
||
if (!hasKey(v, "kind")) shapeFail("shape-missing-field", `${path}.kind`);
|
||
if (v.kind === "unrestricted-simulation") {
|
||
checkObject(v, path, ["kind"]);
|
||
} else if (v.kind === "restrictions") {
|
||
checkObject(v, path, ["kind", "restrictions"]);
|
||
requireKeys(v, path, ["restrictions"]);
|
||
checkRestrictions(v.restrictions, `${path}.restrictions`);
|
||
} else {
|
||
shapeFail("shape-enum-mismatch", `${path}.kind`);
|
||
}
|
||
return v;
|
||
}
|
||
|
||
function checkRegistryEntry(v, path) {
|
||
if (!isObject(v)) shapeFail("shape-type-mismatch", path);
|
||
requireKeys(v, path, ["registry", "id", "revision", "digest"]);
|
||
checkEnum(v.registry, `${path}.registry`, REGISTRIES);
|
||
const contentBearing = CONTENT_REGISTRIES.includes(v.registry);
|
||
checkObject(v, path, contentBearing ? ["registry", "id", "revision", "digest", "content"] : ["registry", "id", "revision", "digest"]);
|
||
checkId(v.id, `${path}.id`);
|
||
checkRevision(v.revision, `${path}.revision`);
|
||
checkDigest(v.digest, `${path}.digest`);
|
||
if (contentBearing) {
|
||
requireKeys(v, path, ["content"]);
|
||
checkObject(v.content, `${path}.content`, ["restrictions"]);
|
||
requireKeys(v.content, `${path}.content`, ["restrictions"]);
|
||
checkRestrictions(v.content.restrictions, `${path}.content.restrictions`);
|
||
}
|
||
return v;
|
||
}
|
||
|
||
function checkRequesterContext(v, path) {
|
||
checkObject(v, path, ["assignmentRef", "execution"]);
|
||
requireKeys(v, path, ["assignmentRef", "execution"]);
|
||
checkRecordRef(v.assignmentRef, `${path}.assignmentRef`, "assignment");
|
||
checkExecution(v.execution, `${path}.execution`);
|
||
return v;
|
||
}
|
||
|
||
const BUNDLE_KEYS = ["bundleVersion", "kind", "simulation", "records", "registries", "artifacts", "authorizations", "selection", "operation", "proposal", "delegationInputs"];
|
||
const PROPOSAL_KEYS = ["kind", "requesterAgentId", "subjectAssignmentRef", "targetTaskRef", "delegationRef", "changeDecisionRef", "requesterContexts", "message"];
|
||
|
||
/**
|
||
* Stage 2: closed bundle shape (pinned-schema column), then the strict pattern profile
|
||
* (addendum FI-C2-1) over every typed field of records, wrapper and declared inputs,
|
||
* then the mock-content digest profile. Throws Refusal; nothing later (identity,
|
||
* graph, admission) sees a bundle that failed any of the three.
|
||
*/
|
||
export function validateBundleShape(b) {
|
||
const outer = profileSink;
|
||
profileSink = [];
|
||
try {
|
||
validateBundleShapeScoped(b);
|
||
} finally {
|
||
profileSink = outer;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Total ordering form over the strict-JSON input domain (§10.4 content tie-break).
|
||
* Deliberately distinct from canonical.mjs, whose ASCII-only safe-integer domain is
|
||
* the MOCK content-digest domain and refuses everything else: every value the strict
|
||
* parser can produce (null, booleans, safe integers, any Unicode string, arrays,
|
||
* objects) has exactly one form here, and distinct values have distinct forms —
|
||
* object keys sorted by UTF-16 code unit, compact separators, array order preserved,
|
||
* strings and keys as well-formed JSON string literals, no normalization or
|
||
* case-folding. It is used only to order traversal; it is never a digest, never
|
||
* echoed and never a validity judgement (FI-FILBERT-7 R5-1).
|
||
*/
|
||
export function orderingForm(value) {
|
||
if (value === null || typeof value === "boolean" || typeof value === "number") return String(value);
|
||
if (typeof value === "string") return JSON.stringify(value);
|
||
if (Array.isArray(value)) return `[${value.map(orderingForm).join(",")}]`;
|
||
if (typeof value === "object") {
|
||
const keys = Object.keys(value).sort((x, y) => (x < y ? -1 : x > y ? 1 : 0));
|
||
return `{${keys.map((k) => `${JSON.stringify(k)}:${orderingForm(value[k])}`).join(",")}}`;
|
||
}
|
||
throw new Error("internal: ordering form of a non-JSON value");
|
||
}
|
||
|
||
/**
|
||
* §10.4: within a stage, records and declared inventories are traversed in stable
|
||
* typed-key order, never input order. Values may still be unvalidated (shape stage),
|
||
* so keys are read tolerantly: `keyOf` returns an array of typed components
|
||
* (string | integer | null, compared component-wise, a null component after every
|
||
* readable value) or null when the item has no readable identity at all. Items without
|
||
* a readable identity sort after every keyed item; equal keys (duplicates, malformed
|
||
* entries) break ties on the total ordering form of the whole entry, then on input
|
||
* index — which only decides between byte-identical values — so no permutation of the
|
||
* input can change the first reported failure. Returns input indices in traversal
|
||
* order; locator paths keep the input index.
|
||
*/
|
||
export function stableOrder(items, keyOf) {
|
||
const memo = new Map();
|
||
const form = (i) => {
|
||
if (!memo.has(i)) memo.set(i, orderingForm(items[i]));
|
||
return memo.get(i);
|
||
};
|
||
const keys = items.map((item, i) => ({ i, key: keyOf(item) }));
|
||
keys.sort((a, b) => {
|
||
if ((a.key === null) !== (b.key === null)) return a.key === null ? 1 : -1;
|
||
if (a.key !== null) {
|
||
const c = compareTypedKeys(a.key, b.key);
|
||
if (c !== 0) return c;
|
||
}
|
||
const fa = form(a.i);
|
||
const fb = form(b.i);
|
||
if (fa !== fb) return fa < fb ? -1 : 1;
|
||
return a.i - b.i;
|
||
});
|
||
return keys.map((k) => k.i);
|
||
}
|
||
|
||
function compareTypedKeys(a, b) {
|
||
const n = Math.max(a.length, b.length);
|
||
for (let k = 0; k < n; k += 1) {
|
||
const x = k < a.length ? a[k] : null;
|
||
const y = k < b.length ? b[k] : null;
|
||
if (x === y) continue;
|
||
if (x === null) return 1;
|
||
if (y === null) return -1;
|
||
if (typeof x !== typeof y) return typeof x === "string" ? -1 : 1;
|
||
return x < y ? -1 : 1;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
const str = (v) => (typeof v === "string" ? v : null);
|
||
const int = (v) => (Number.isInteger(v) ? v : null);
|
||
/** Tolerant scope key: the scope kind must be readable, ids default to "". */
|
||
function tolerantScopeKey(sc) {
|
||
if (!isObject(sc) || typeof sc.kind !== "string") return null;
|
||
return scopeKey({ kind: sc.kind, projectId: typeof sc.projectId === "string" ? sc.projectId : "", workspaceId: typeof sc.workspaceId === "string" ? sc.workspaceId : "" });
|
||
}
|
||
/** Tolerant kind/id/scope/revision key of a record or recordRef; null unless kind, id and scope kind are readable. */
|
||
function tolerantRefKey(v) {
|
||
if (!isObject(v) || typeof v.kind !== "string" || typeof v.id !== "string") return null;
|
||
const sk = tolerantScopeKey(v.scope);
|
||
if (sk === null) return null;
|
||
return [v.kind, v.id, sk, int(v.revision)];
|
||
}
|
||
|
||
/** Typed identity keys per traversal family (§10.4): kind/id/scope/revision for records. */
|
||
export const ORDER_KEYS = Object.freeze({
|
||
records: tolerantRefKey,
|
||
registries: (e) => (isObject(e) && typeof e.registry === "string" && typeof e.id === "string" ? [e.registry, e.id, int(e.revision), str(e.digest)] : null),
|
||
artifacts: (a) => (isObject(a) && typeof a.runId === "string" && typeof a.artifactId === "string" ? [a.runId, a.artifactId, str(a.digest)] : null),
|
||
authorizations: (a) => (typeof a === "string" ? [a] : null),
|
||
delegationInputs: (d) => (isObject(d) ? tolerantRefKey(d.decisionRef) : null),
|
||
});
|
||
|
||
function shapeValidationOrder(records) {
|
||
return stableOrder(records, ORDER_KEYS.records);
|
||
}
|
||
|
||
/** checkArray for a declared inventory: bounds first, then every item in stable order with its input-index path. */
|
||
function checkInventory(v, path, itemCheck, keyOf) {
|
||
if (!Array.isArray(v)) shapeFail("shape-type-mismatch", path);
|
||
if (v.length > MAX_INVENTORY) shapeFail("shape-bound-exceeded", path);
|
||
for (const i of stableOrder(v, keyOf)) itemCheck(v[i], `${path}[${i}]`);
|
||
return v;
|
||
}
|
||
|
||
function validateBundleShapeScoped(b) {
|
||
try {
|
||
checkObject(b, "bundle", BUNDLE_KEYS);
|
||
requireKeys(b, "bundle", BUNDLE_KEYS);
|
||
checkConst(b.bundleVersion, "bundle.bundleVersion", 1);
|
||
checkConst(b.kind, "bundle.kind", "foundation-inspector-bundle");
|
||
checkConst(b.simulation, "bundle.simulation", true);
|
||
if (!Array.isArray(b.records)) shapeFail("shape-type-mismatch", "bundle.records");
|
||
if (b.records.length > MAX_INVENTORY) shapeFail("shape-bound-exceeded", "bundle.records");
|
||
for (const i of shapeValidationOrder(b.records)) {
|
||
const v = validateRecordShape(b.records[i], `bundle.records[${i}]`);
|
||
if (v.verdict === "unsupported-kind") refuse(2, "unsupported-kind", "record-kind-unsupported", v.path);
|
||
if (v.verdict !== "valid") shapeFail(v.rule, v.path);
|
||
profileSink.push(...v.profileViolations);
|
||
}
|
||
checkInventory(b.registries, "bundle.registries", checkRegistryEntry, ORDER_KEYS.registries);
|
||
checkInventory(b.artifacts, "bundle.artifacts", checkArtifactRef, ORDER_KEYS.artifacts);
|
||
checkInventory(b.authorizations, "bundle.authorizations", checkRuntimeId, ORDER_KEYS.authorizations);
|
||
const s = b.selection;
|
||
checkObject(s, "bundle.selection", ["agentId", "projectId", "workspaceId", "assignmentRef", "execution"]);
|
||
requireKeys(s, "bundle.selection", ["agentId", "projectId", "workspaceId", "assignmentRef", "execution"]);
|
||
checkId(s.agentId, "bundle.selection.agentId");
|
||
checkId(s.projectId, "bundle.selection.projectId");
|
||
checkId(s.workspaceId, "bundle.selection.workspaceId");
|
||
checkNullable(s.assignmentRef, "bundle.selection.assignmentRef", (v, q) => checkRecordRef(v, q, "assignment"));
|
||
checkExecution(s.execution, "bundle.selection.execution");
|
||
const o = b.operation;
|
||
checkObject(o, "bundle.operation", ["name", "target"]);
|
||
requireKeys(o, "bundle.operation", ["name", "target"]);
|
||
checkString(o.name, "bundle.operation.name", 1, 4096);
|
||
checkNullable(o.target, "bundle.operation.target", (v, q) => {
|
||
checkObject(v, q, ["root", "path"]);
|
||
requireKeys(v, q, ["root", "path"]);
|
||
checkConst(v.root, `${q}.root`, "workspace");
|
||
checkRelativePath(v.path, `${q}.path`);
|
||
});
|
||
const p = b.proposal;
|
||
if (p !== null) {
|
||
checkObject(p, "bundle.proposal", PROPOSAL_KEYS);
|
||
requireKeys(p, "bundle.proposal", PROPOSAL_KEYS.filter((k) => k !== "message"));
|
||
checkConst(p.kind, "bundle.proposal.kind", "assignment-change");
|
||
checkId(p.requesterAgentId, "bundle.proposal.requesterAgentId");
|
||
checkRecordRef(p.subjectAssignmentRef, "bundle.proposal.subjectAssignmentRef", "assignment");
|
||
checkRecordRef(p.targetTaskRef, "bundle.proposal.targetTaskRef", "task");
|
||
checkNullable(p.delegationRef, "bundle.proposal.delegationRef", (v, q) => checkRecordRef(v, q, "decision"));
|
||
checkNullable(p.changeDecisionRef, "bundle.proposal.changeDecisionRef", (v, q) => checkRecordRef(v, q, "decision"));
|
||
checkObject(p.requesterContexts, "bundle.proposal.requesterContexts", ["original", "target"]);
|
||
requireKeys(p.requesterContexts, "bundle.proposal.requesterContexts", ["original", "target"]);
|
||
checkRequesterContext(p.requesterContexts.original, "bundle.proposal.requesterContexts.original");
|
||
checkRequesterContext(p.requesterContexts.target, "bundle.proposal.requesterContexts.target");
|
||
if (hasKey(p, "message")) checkString(p.message, "bundle.proposal.message", 0, 4000);
|
||
}
|
||
checkInventory(b.delegationInputs, "bundle.delegationInputs", (d, q) => {
|
||
checkObject(d, q, ["decisionRef", "mode", "issuerCeiling"]);
|
||
requireKeys(d, q, ["decisionRef", "mode", "issuerCeiling"]);
|
||
checkRecordRef(d.decisionRef, `${q}.decisionRef`, "decision");
|
||
checkConst(d.mode, `${q}.mode`, "direct-declared");
|
||
checkRestrictions(d.issuerCeiling, `${q}.issuerCeiling`);
|
||
}, ORDER_KEYS.delegationInputs);
|
||
} catch (e) {
|
||
if (e instanceof ShapeError) refuse(2, "invalid-request", e.rule, e.path);
|
||
throw e;
|
||
}
|
||
// Profile: strict typed-string grammar (addendum FI-C2-1). Schema-valid under the
|
||
// pinned checker's newline tolerance, refused here before any identity lookup; the
|
||
// first violating path in validation order is the internal locator, never the value.
|
||
if (profileSink.length > 0) refuse(2, "invalid-request", "profile-pattern-mismatch", profileSink[0]);
|
||
// Profile: mock registry content digests (inspector-content-digest/1), in stable
|
||
// registry/id/revision/digest order (§10.4), never input order.
|
||
for (const i of stableOrder(b.registries, ORDER_KEYS.registries)) {
|
||
const entry = b.registries[i];
|
||
if (!CONTENT_REGISTRIES.includes(entry.registry)) continue;
|
||
let digest;
|
||
try {
|
||
digest = digestOf(entry.content);
|
||
} catch (e) {
|
||
if (e instanceof CanonicalError) refuse(2, "unsupported-capability", "mock-content-unsupported", `bundle.registries[${i}].content`);
|
||
throw e;
|
||
}
|
||
if (digest !== entry.digest) refuse(2, "invalid-request", "registry-digest-mismatch", `bundle.registries[${i}].digest`);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Identity helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export function scopeKey(scope) {
|
||
if (scope.kind === "system") return "system";
|
||
if (scope.kind === "project") return `project:${scope.projectId}`;
|
||
return `workspace:${scope.projectId}/${scope.workspaceId}`;
|
||
}
|
||
|
||
export function identityKey(kind, id, scope) {
|
||
return `${kind}|${id}|${scopeKey(scope)}`;
|
||
}
|
||
|
||
function refIdentityKey(ref) {
|
||
return identityKey(ref.kind, ref.id, ref.scope);
|
||
}
|
||
|
||
function refKey(ref) {
|
||
return `${refIdentityKey(ref)}#${ref.revision}`;
|
||
}
|
||
|
||
function recordKey(rec) {
|
||
return refKey(rec);
|
||
}
|
||
|
||
function plainScope(scope) {
|
||
if (scope.kind === "system") return { kind: "system" };
|
||
if (scope.kind === "project") return { kind: "project", projectId: scope.projectId };
|
||
return { kind: "workspace", projectId: scope.projectId, workspaceId: scope.workspaceId };
|
||
}
|
||
|
||
/** Ordinary-prototype copy of a recordRef with fixed key order. */
|
||
export function plainRef(ref) {
|
||
if (ref === null) return null;
|
||
return { kind: ref.kind, id: ref.id, scope: plainScope(ref.scope), revision: ref.revision };
|
||
}
|
||
|
||
function refEquals(a, b) {
|
||
return a.kind === b.kind && a.id === b.id && a.revision === b.revision && scopeKey(a.scope) === scopeKey(b.scope);
|
||
}
|
||
|
||
function sameIdentity(a, b) {
|
||
return a.kind === b.kind && a.id === b.id && scopeKey(a.scope) === scopeKey(b.scope);
|
||
}
|
||
|
||
function compareRecords(a, b) {
|
||
const ka = [a.kind, a.id, scopeKey(a.scope)];
|
||
const kb = [b.kind, b.id, scopeKey(b.scope)];
|
||
for (let i = 0; i < 3; i += 1) {
|
||
if (ka[i] < kb[i]) return -1;
|
||
if (ka[i] > kb[i]) return 1;
|
||
}
|
||
return a.revision - b.revision;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Cycle detection (stage 5) — exported for detector unit tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* edges: Map<node, node[]>. Nodes are visited in sorted order; returns the first
|
||
* node found on a cycle (including self-loops) or null.
|
||
*/
|
||
export function findCycle(edges) {
|
||
const WHITE = 0; const GREY = 1; const BLACK = 2;
|
||
const state = new Map();
|
||
const nodes = [...edges.keys()].sort();
|
||
for (const start of nodes) {
|
||
if (state.get(start)) continue;
|
||
const stack = [[start, 0]];
|
||
state.set(start, GREY);
|
||
while (stack.length) {
|
||
const frame = stack[stack.length - 1];
|
||
const [node] = frame;
|
||
const next = (edges.get(node) || []);
|
||
if (frame[1] < next.length) {
|
||
const target = next[frame[1]];
|
||
frame[1] += 1;
|
||
const st = state.get(target) || WHITE;
|
||
if (st === GREY) return target;
|
||
if (st === WHITE) {
|
||
state.set(target, GREY);
|
||
stack.push([target, 0]);
|
||
}
|
||
} else {
|
||
state.set(node, BLACK);
|
||
stack.pop();
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Restrictions calculus (§4): component-prefix paths, set intersection
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function grantSegments(grant) {
|
||
return grant.path === null ? [] : grant.path.split("/");
|
||
}
|
||
|
||
/** Is grant `a` a segment-prefix of grant `b` (a covers b)? */
|
||
function grantCovers(a, b) {
|
||
const sa = grantSegments(a);
|
||
const sb = grantSegments(b);
|
||
if (sa.length > sb.length) return false;
|
||
for (let i = 0; i < sa.length; i += 1) if (sa[i] !== sb[i]) return false;
|
||
return true;
|
||
}
|
||
|
||
export function intersectGrants(A, B) {
|
||
const out = [];
|
||
const seen = new Set();
|
||
for (const a of A) {
|
||
for (const b of B) {
|
||
let keep = null;
|
||
if (grantCovers(a, b)) keep = b;
|
||
else if (grantCovers(b, a)) keep = a;
|
||
if (keep) {
|
||
const key = keep.path === null ? " root" : keep.path;
|
||
if (!seen.has(key)) {
|
||
seen.add(key);
|
||
out.push({ root: "workspace", path: keep.path });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export function pathPermitted(path, grants) {
|
||
const target = { root: "workspace", path };
|
||
return grants.some((g) => grantCovers(g, target));
|
||
}
|
||
|
||
/**
|
||
* A layer is {operations, readPaths, writePaths, network, endpointRefs}; any member
|
||
* may be null meaning "no narrowing" (used for delegated-operation sets). A full
|
||
* candidate restrictions object is a layer with every member present.
|
||
*/
|
||
export function layerFromRestrictions(r) {
|
||
return {
|
||
operations: [...r.operations],
|
||
readPaths: r.readPaths.map((g) => ({ root: "workspace", path: g.path })),
|
||
writePaths: r.writePaths.map((g) => ({ root: "workspace", path: g.path })),
|
||
network: r.network,
|
||
endpointRefs: r.endpointRefs.map((e) => ({ registry: e.registry, id: e.id, revision: e.revision, digest: e.digest })),
|
||
};
|
||
}
|
||
|
||
export const UNRESTRICTED_LAYER = Object.freeze({
|
||
operations: null, readPaths: null, writePaths: null, network: null, endpointRefs: null,
|
||
});
|
||
|
||
export function intersectLayers(a, b) {
|
||
const ops = a.operations === null ? b.operations : b.operations === null ? a.operations
|
||
: a.operations.filter((op) => b.operations.includes(op));
|
||
const readPaths = a.readPaths === null ? b.readPaths : b.readPaths === null ? a.readPaths : intersectGrants(a.readPaths, b.readPaths);
|
||
const writePaths = a.writePaths === null ? b.writePaths : b.writePaths === null ? a.writePaths : intersectGrants(a.writePaths, b.writePaths);
|
||
let network;
|
||
let endpointRefs;
|
||
if (a.network === null) { network = b.network; endpointRefs = b.endpointRefs; }
|
||
else if (b.network === null) { network = a.network; endpointRefs = a.endpointRefs; }
|
||
else if (a.network === "none" || b.network === "none") { network = "none"; endpointRefs = []; }
|
||
else {
|
||
endpointRefs = a.endpointRefs.filter((e) => b.endpointRefs.some((f) => deepEqual(e, f)));
|
||
network = endpointRefs.length ? "approved-endpoints" : "none";
|
||
}
|
||
return { operations: ops === null ? null : [...ops], readPaths, writePaths, network, endpointRefs };
|
||
}
|
||
|
||
function layerHasOperation(layer, op) {
|
||
return layer.operations === null || layer.operations.includes(op);
|
||
}
|
||
|
||
function layerPermitsPath(layer, member, path) {
|
||
const grants = layer[member];
|
||
return grants === null || pathPermitted(path, grants);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Evaluation
|
||
// ---------------------------------------------------------------------------
|
||
|
||
class Model {
|
||
constructor(bundle) {
|
||
this.bundle = bundle;
|
||
this.records = [...bundle.records].sort(compareRecords);
|
||
this.byKey = new Map(); // refKey -> record
|
||
this.identities = new Map(); // identityKey -> {records: Map<rev, rec>, head}
|
||
this.registries = bundle.registries;
|
||
this.artifacts = bundle.artifacts;
|
||
this.authorizations = new Set(bundle.authorizations);
|
||
this.delegationInputs = bundle.delegationInputs;
|
||
// §10.4: declared inventories are traversed in stable typed-key order (input indices).
|
||
this.order = {
|
||
registries: stableOrder(bundle.registries, ORDER_KEYS.registries),
|
||
artifacts: stableOrder(bundle.artifacts, ORDER_KEYS.artifacts),
|
||
authorizations: stableOrder(bundle.authorizations, ORDER_KEYS.authorizations),
|
||
delegationInputs: stableOrder(bundle.delegationInputs, ORDER_KEYS.delegationInputs),
|
||
};
|
||
}
|
||
|
||
// ---- stage 3
|
||
indexIdentities() {
|
||
for (const rec of this.records) {
|
||
const key = recordKey(rec);
|
||
if (this.byKey.has(key)) refuse(2, "invalid-request", "duplicate-record-identity", key);
|
||
this.byKey.set(key, rec);
|
||
const ik = identityKey(rec.kind, rec.id, rec.scope);
|
||
if (!this.identities.has(ik)) this.identities.set(ik, { records: new Map(), head: null });
|
||
this.identities.get(ik).records.set(rec.revision, rec);
|
||
}
|
||
const regSeen = new Set();
|
||
for (const i of this.order.registries) {
|
||
const e = this.registries[i];
|
||
const k = `${e.registry}|${e.id}|${e.revision}`;
|
||
if (regSeen.has(k)) refuse(2, "invalid-request", "duplicate-registry-identity", `bundle.registries[${i}]`);
|
||
regSeen.add(k);
|
||
}
|
||
const artSeen = new Set();
|
||
for (const i of this.order.artifacts) {
|
||
const a = this.artifacts[i];
|
||
const k = `${a.runId}|${a.artifactId}`;
|
||
if (artSeen.has(k)) refuse(2, "invalid-request", "duplicate-artifact-identity", `bundle.artifacts[${i}]`);
|
||
artSeen.add(k);
|
||
}
|
||
const authSeen = new Set();
|
||
for (const i of this.order.authorizations) {
|
||
const a = this.bundle.authorizations[i];
|
||
if (authSeen.has(a)) refuse(2, "invalid-request", "duplicate-authorization-id", `bundle.authorizations[${i}]`);
|
||
authSeen.add(a);
|
||
}
|
||
const delSeen = new Set();
|
||
for (const i of this.order.delegationInputs) {
|
||
const k = refKey(this.delegationInputs[i].decisionRef);
|
||
if (delSeen.has(k)) refuse(2, "invalid-request", "duplicate-delegation-input", `bundle.delegationInputs[${i}]`);
|
||
delSeen.add(k);
|
||
}
|
||
}
|
||
|
||
exact(ref) {
|
||
return this.byKey.get(refKey(ref));
|
||
}
|
||
|
||
requireExact(ref, where) {
|
||
const rec = this.exact(ref);
|
||
if (!rec) refuse(2, "missing-state", "record-reference-missing", where);
|
||
return rec;
|
||
}
|
||
|
||
// ---- stage 4
|
||
checkStructuralReferences() {
|
||
for (const rec of this.records) {
|
||
const where = recordKey(rec);
|
||
if (rec.supersedes !== null) this.requireExact(rec.supersedes, `${where}.supersedes`);
|
||
const p = rec.payload;
|
||
switch (rec.kind) {
|
||
case "registration":
|
||
if (p.projectRegistrationRef !== null) this.requireExact(p.projectRegistrationRef, `${where}.projectRegistrationRef`);
|
||
if (p.delegationRef !== null) this.requireExact(p.delegationRef, `${where}.delegationRef`);
|
||
break;
|
||
case "mission":
|
||
if (p.parentMissionRef !== null) this.requireExact(p.parentMissionRef, `${where}.parentMissionRef`);
|
||
break;
|
||
case "task":
|
||
if (p.missionRef !== null) this.requireExact(p.missionRef, `${where}.missionRef`);
|
||
this.requireExact(p.intentRef, `${where}.intentRef`);
|
||
p.dependencies.forEach((d, i) => this.requireExact(d, `${where}.dependencies[${i}]`));
|
||
break;
|
||
case "assignment":
|
||
this.requireExact(p.taskRef, `${where}.taskRef`);
|
||
this.requireExact(p.intentRef, `${where}.intentRef`);
|
||
break;
|
||
case "decision":
|
||
p.subjectRefs.forEach((s, i) => this.requireExact(s, `${where}.subjectRefs[${i}]`));
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
const b = this.bundle;
|
||
if (b.selection.assignmentRef !== null) this.requireExact(b.selection.assignmentRef, "bundle.selection.assignmentRef");
|
||
if (b.proposal !== null) {
|
||
const p = b.proposal;
|
||
this.requireExact(p.subjectAssignmentRef, "bundle.proposal.subjectAssignmentRef");
|
||
this.requireExact(p.targetTaskRef, "bundle.proposal.targetTaskRef");
|
||
if (p.delegationRef !== null) this.requireExact(p.delegationRef, "bundle.proposal.delegationRef");
|
||
if (p.changeDecisionRef !== null) this.requireExact(p.changeDecisionRef, "bundle.proposal.changeDecisionRef");
|
||
this.requireExact(p.requesterContexts.original.assignmentRef, "bundle.proposal.requesterContexts.original.assignmentRef");
|
||
this.requireExact(p.requesterContexts.target.assignmentRef, "bundle.proposal.requesterContexts.target.assignmentRef");
|
||
}
|
||
for (const i of this.order.delegationInputs) this.requireExact(b.delegationInputs[i].decisionRef, `bundle.delegationInputs[${i}].decisionRef`);
|
||
}
|
||
|
||
// ---- stage 5
|
||
checkCycles() {
|
||
const deps = new Map();
|
||
const parents = new Map();
|
||
const supersedes = new Map();
|
||
// Exact four-field nodes and edges (§3, r2 §6.2): every revision is its own node,
|
||
// so temporally distinct edges of one identity are never unioned into a cycle.
|
||
for (const rec of this.records) {
|
||
const rk = recordKey(rec);
|
||
if (rec.kind === "task") deps.set(rk, rec.payload.dependencies.map(refKey).sort());
|
||
if (rec.kind === "mission") parents.set(rk, rec.payload.parentMissionRef === null ? [] : [refKey(rec.payload.parentMissionRef)]);
|
||
supersedes.set(rk, rec.supersedes === null ? [] : [refKey(rec.supersedes)]);
|
||
}
|
||
let c = findCycle(deps);
|
||
if (c) refuse(2, "invalid-request", "dependency-cycle", c);
|
||
c = findCycle(parents);
|
||
if (c) refuse(2, "invalid-request", "mission-parent-cycle", c);
|
||
c = findCycle(supersedes);
|
||
if (c) refuse(2, "invalid-request", "supersedes-cycle", c);
|
||
}
|
||
|
||
// ---- stage 6
|
||
checkRevisionChains() {
|
||
const keys = [...this.identities.keys()].sort();
|
||
for (const ik of keys) {
|
||
const entry = this.identities.get(ik);
|
||
const revs = [...entry.records.keys()].sort((a, b) => a - b);
|
||
for (let i = 0; i < revs.length; i += 1) {
|
||
if (revs[i] !== i + 1) refuse(2, "invalid-request", "revision-chain-gap", `${ik}#${revs[i]}`);
|
||
const rec = entry.records.get(revs[i]);
|
||
if (revs[i] > 1) {
|
||
const s = rec.supersedes;
|
||
if (!sameIdentity(s, rec) || s.revision !== revs[i] - 1) {
|
||
refuse(2, "invalid-request", "supersedes-mismatch", `${ik}#${revs[i]}`);
|
||
}
|
||
}
|
||
}
|
||
entry.head = entry.records.get(revs[revs.length - 1]);
|
||
}
|
||
}
|
||
|
||
headOf(kind, id, scope) {
|
||
const entry = this.identities.get(identityKey(kind, id, scope));
|
||
return entry ? entry.head : undefined;
|
||
}
|
||
|
||
headFor(ref) {
|
||
return this.headOf(ref.kind, ref.id, ref.scope);
|
||
}
|
||
|
||
// ---- stage 7
|
||
checkOwnership() {
|
||
for (const rec of this.records) {
|
||
const where = recordKey(rec);
|
||
const p = rec.payload;
|
||
switch (rec.kind) {
|
||
case "project":
|
||
if (rec.id !== rec.scope.projectId) refuse(2, "invalid-request", "project-id-scope-mismatch", where);
|
||
break;
|
||
case "workspace": {
|
||
if (rec.id !== rec.scope.workspaceId) refuse(2, "invalid-request", "workspace-id-scope-mismatch", where);
|
||
const projectScope = { kind: "project", projectId: rec.scope.projectId };
|
||
if (!this.identities.has(identityKey("project", rec.scope.projectId, projectScope))) {
|
||
refuse(2, "missing-state", "workspace-project-missing", where);
|
||
}
|
||
break;
|
||
}
|
||
case "registration":
|
||
if (rec.scope.kind === "workspace") {
|
||
const parent = this.exact(p.projectRegistrationRef);
|
||
if (parent.scope.kind !== "project" || parent.scope.projectId !== rec.scope.projectId || parent.payload.agentId !== p.agentId) {
|
||
refuse(2, "invalid-request", "registration-parent-mismatch", where);
|
||
}
|
||
}
|
||
break;
|
||
case "mission":
|
||
if (rec.scope.kind === "workspace" && p.parentMissionRef !== null) {
|
||
const parent = this.exact(p.parentMissionRef);
|
||
if (parent.scope.kind !== "project" || parent.scope.projectId !== rec.scope.projectId) {
|
||
refuse(2, "invalid-request", "mission-parent-scope-mismatch", where);
|
||
}
|
||
}
|
||
break;
|
||
case "task":
|
||
if (p.missionRef !== null) {
|
||
const mission = this.exact(p.missionRef);
|
||
if (mission.scope.projectId !== rec.scope.projectId) {
|
||
refuse(2, "invalid-request", "mission-owning-project-mismatch", where);
|
||
}
|
||
}
|
||
break;
|
||
case "assignment":
|
||
if (scopeKey(p.taskRef.scope) !== scopeKey(rec.scope)) {
|
||
refuse(2, "invalid-request", "assignment-task-scope-mismatch", where);
|
||
}
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- stage 8
|
||
checkOperation() {
|
||
const { operation, proposal } = this.bundle;
|
||
if (!OPERATION_CATALOG.includes(operation.name)) refuse(2, "invalid-request", "operation-unknown", "bundle.operation.name");
|
||
if (!SUPPORTED_OPERATIONS.includes(operation.name)) refuse(2, "unsupported-capability", "operation-unsupported", "bundle.operation.name");
|
||
if (operation.name === "work.read" || operation.name === "assignment.change") {
|
||
if (operation.target !== null) refuse(2, "invalid-request", "operation-target-mismatch", "bundle.operation.target");
|
||
} else if (operation.target === null) {
|
||
refuse(2, "invalid-request", "operation-target-mismatch", "bundle.operation.target");
|
||
}
|
||
if (operation.name === "assignment.change") {
|
||
if (proposal === null) refuse(2, "invalid-request", "proposal-required", "bundle.proposal");
|
||
} else if (proposal !== null) {
|
||
refuse(2, "invalid-request", "proposal-not-applicable", "bundle.proposal");
|
||
}
|
||
}
|
||
|
||
// ---- stage 9 helpers
|
||
requireAuthorization(rec) {
|
||
if (!this.authorizations.has(rec.authorizationRef)) {
|
||
refuse(3, "missing-state", "authorization-undeclared", recordKey(rec));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* §3 / r2 §6.2: every consulted registry reference must name a declared entry by
|
||
* exact registry/id/revision/digest. An absent declared input is an admission
|
||
* failure (exit 3 missing-state), never the ordinary-record structural rule.
|
||
*/
|
||
requireRegistryDeclaration(ref, where) {
|
||
const declared = this.registries.some((e) => e.registry === ref.registry && e.id === ref.id && e.revision === ref.revision && e.digest === ref.digest);
|
||
if (!declared) refuse(3, "missing-state", "registry-declaration-missing", where);
|
||
}
|
||
|
||
/** A restrictions layer whose endpoint references are all declared. */
|
||
declaredLayer(r, where) {
|
||
r.endpointRefs.forEach((e, i) => this.requireRegistryDeclaration(e, `${where}.endpointRefs[${i}]`));
|
||
return layerFromRestrictions(r);
|
||
}
|
||
|
||
requireArtifacts(decision) {
|
||
const declared = (a) => this.artifacts.some((d) => d.runId === a.runId && d.artifactId === a.artifactId && d.digest === a.digest);
|
||
const p = decision.payload;
|
||
if (!declared(p.basisRef)) refuse(3, "missing-state", "artifact-undeclared", `${recordKey(decision)}.basisRef`);
|
||
p.evidenceRefs.forEach((e, i) => {
|
||
if (!declared(e)) refuse(3, "missing-state", "artifact-undeclared", `${recordKey(decision)}.evidenceRefs[${i}]`);
|
||
});
|
||
}
|
||
|
||
/** Consult a decision head: authorization + artifacts declared. */
|
||
consultDecision(decision) {
|
||
this.requireAuthorization(decision);
|
||
this.requireArtifacts(decision);
|
||
}
|
||
|
||
/** Resolve a current-admission reference: must exist (structural) and be the head. */
|
||
resolveCurrent(ref, staleRule, where) {
|
||
const head = this.headFor(ref);
|
||
if (!head) refuse(2, "missing-state", "record-reference-missing", where);
|
||
if (head.revision !== ref.revision) refuse(3, "stale-revision", staleRule, where);
|
||
return head;
|
||
}
|
||
|
||
registryContent(ref, missingRule, where) {
|
||
for (const e of this.registries) {
|
||
if (e.registry === ref.registry && e.id === ref.id && e.revision > ref.revision) {
|
||
refuse(3, "stale-revision", "policy-ceiling-stale", where);
|
||
}
|
||
}
|
||
const entry = this.registries.find((e) => e.registry === ref.registry && e.id === ref.id && e.revision === ref.revision && e.digest === ref.digest);
|
||
if (!entry) refuse(3, "missing-state", missingRule, where);
|
||
return this.declaredLayer(entry.content.restrictions, `${where}.content.restrictions`);
|
||
}
|
||
|
||
/**
|
||
* Active registration for agentId at scope (L2/L3) with its ceiling layers,
|
||
* including the §10.1 delegation checks.
|
||
*/
|
||
registrationLayer(agentId, agent, scope, parent) {
|
||
const sk = scopeKey(scope);
|
||
const heads = [];
|
||
for (const ik of [...this.identities.keys()].sort()) {
|
||
const head = this.identities.get(ik).head;
|
||
if (head.kind === "registration" && scopeKey(head.scope) === sk && head.payload.agentId === agentId) heads.push(head);
|
||
}
|
||
const active = heads.filter((r) => r.payload.status === "active");
|
||
const missingRule = scope.kind === "project" ? "project-registration-missing" : "workspace-registration-missing";
|
||
if (active.length > 1) refuse(2, "invalid-request", "registration-ambiguous", sk);
|
||
if (active.length === 0) {
|
||
if (heads.length > 0) refuse(3, "not-authorized", "registration-revoked", sk);
|
||
refuse(3, "not-authorized", missingRule, sk);
|
||
}
|
||
const reg = active[0];
|
||
const where = recordKey(reg);
|
||
this.requireAuthorization(reg);
|
||
if (parent) {
|
||
const pref = reg.payload.projectRegistrationRef;
|
||
if (!refEquals(pref, parent)) {
|
||
if (sameIdentity(pref, parent)) refuse(3, "stale-revision", "registration-parent-stale", where);
|
||
refuse(3, "not-authorized", "registration-parent-not-current", where);
|
||
}
|
||
}
|
||
const layers = [this.registryContent(reg.payload.scopeRoleRef, "scope-role-missing", `${where}.scopeRoleRef`)];
|
||
if (reg.payload.restrictions !== null) layers.push(this.declaredLayer(reg.payload.restrictions, `${where}.restrictions`));
|
||
if (reg.payload.delegationRef !== null) {
|
||
const dwhere = `${where}.delegationRef`;
|
||
const d = this.resolveCurrent(reg.payload.delegationRef, "registration-delegation-stale", dwhere);
|
||
this.consultDecision(d);
|
||
const dp = d.payload;
|
||
if (dp.decisionKind !== "delegation" || dp.outcome !== "approved") {
|
||
refuse(3, "not-authorized", "registration-delegation-not-approved", dwhere);
|
||
}
|
||
if (scopeKey(d.scope) !== sk) refuse(3, "unsupported-capability", "registration-delegation-scope-not-modelled", dwhere);
|
||
const agentSubjects = dp.subjectRefs.filter((s) => s.kind === "agent-definition");
|
||
const regSubjects = dp.subjectRefs.filter((s) => s.kind === "registration");
|
||
if (dp.subjectRefs.length !== 2 || agentSubjects.length !== 1 || regSubjects.length !== 1) {
|
||
refuse(3, "unsupported-capability", "registration-delegation-subject-form", dwhere);
|
||
}
|
||
if (!refEquals(agentSubjects[0], agent) || !refEquals(regSubjects[0], reg)) {
|
||
refuse(3, "not-authorized", "registration-delegation-bounds", dwhere);
|
||
}
|
||
const input = this.delegationInputs.find((di) => refEquals(di.decisionRef, d));
|
||
if (!input) refuse(3, "missing-state", "delegation-input-missing", dwhere);
|
||
for (const op of dp.delegatedOperations) {
|
||
if (!input.issuerCeiling.operations.includes(op)) refuse(3, "not-authorized", "delegation-exceeds-issuer-ceiling", dwhere);
|
||
}
|
||
layers.push({ ...UNRESTRICTED_LAYER, operations: [...dp.delegatedOperations] });
|
||
layers.push(this.declaredLayer(input.issuerCeiling, `${dwhere}.issuerCeiling`));
|
||
}
|
||
return { registration: reg, layers };
|
||
}
|
||
|
||
/** §10.2 scope guard for a consulted work reference relative to a workspace scope. */
|
||
workScopeGuard(ref, contextScope, where) {
|
||
const s = ref.scope;
|
||
if (s.kind === "workspace" && s.projectId === contextScope.projectId && s.workspaceId === contextScope.workspaceId) return;
|
||
if (s.kind === "project" && s.projectId === contextScope.projectId) return;
|
||
if (s.projectId === contextScope.projectId) refuse(3, "unsupported-capability", "cross-workspace-work-reference-not-modelled", where);
|
||
refuse(3, "unsupported-capability", "cross-project-work-reference-not-modelled", where);
|
||
}
|
||
|
||
/**
|
||
* L1–L9 for one calculation context. Returns {layer, task, missions, dependencies}.
|
||
* Throws Refusal at the first failing gate in fixed order.
|
||
*/
|
||
computeContext(ctx) {
|
||
const { agentId, projectId, workspaceId, assignmentRef, execution, requiredOperation, purpose } = ctx;
|
||
const systemScope = { kind: "system" };
|
||
const projectScope = { kind: "project", projectId };
|
||
const workspaceScope = { kind: "workspace", projectId, workspaceId };
|
||
|
||
const agent = this.headOf("agent-definition", agentId, systemScope);
|
||
if (!agent) refuse(2, "missing-state", purpose === "requester" ? "requester-agent-missing" : "selected-agent-missing", agentId);
|
||
this.requireAuthorization(agent);
|
||
if (agent.payload.status !== "enabled") refuse(3, "not-authorized", "agent-disabled", recordKey(agent));
|
||
const agentPayload = agent.payload;
|
||
const agentWhere = recordKey(agent);
|
||
this.requireRegistryDeclaration(agentPayload.harnessRef, `${agentWhere}.harnessRef`);
|
||
this.requireRegistryDeclaration(agentPayload.settingsRef, `${agentWhere}.settingsRef`);
|
||
this.requireRegistryDeclaration(agentPayload.soulRef, `${agentWhere}.soulRef`);
|
||
agentPayload.instructionRefs.forEach((r, i) => this.requireRegistryDeclaration(r, `${agentWhere}.instructionRefs[${i}]`));
|
||
agentPayload.skillRefs.forEach((r, i) => this.requireRegistryDeclaration(r, `${agentWhere}.skillRefs[${i}]`));
|
||
const l1 = this.registryContent(agentPayload.policyRef, "agent-policy-missing", `${agentWhere}.policyRef`);
|
||
|
||
const project = this.headOf("project", projectId, projectScope);
|
||
if (!project) refuse(2, "missing-state", "selected-project-missing", projectId);
|
||
this.requireAuthorization(project);
|
||
if (project.payload.status !== "active") refuse(3, "not-authorized", "project-not-active", recordKey(project));
|
||
const l4 = this.registryContent(project.payload.policyRef, "project-policy-missing", `${recordKey(project)}.policyRef`);
|
||
|
||
const workspace = this.headOf("workspace", workspaceId, workspaceScope);
|
||
if (!workspace) refuse(2, "missing-state", "selected-workspace-missing", workspaceId);
|
||
this.requireAuthorization(workspace);
|
||
if (workspace.payload.status !== "active") refuse(3, "not-authorized", "workspace-not-active", recordKey(workspace));
|
||
const l5 = this.registryContent(workspace.payload.policyRef, "workspace-policy-missing", `${recordKey(workspace)}.policyRef`);
|
||
|
||
const l2 = this.registrationLayer(agentId, agent, projectScope, null);
|
||
const l3 = this.registrationLayer(agentId, agent, workspaceScope, l2.registration);
|
||
|
||
const layers = [l1, ...l2.layers, ...l3.layers, l4, l5];
|
||
let task = null;
|
||
let assignment = null;
|
||
const missions = [];
|
||
const dependencies = [];
|
||
let consultedWork = false;
|
||
|
||
if (assignmentRef === null) {
|
||
if (!TASKLESS_OPERATIONS.includes(requiredOperation)) refuse(3, "not-authorized", "assignment-required", "bundle.selection.assignmentRef");
|
||
} else {
|
||
assignment = this.resolveCurrent(assignmentRef, "assignment-stale", "assignmentRef");
|
||
this.requireAuthorization(assignment);
|
||
const ap = assignment.payload;
|
||
if (ap.status !== "selected") refuse(3, "not-authorized", "assignment-not-selected", recordKey(assignment));
|
||
if (ap.agentId !== agentId) refuse(3, "not-authorized", "assignment-agent-mismatch", recordKey(assignment));
|
||
if (scopeKey(assignment.scope) !== scopeKey(workspaceScope)) refuse(3, "not-authorized", "assignment-workspace-mismatch", recordKey(assignment));
|
||
task = this.resolveCurrent(ap.taskRef, "task-ref-stale", `${recordKey(assignment)}.taskRef`);
|
||
const intent = this.resolveCurrent(ap.intentRef, "assignment-intent-stale", `${recordKey(assignment)}.intentRef`);
|
||
this.consultDecision(intent);
|
||
const ip = intent.payload;
|
||
const covers = ip.subjectRefs.some((s) => refEquals(s, task) || refEquals(s, assignment));
|
||
if (ip.outcome !== "approved" || !["plan-approval", "delegation", "assignment-change"].includes(ip.decisionKind) || !covers) {
|
||
refuse(3, "not-authorized", "assignment-intent-not-applicable", `${recordKey(assignment)}.intentRef`);
|
||
}
|
||
// L7 — the assigned task itself is consulted work (§10.2): work.read is required
|
||
// in the full intersection even when it has no mission or dependency references.
|
||
consultedWork = true;
|
||
this.requireAuthorization(task);
|
||
const tp = task.payload;
|
||
if (!["ready", "active"].includes(tp.status)) refuse(3, "not-authorized", "task-not-ready", recordKey(task));
|
||
const taskIntent = this.resolveCurrent(tp.intentRef, "task-intent-stale", `${recordKey(task)}.intentRef`);
|
||
this.consultDecision(taskIntent);
|
||
const tip = taskIntent.payload;
|
||
if (tip.outcome !== "approved" || tip.decisionKind !== "plan-approval" || !tip.subjectRefs.some((s) => refEquals(s, task))) {
|
||
refuse(3, "not-authorized", "task-intent-not-approved", `${recordKey(task)}.intentRef`);
|
||
}
|
||
// §10.2 scope guards, then L8 mission chain and dependency heads
|
||
if (tp.missionRef !== null) this.workScopeGuard(tp.missionRef, workspaceScope, `${recordKey(task)}.missionRef`);
|
||
tp.dependencies.forEach((d, i) => this.workScopeGuard(d, workspaceScope, `${recordKey(task)}.dependencies[${i}]`));
|
||
if (tp.restrictions !== null) layers.push(this.declaredLayer(tp.restrictions, `${recordKey(task)}.restrictions`));
|
||
if (tp.missionRef !== null) {
|
||
let mref = tp.missionRef;
|
||
let mwhere = `${recordKey(task)}.missionRef`;
|
||
while (mref !== null) {
|
||
const mission = this.resolveCurrent(mref, "mission-stale", mwhere);
|
||
this.requireAuthorization(mission);
|
||
if (mission.payload.status !== "active") refuse(3, "not-authorized", "mission-not-active", recordKey(mission));
|
||
missions.push(mission);
|
||
if (mission.payload.restrictions !== null) layers.push(this.declaredLayer(mission.payload.restrictions, `${recordKey(mission)}.restrictions`));
|
||
mref = mission.payload.parentMissionRef;
|
||
mwhere = `${recordKey(mission)}.parentMissionRef`;
|
||
}
|
||
}
|
||
tp.dependencies.forEach((d, i) => {
|
||
const dep = this.resolveCurrent(d, "dependency-stale", `${recordKey(task)}.dependencies[${i}]`);
|
||
this.requireAuthorization(dep);
|
||
dependencies.push(dep);
|
||
});
|
||
}
|
||
// L9
|
||
if (execution.kind === "restrictions") layers.push(this.declaredLayer(execution.restrictions, "execution.restrictions"));
|
||
|
||
let layer = UNRESTRICTED_LAYER;
|
||
for (const l of layers) layer = intersectLayers(layer, l);
|
||
|
||
if (consultedWork && !layerHasOperation(layer, "work.read")) {
|
||
refuse(3, "not-authorized", "consulted-work-not-readable", recordKey(task));
|
||
}
|
||
if (requiredOperation === "file.change" || purpose === "requester") {
|
||
dependencies.forEach((dep) => {
|
||
if (dep.payload.status !== "accepted") refuse(3, "not-authorized", "dependency-not-accepted", recordKey(dep));
|
||
});
|
||
}
|
||
return { layer, agent, project, workspace, task, assignment, missions, dependencies };
|
||
}
|
||
|
||
/**
|
||
* §10.2 for a proposal's subject/target task under a requester context: scope guards
|
||
* on its references, then work.read in the given intersection. The task is consulted
|
||
* work in itself, so work.read is required even without mission/dependency references.
|
||
*/
|
||
checkWorkAccess(task, contextScope, layer) {
|
||
const tp = task.payload;
|
||
if (tp.missionRef !== null) this.workScopeGuard(tp.missionRef, contextScope, `${recordKey(task)}.missionRef`);
|
||
tp.dependencies.forEach((d, i) => this.workScopeGuard(d, contextScope, `${recordKey(task)}.dependencies[${i}]`));
|
||
if (!layerHasOperation(layer, "work.read")) refuse(3, "not-authorized", "consulted-work-not-readable", recordKey(task));
|
||
}
|
||
|
||
checkOperationPermitted(layer, operation) {
|
||
if (!layerHasOperation(layer, operation.name)) refuse(3, "not-authorized", "operation-not-permitted", operation.name);
|
||
if (operation.name === "file.read" && !layerPermitsPath(layer, "readPaths", operation.target.path)) {
|
||
refuse(3, "not-authorized", "path-not-permitted", "bundle.operation.target");
|
||
}
|
||
if (operation.name === "file.change" && !layerPermitsPath(layer, "writePaths", operation.target.path)) {
|
||
refuse(3, "not-authorized", "path-not-permitted", "bundle.operation.target");
|
||
}
|
||
}
|
||
|
||
// ---- stage 9: main permission preview
|
||
evaluateMain() {
|
||
const { selection, operation } = this.bundle;
|
||
const ctx = this.computeContext({
|
||
agentId: selection.agentId,
|
||
projectId: selection.projectId,
|
||
workspaceId: selection.workspaceId,
|
||
assignmentRef: selection.assignmentRef,
|
||
execution: selection.execution,
|
||
requiredOperation: operation.name,
|
||
purpose: "selection",
|
||
});
|
||
this.checkOperationPermitted(ctx.layer, operation);
|
||
}
|
||
|
||
// ---- stage 9: proposal negative check (§5, r2 §8, §10.1–10.3)
|
||
evaluateProposal() {
|
||
const { selection, proposal: p } = this.bundle;
|
||
const step = (id, refusal) => {
|
||
if (!(id in PROPOSAL_STEPS)) throw new Error(`internal: unknown proposal step ${id}`);
|
||
refusal.step = id;
|
||
throw refusal;
|
||
};
|
||
const attempt = (id, fn) => {
|
||
try {
|
||
return fn();
|
||
} catch (e) {
|
||
if (e instanceof Refusal && !e.step) step(id, e);
|
||
throw e;
|
||
}
|
||
};
|
||
|
||
if (selection.assignmentRef === null || !refEquals(p.subjectAssignmentRef, selection.assignmentRef)) {
|
||
refuse(2, "invalid-request", "proposal-subject-mismatch", "bundle.proposal.subjectAssignmentRef");
|
||
}
|
||
const subject = this.exact(selection.assignmentRef);
|
||
const selectedScope = { kind: "workspace", projectId: selection.projectId, workspaceId: selection.workspaceId };
|
||
if (subject.payload.agentId !== selection.agentId || scopeKey(subject.scope) !== scopeKey(selectedScope)) {
|
||
refuse(2, "invalid-request", "proposal-selection-mismatch", "bundle.selection");
|
||
}
|
||
const requesterAgent = this.headOf("agent-definition", p.requesterAgentId, { kind: "system" });
|
||
if (!requesterAgent) refuse(2, "missing-state", "requester-agent-missing", "bundle.proposal.requesterAgentId");
|
||
const targetTaskRecord = this.exact(p.targetTaskRef);
|
||
const originalScope = plainScope(subject.scope);
|
||
const targetScope = plainScope(targetTaskRecord.scope);
|
||
const bindContext = (ctx, scope, where) => {
|
||
const a = this.exact(ctx.assignmentRef);
|
||
if (a.payload.agentId !== p.requesterAgentId || scopeKey(a.scope) !== scopeKey(scope)) {
|
||
refuse(2, "invalid-request", "requester-context-mismatch", where);
|
||
}
|
||
};
|
||
bindContext(p.requesterContexts.original, originalScope, "bundle.proposal.requesterContexts.original");
|
||
bindContext(p.requesterContexts.target, targetScope, "bundle.proposal.requesterContexts.target");
|
||
|
||
// §5: once structural validation succeeds, a missing delegation accompanied by a
|
||
// message yields message-is-not-authority before any requester admission step.
|
||
if (p.delegationRef === null && hasKey(p, "message")) {
|
||
step("message-is-not-authority", new Refusal(3, "not-authorized", "message-is-not-authority", "bundle.proposal.message"));
|
||
}
|
||
|
||
// current-head requirements on proposal refs
|
||
const subjectHead = this.resolveCurrent(selection.assignmentRef, "assignment-stale", "bundle.selection.assignmentRef");
|
||
const targetTask = this.resolveCurrent(p.targetTaskRef, "task-ref-stale", "bundle.proposal.targetTaskRef");
|
||
const subjectTask = this.resolveCurrent(subjectHead.payload.taskRef, "task-ref-stale", `${recordKey(subjectHead)}.taskRef`);
|
||
|
||
// Step 1: original scope
|
||
const original = attempt("requester-lacks-original-scope-authority", () => {
|
||
const c = this.computeContext({
|
||
agentId: p.requesterAgentId,
|
||
projectId: originalScope.projectId,
|
||
workspaceId: originalScope.workspaceId,
|
||
assignmentRef: p.requesterContexts.original.assignmentRef,
|
||
execution: p.requesterContexts.original.execution,
|
||
requiredOperation: "assignment.change",
|
||
purpose: "requester",
|
||
});
|
||
if (!layerHasOperation(c.layer, "assignment.change")) refuse(3, "not-authorized", "operation-not-permitted", "original");
|
||
this.checkWorkAccess(subjectTask, originalScope, c.layer);
|
||
return c;
|
||
});
|
||
// Step 2: target scope
|
||
const target = attempt("requester-lacks-target-scope-authority", () => {
|
||
const c = this.computeContext({
|
||
agentId: p.requesterAgentId,
|
||
projectId: targetScope.projectId,
|
||
workspaceId: targetScope.workspaceId,
|
||
assignmentRef: p.requesterContexts.target.assignmentRef,
|
||
execution: p.requesterContexts.target.execution,
|
||
requiredOperation: "assignment.change",
|
||
purpose: "requester",
|
||
});
|
||
if (!layerHasOperation(c.layer, "assignment.change")) refuse(3, "not-authorized", "operation-not-permitted", "target");
|
||
this.checkWorkAccess(targetTask, targetScope, c.layer);
|
||
return c;
|
||
});
|
||
// Step 3: delegation
|
||
if (p.delegationRef === null) {
|
||
step("delegation-not-applicable", new Refusal(3, "not-authorized", "delegation-missing", "bundle.proposal.delegationRef"));
|
||
}
|
||
const issuer = attempt("delegation-not-applicable", () => {
|
||
const where = "bundle.proposal.delegationRef";
|
||
const d = this.resolveCurrent(p.delegationRef, "delegation-stale", where);
|
||
this.consultDecision(d);
|
||
const dp = d.payload;
|
||
if (dp.decisionKind !== "delegation" || dp.outcome !== "approved") refuse(3, "not-authorized", "delegation-not-approved", where);
|
||
if (!dp.delegatedOperations.includes("assignment.change")) refuse(3, "not-authorized", "delegation-operation-not-covered", where);
|
||
const recipient = dp.subjectRefs.some((s) => refEquals(s, requesterAgent));
|
||
const bounded = dp.subjectRefs.some((s) => refEquals(s, subjectHead) || refEquals(s, subjectTask));
|
||
if (!recipient || !bounded) refuse(3, "not-authorized", "delegation-bounds", where);
|
||
const input = this.delegationInputs.find((di) => refEquals(di.decisionRef, d));
|
||
if (!input) refuse(3, "missing-state", "delegation-input-missing", where);
|
||
for (const op of dp.delegatedOperations) {
|
||
if (!input.issuerCeiling.operations.includes(op)) refuse(3, "not-authorized", "delegation-exceeds-issuer-ceiling", where);
|
||
}
|
||
return this.declaredLayer(input.issuerCeiling, `${where}.issuerCeiling`);
|
||
});
|
||
// §10.1/§10.2: the issuer ceiling additionally narrows both requester calculations,
|
||
// so every layer-dependent access condition is rechecked on the narrowed intersection
|
||
// in the order of steps 1 and 2: consulted own work, the operation, subject/target work.
|
||
const recheck = (id, c, work, scope, label) => attempt(id, () => {
|
||
const narrowed = intersectLayers(c.layer, issuer);
|
||
if (!layerHasOperation(narrowed, "work.read")) refuse(3, "not-authorized", "consulted-work-not-readable", recordKey(c.task));
|
||
if (!layerHasOperation(narrowed, "assignment.change")) refuse(3, "not-authorized", "operation-not-permitted", `${label}+issuer`);
|
||
this.checkWorkAccess(work, scope, narrowed);
|
||
});
|
||
recheck("requester-lacks-original-scope-authority", original, subjectTask, originalScope, "original");
|
||
recheck("requester-lacks-target-scope-authority", target, targetTask, targetScope, "target");
|
||
// Step 4: recorded change
|
||
attempt("change-not-recorded", () => {
|
||
const where = "bundle.proposal.changeDecisionRef";
|
||
if (p.changeDecisionRef === null) refuse(3, "not-authorized", "change-not-recorded", where);
|
||
const c = this.resolveCurrent(p.changeDecisionRef, "change-decision-stale", where);
|
||
this.consultDecision(c);
|
||
const cp = c.payload;
|
||
if (cp.decisionKind !== "assignment-change" || cp.outcome !== "approved") refuse(3, "not-authorized", "change-not-approved", where);
|
||
const hasSubject = cp.subjectRefs.some((s) => refEquals(s, subjectHead));
|
||
const hasTarget = cp.subjectRefs.some((s) => refEquals(s, targetTask));
|
||
if (!hasSubject || !hasTarget) refuse(3, "not-authorized", "change-subjects-mismatch", where);
|
||
});
|
||
// Step 5: current intent/status of subject assignment and target task
|
||
attempt("intent-not-current", () => {
|
||
this.requireAuthorization(subjectHead);
|
||
const sp = subjectHead.payload;
|
||
if (sp.status !== "selected") refuse(3, "not-authorized", "assignment-not-selected", recordKey(subjectHead));
|
||
const intent = this.resolveCurrent(sp.intentRef, "assignment-intent-stale", `${recordKey(subjectHead)}.intentRef`);
|
||
this.consultDecision(intent);
|
||
const ip = intent.payload;
|
||
const covers = ip.subjectRefs.some((s) => refEquals(s, subjectTask) || refEquals(s, subjectHead));
|
||
if (ip.outcome !== "approved" || !["plan-approval", "delegation", "assignment-change"].includes(ip.decisionKind) || !covers) {
|
||
refuse(3, "not-authorized", "assignment-intent-not-applicable", `${recordKey(subjectHead)}.intentRef`);
|
||
}
|
||
this.requireAuthorization(subjectTask);
|
||
this.requireAuthorization(targetTask);
|
||
const tp = targetTask.payload;
|
||
if (!["ready", "active"].includes(tp.status)) refuse(3, "not-authorized", "task-not-ready", recordKey(targetTask));
|
||
const ti = this.resolveCurrent(tp.intentRef, "task-intent-stale", `${recordKey(targetTask)}.intentRef`);
|
||
this.consultDecision(ti);
|
||
const tip = ti.payload;
|
||
if (tip.outcome !== "approved" || tip.decisionKind !== "plan-approval" || !tip.subjectRefs.some((s) => refEquals(s, targetTask))) {
|
||
refuse(3, "not-authorized", "task-intent-not-approved", `${recordKey(targetTask)}.intentRef`);
|
||
}
|
||
});
|
||
// Step 6: every simulation check passed — still not allowed.
|
||
step("runtime-reconciliation-required", new Refusal(3, "unknown-effects", "runtime-reconciliation-required", null));
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Result assembly
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function resultFor(exit, reason) {
|
||
if (exit === 0) return "allowed";
|
||
if (exit === 3) return reason === "unknown-effects" ? "unresolved" : "refused";
|
||
return "invalid";
|
||
}
|
||
|
||
/**
|
||
* Process exit status for a closed result (charter §7 exits). The exit status is
|
||
* process metadata derived from the emitted result, never a field of it:
|
||
* 0 allowed; 3 refused/unresolved; 4 invalid with io-failure; 2 any other invalid.
|
||
*/
|
||
export function exitFor(result) {
|
||
if (result.result === "allowed") return 0;
|
||
if (result.result === "refused" || result.result === "unresolved") return 3;
|
||
return result.reason === "io-failure" ? 4 : 2;
|
||
}
|
||
|
||
/** The closed emitted result fields of charter §7, in emission order. */
|
||
export const RESULT_KEYS = Object.freeze([
|
||
"disclaimer", "preview", "bundleVersion", "authentication", "declarations",
|
||
"result", "reason", "rule", "selection", "operation", "proposal", "diagnostic",
|
||
]);
|
||
|
||
/** Build the closed result object (§7). Key order is fixed; no exit field. */
|
||
export function buildResult({ exit, reason, rule, selection, operation, proposal, diagnostic }) {
|
||
if (rule !== null && !RULE_SET.has(rule)) throw new Error(`internal: unknown rule ${rule}`);
|
||
if (!REASONS.includes(reason)) throw new Error(`internal: unknown reason ${reason}`);
|
||
if ((exit === 0) !== (reason === "allowed")) throw new Error(`internal: exit ${exit} inconsistent with reason ${reason}`);
|
||
const built = {
|
||
disclaimer: DISCLAIMER,
|
||
preview: PREVIEW,
|
||
bundleVersion: 1,
|
||
authentication: "not-modelled",
|
||
declarations: "unverified-simulation",
|
||
result: resultFor(exit, reason),
|
||
reason,
|
||
rule,
|
||
selection: selection === null ? null : {
|
||
agentId: selection.agentId,
|
||
projectId: selection.projectId,
|
||
workspaceId: selection.workspaceId,
|
||
assignmentRef: plainRef(selection.assignmentRef),
|
||
},
|
||
operation: operation === null ? null : {
|
||
name: operation.name,
|
||
target: operation.target === null ? null : { root: "workspace", path: operation.target.path },
|
||
},
|
||
proposal: proposal === null ? null : {
|
||
result: proposal.result,
|
||
reason: proposal.reason,
|
||
rule: proposal.rule,
|
||
selectedAssignmentRef: plainRef(proposal.selectedAssignmentRef),
|
||
},
|
||
diagnostic: diagnostic === null ? null : {
|
||
byteOffset: diagnostic.byteOffset === undefined ? null : diagnostic.byteOffset,
|
||
inputPath: diagnostic.inputPath === undefined ? null : diagnostic.inputPath,
|
||
},
|
||
};
|
||
if (exitFor(built) !== exit) throw new Error(`internal: exit ${exit} inconsistent with ${built.result}/${reason}`);
|
||
return built;
|
||
}
|
||
|
||
/**
|
||
* Evaluate a parsed bundle value. Returns {result, exit, detail}: result is the
|
||
* closed §7 object, exit the derived process status (never emitted inside the
|
||
* result), and detail an internal locator (or null) that the CLI must never emit.
|
||
*/
|
||
export function evaluate(bundle) {
|
||
let selection = null;
|
||
let operation = null;
|
||
try {
|
||
validateBundleShape(bundle);
|
||
selection = bundle.selection;
|
||
operation = bundle.operation;
|
||
const model = new Model(bundle);
|
||
model.indexIdentities();
|
||
model.checkStructuralReferences();
|
||
model.checkCycles();
|
||
model.checkRevisionChains();
|
||
model.checkOwnership();
|
||
model.checkOperation();
|
||
if (operation.name === "assignment.change") {
|
||
model.evaluateProposal();
|
||
throw new Error("internal: proposal evaluation must not fall through");
|
||
}
|
||
model.evaluateMain();
|
||
const result = buildResult({ exit: 0, reason: "allowed", rule: null, selection, operation, proposal: null, diagnostic: null });
|
||
return { result, exit: exitFor(result), detail: null };
|
||
} catch (e) {
|
||
if (!(e instanceof Refusal)) throw e;
|
||
let proposal = null;
|
||
if (e.step) {
|
||
proposal = {
|
||
result: PROPOSAL_STEPS[e.step],
|
||
reason: e.reason,
|
||
rule: e.step,
|
||
selectedAssignmentRef: selection.assignmentRef,
|
||
};
|
||
}
|
||
const result = buildResult({ exit: e.exit, reason: e.reason, rule: e.rule, selection, operation, proposal, diagnostic: null });
|
||
return { result, exit: exitFor(result), detail: e.detail };
|
||
}
|
||
}
|
||
|
||
/** Closed result only (no internal detail). */
|
||
export function inspect(bundle) {
|
||
return evaluate(bundle).result;
|
||
}
|