logicsrc/packages/opencontext/src/manifest.ts
Anthony Ettinger ec3ed64f20 Add the LogicSRC OpenContext specification
OpenContext is an open specification for durable, portable, permissioned,
provenance-aware context shared between humans and AI agents. It defines how
organizational knowledge is described, authorized, versioned, resolved,
audited, and handed between replaceable workers without losing institutional
state.

Follows the OpenPRD/OpenOntology pattern already in the repo: self-contained
JSON Schemas in @logicsrc/schemas, a reference implementation package, CLI
subcommands, docs, examples, and an OpenPRD record.

Schemas (8, all self-contained so a third party can fetch one file and
validate against it with no further resolution):
  manifest, object, bundle, role, provenance, decision, diagnostic,
  audit-event — registered in @logicsrc/validators and schemas:validate.

Reference implementation (@logicsrc/opencontext):
  loader with upward manifest discovery, the full resolution pipeline,
  authority/supersession, permissions, redaction, lifecycle, provenance,
  deterministic digests, doctor, search, graph, history/diff, guarded writes,
  audit events, and file/http/git/sqlite adapters.

CLI: all 15 specified commands, as a standalone `opencontext` binary and as
`logicsrc context`, sharing one implementation so the two cannot drift.

Design decisions worth noting:

- Supersession is declared, never inferred from version numbers. Inferring it
  would hide the governance failure it represents and make
  multiple-active-versions and duplicate-canonical impossible to detect.

- The bundle digest identifies the resolved context, not the moment it was
  computed, so generated_at/bundle_id/digest/as_of are excluded while objects,
  lifecycle states, exclusions and warnings are covered. That is what lets a
  decision record cite exactly the context that produced it.

- A role's own max_classification beats an inherited one, so a ceiling on a
  shared base role cannot silently cap a role deliberately granted more;
  requesting several roles at once still takes the lowest, so combining roles
  never escalates.

- Scope wildcards match whole dotted segments only. A trailing .* covers a
  subtree; an interior * matches exactly one segment. Substring matching here
  would be an access-control bug.

- --include narrows an existing scope and is applied after it, never merged
  into it, so a request can never widen what a role holds.

Verified: 226 tests across core primitives, permissions/redaction, the
resolution pipeline, security, the published conformance fixtures (13 valid,
35 invalid, 8 resolution scenarios), project behaviour, and the five shipped
examples — which are held to --strict and a 100% health score. Benchmarks meet
every published budget (resolve 1,000 objects in ~33ms against a 2s target).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 18:22:42 +00:00

291 lines
9.7 KiB
TypeScript

/**
* Finding, reading, and normalizing `opencontext.yaml`.
*
* Discovery walks upward from the working directory the way git finds `.git`,
* so `opencontext resolve` works from anywhere inside a project without a flag.
*/
import { existsSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { parse as parseYaml } from "yaml";
import { validate } from "@logicsrc/validators";
import type { Authority, Classification, Diagnostic, Manifest, Severity, TieBreaker } from "./types.js";
import { AUTHORITIES } from "./types.js";
import { findFieldLine } from "./parse.js";
/** Canonical first: an implementation MAY support JSON, but YAML is the name people type. */
export const MANIFEST_FILENAMES = ["opencontext.yaml", "opencontext.yml", "opencontext.json"] as const;
export const SPEC_VERSION = "1.0";
export const DEFAULT_PRECEDENCE: Authority[] = [
"canonical",
"approved",
"reference",
"observed",
"inferred",
"historical"
];
export const DEFAULT_TIE_BREAKERS: TieBreaker[] = ["version", "updated", "confidence", "id"];
export const DEFAULT_MAX_CLASSIFICATION: Classification = "internal";
export class ManifestNotFoundError extends Error {
constructor(startDir: string) {
super(
`No opencontext.yaml found in ${startDir} or any parent directory. ` +
`Run \`opencontext init\` to create one.`
);
this.name = "ManifestNotFoundError";
}
}
export class ManifestInvalidError extends Error {
readonly diagnostics: Diagnostic[];
constructor(message: string, diagnostics: Diagnostic[]) {
super(message);
this.name = "ManifestInvalidError";
this.diagnostics = diagnostics;
}
}
/** Walk upward from `startDir` looking for a manifest. Returns the absolute path. */
export function discoverManifest(startDir: string = process.cwd()): string {
let dir = resolve(startDir);
// A directory that *is* a manifest path is accepted too, so callers can pass
// either `./project` or `./project/opencontext.yaml`.
if (isManifestPath(dir) && existsSync(dir)) return dir;
for (;;) {
for (const name of MANIFEST_FILENAMES) {
const candidate = join(dir, name);
if (existsSync(candidate)) return candidate;
}
const parent = dirname(dir);
if (parent === dir) throw new ManifestNotFoundError(resolve(startDir));
dir = parent;
}
}
function isManifestPath(path: string): boolean {
return MANIFEST_FILENAMES.some((name) => path.endsWith(name));
}
export interface LoadedManifest {
manifest: Manifest;
path: string;
dir: string;
raw: string;
}
/**
* Read and validate a manifest.
*
* Schema failures are raised rather than collected: nothing downstream is
* meaningful if the manifest itself is wrong, and a half-understood control
* plane is exactly the situation the specification is trying to prevent.
*/
export function loadManifest(pathOrDir: string = process.cwd()): LoadedManifest {
const path = isManifestPath(pathOrDir) ? resolve(pathOrDir) : discoverManifest(pathOrDir);
const raw = readFileSync(path, "utf8");
let data: unknown;
try {
data = path.endsWith(".json") ? JSON.parse(raw) : parseYaml(raw);
} catch (error) {
throw new ManifestInvalidError(`${path}: ${(error as Error).message}`, [
{ code: "manifest-invalid", severity: "error", message: (error as Error).message, file: path }
]);
}
const diagnostics = validateManifestData(data, raw, path);
const errors = diagnostics.filter((finding) => finding.severity === "error");
if (errors.length > 0) {
throw new ManifestInvalidError(
`${path} is not a valid OpenContext manifest:\n${errors.map((e) => ` - ${e.message}`).join("\n")}`,
diagnostics
);
}
return { manifest: data as Manifest, path, dir: dirname(path), raw };
}
/** Schema validation plus the cross-field rules JSON Schema cannot express. */
export function validateManifestData(data: unknown, raw: string, file: string): Diagnostic[] {
const findings: Diagnostic[] = [];
const result = validate("opencontext-manifest", data);
if (!result.ok) {
for (const error of result.errors) {
const field = error.instancePath.replace(/^\//, "").replace(/\//g, ".");
findings.push({
code: "manifest-invalid",
severity: "error",
message: `${field || "manifest"} ${error.message ?? "is invalid"}`,
file,
field: field || undefined,
line: field ? findFieldLine(raw, field.split(".")[0]!) : undefined,
expected: error.params,
remediation: remediationFor(error.keyword, field)
});
}
return findings;
}
const manifest = data as Manifest;
const major = manifest.opencontext.split(".")[0];
if (major !== SPEC_VERSION.split(".")[0]) {
findings.push({
code: "manifest-invalid",
severity: "error",
message: `Manifest declares OpenContext ${manifest.opencontext}, but this implementation supports ${SPEC_VERSION}.`,
file,
field: "opencontext",
line: findFieldLine(raw, "opencontext"),
remediation: `Set opencontext: "${SPEC_VERSION}", or use a runtime that implements ${major}.x.`
});
}
// Precedence must stay a permutation of the standard levels. Dropping one
// would leave objects at that authority unrankable; inventing one would let a
// repository define something that outranks canonical.
const precedence = manifest.authority?.precedence;
if (precedence) {
const missing = AUTHORITIES.filter((level) => !precedence.includes(level));
if (missing.length > 0) {
findings.push({
code: "manifest-invalid",
severity: "error",
message: `authority.precedence omits ${missing.join(", ")}. It must list every authority level exactly once.`,
file,
field: "authority.precedence",
line: findFieldLine(raw, "authority"),
expected: [...AUTHORITIES],
actual: precedence,
remediation: "List all six levels, reordered as you need them."
});
}
}
findings.push(...validateRoleGraph(manifest, raw, file));
for (const [name, binding] of Object.entries(manifest.agents ?? {})) {
for (const role of binding.roles) {
if (!manifest.roles?.[role]) {
findings.push({
code: "unknown-role",
severity: "error",
message: `Agent "${name}" holds role "${role}", which is not defined under roles.`,
file,
field: `agents.${name}.roles`,
line: findFieldLine(raw, "agents"),
remediation: `Define roles.${role}, or remove it from agents.${name}.`
});
}
}
}
return findings;
}
/** Role inheritance must terminate, and every parent must exist. */
function validateRoleGraph(manifest: Manifest, raw: string, file: string): Diagnostic[] {
const findings: Diagnostic[] = [];
const roles = manifest.roles ?? {};
for (const [name, role] of Object.entries(roles)) {
for (const parent of role.inherits ?? []) {
if (!roles[parent]) {
findings.push({
code: "unknown-role",
severity: "error",
message: `Role "${name}" inherits "${parent}", which is not defined.`,
file,
field: `roles.${name}.inherits`,
line: findFieldLine(raw, "roles"),
remediation: `Define roles.${parent}, or remove it from roles.${name}.inherits.`
});
}
}
if ((role.include ?? []).length === 0 && (role.inherits ?? []).length === 0) {
findings.push({
code: "empty-scope",
severity: "warning",
message: `Role "${name}" includes nothing, so it resolves to an empty bundle.`,
file,
field: `roles.${name}.include`,
line: findFieldLine(raw, "roles"),
remediation: `Add include patterns, or inherit from another role. Scope is opt-in by design.`
});
}
}
for (const name of Object.keys(roles)) {
const cycle = findCycle(name, roles, new Set(), []);
if (cycle) {
findings.push({
code: "role-cycle",
severity: "error",
message: `Role inheritance cycle: ${cycle.join(" -> ")}.`,
file,
field: `roles.${name}.inherits`,
line: findFieldLine(raw, "roles"),
remediation: "Break the cycle — inheritance must form a tree."
});
break;
}
}
return findings;
}
function findCycle(
name: string,
roles: Record<string, { inherits?: string[] }>,
seen: Set<string>,
path: string[]
): string[] | null {
if (seen.has(name)) return [...path, name];
seen.add(name);
for (const parent of roles[name]?.inherits ?? []) {
if (!roles[parent]) continue;
const cycle = findCycle(parent, roles, new Set(seen), [...path, name]);
if (cycle) return cycle;
}
return null;
}
function remediationFor(keyword: string, field: string): string | undefined {
switch (keyword) {
case "additionalProperties":
return `Remove the unrecognised key, or move it under extensions with a namespaced name such as com.example.${field || "custom"}.`;
case "required":
return "Add the missing required field.";
case "enum":
return "Use one of the listed values.";
case "pattern":
return "Check the format — ids are lowercase dotted slugs and durations look like 30d.";
default:
return undefined;
}
}
/** Precedence with defaults applied, highest authority first. */
export function precedenceOf(manifest: Manifest): Authority[] {
return manifest.authority?.precedence ?? DEFAULT_PRECEDENCE;
}
/** Tie breakers with defaults applied. `id` is always appended so ordering is total. */
export function tieBreakersOf(manifest: Manifest): TieBreaker[] {
const configured = manifest.authority?.tie_breakers ?? DEFAULT_TIE_BREAKERS;
return configured.includes("id") ? configured : [...configured, "id"];
}
export function failOnSeverityOf(manifest: Manifest): Severity {
return manifest.health?.fail_on ?? "error";
}