feat(openontology): implement OpenOntology Phase 0 + local engine and CLI (#99)
Some checks failed
CI / build (push) Has been cancelled
test / test (push) Has been cancelled

Implements OpenPRD 0001 through Phase 0 (specification, schemas, example,
docs surface) and Phase 1 (local engine, CLI, conformance tests).

Schemas (17 contracts, JSON Schema Draft 2020-12, additionalProperties:false)
  manifest, namespace, entity-type, property, relationship-type, constraint,
  query, action, entity, claim, source, evidence, changeset, review, approval,
  event, package — registered in @logicsrc/validators and exported from
  @logicsrc/schemas under https://logicsrc.com/schemas/openontology/.

@logicsrc/openontology
  - canonical JSON + sha256 package digests; YAML, JSON, NDJSON, and inline
    authoring all compile to the same bytes, so digests are authoring-agnostic
  - id profile: compact / IRI / urn with one canonicalization rule, prefix
    bound by a Namespace object so IRIs reverse unambiguously
  - validation: schema, graph (domain/range, datatypes, dangling refs),
    provenance (source-or-firstParty, agent runId, derivation inputs), policy
    (excerpt limits, licensing, visibility, staleness) and declared
    constraints; four severities, stable codes, text/json/yaml/markdown
  - portable triple-pattern query AST: multi-hop, 14 operators, asOf and
    recordedAsOf, per-status filtering, distinct/order/limit, explanation
    mode, and enforced depth/binding/row limits
  - append-only store: claims are immutable; dispute/retract/supersede append
    status transitions and the effective status is the latest one
  - change sets: 9 operations, atomic pre-flight, conflict detection on stale
    base revisions, semantic diff with duplicate-identity warnings and
    affected-query deltas, per-operation reviewer decisions
  - policy: agents propose but can never apply — the denial keys on actor
    type, so every scope plus high confidence plus --yolo still cannot apply;
    merges need approval, bulk retractions need two, undeclared action side
    effects are denied
  - JSON-LD 1.1 export/import with PROV-O aliases and lossy-field reporting
  - pluggable signature envelope with a jws-ed25519 reference profile and a
    fail-closed trust policy

CLI: logicsrc ontology init|validate|lint|build|inspect, entity, claim, query,
changeset, import, export, audit. Reads take --format, writes default to a
proposal, exit codes are stable for CI.

Example: examples/openontology/ethereum-ecosystem — 12 entity types, 17
relationship types, 63 entities, 169 claims, 25 sources, 31 evidence records,
5 saved queries, every claim lifecycle state, and a pending merge proposal.
All data is fictional; the directory is removable without affecting any core
test.

Docs: docs/openontology{,-governance,-interoperability}.md, a real
/openontology route, homepage + nav + sitemap entries, and a root README
section.

Verification: 112 new tests; full monorepo build and every workspace test
pass; conformance bundle (18 valid + 13 invalid fixtures) runs against the
published schemas alone; Node.js 25 and Bun 1.3 produce byte-identical
digests, revisions, event trails, and query results.

Not included (later PRD phases): MCP resources, REST/SSE, Turso adapter, TUI
and PWA surfaces, RDF/SHACL mappings, source adapters, governed actions.

Refs: prd/0001-add-logicsrc-openontology-spec.md

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-07-26 02:10:13 -07:00 • committed by GitHub
parent 0d9dab0447
commit 58c942c67f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
101 changed files with 11934 additions and 10 deletions

View file

@ -0,0 +1,256 @@
import { existsSync, readFileSync } from "node:fs";
import { isAbsolute, join, resolve } from "node:path";
import { parse as parseYaml } from "yaml";
import { canonicalObject, digest, packageDigest } from "./canonical.js";
import { OPENONTOLOGY_VERSION } from "./types.js";
import type {
BuiltPackage,
DataSection,
LoadedPackage,
Manifest,
SchemaSection
} from "./types.js";
const SCHEMA_SECTIONS: SchemaSection[] = [
"namespaces",
"entityTypes",
"properties",
"relationships",
"constraints",
"queries",
"actions"
];
const DATA_SECTIONS: DataSection[] = ["entities", "claims", "sources", "evidence"];
const MANIFEST_NAMES = ["openontology.yaml", "openontology.yml", "openontology.json"];
export class PackageLoadError extends Error {
readonly code = "OO-L-LOAD";
constructor(message: string) {
super(message);
this.name = "PackageLoadError";
}
}
export interface LoadInput {
/** Directory containing openontology.yaml, or the manifest file itself. */
dir?: string;
/** Fully in-memory package (used by tests, imports, and hosted adapters). */
manifest?: Manifest;
schema?: Partial<LoadedPackage["schema"]>;
data?: Partial<LoadedPackage["data"]>;
}
/**
* Load a package from disk or memory. Authoring formats (YAML, JSON, NDJSON,
* inline manifest arrays) all compile to the same canonical objects here, so
* everything downstream — validation, digests, queries — sees one shape.
*/
export function loadOntologyPackage(input: string | LoadInput): LoadedPackage {
if (typeof input === "string") return loadFromDir(input);
if (input.dir) return loadFromDir(input.dir);
if (!input.manifest) throw new PackageLoadError("Provide either a directory or a manifest");
const loaded: LoadedPackage = {
manifest: canonicalObject(input.manifest),
schema: emptySchema(),
data: emptyData(),
files: []
};
for (const section of SCHEMA_SECTIONS) {
const items = input.schema?.[section];
if (items) (loaded.schema[section] as unknown[]) = canonicalObject(items as unknown[]);
}
for (const section of DATA_SECTIONS) {
const items = input.data?.[section];
if (items) (loaded.data[section] as unknown[]) = canonicalObject(items as unknown[]);
}
return loaded;
}
function loadFromDir(dirOrFile: string): LoadedPackage {
const base = resolve(dirOrFile);
const manifestPath = MANIFEST_NAMES.some((name) => base.endsWith(name))
? base
: findManifest(base);
const dir = manifestPath.slice(0, manifestPath.lastIndexOf("/")) || ".";
const manifest = canonicalObject(parseFile(manifestPath) as Manifest);
if (manifest?.kind !== "OntologyPackage") {
throw new PackageLoadError(
`${manifestPath} is not an OpenOntology manifest (kind: ${JSON.stringify(manifest?.kind ?? null)})`
);
}
const loaded: LoadedPackage = {
manifest,
dir,
schema: emptySchema(),
data: emptyData(),
files: []
};
for (const section of SCHEMA_SECTIONS) {
const declared = manifest.schema?.[section];
const { items, path } = resolveSection(dir, declared, `schema.${section}`);
(loaded.schema[section] as unknown[]) = items;
if (path) loaded.files.push({ path, digest: digest(items), count: items.length });
}
for (const section of DATA_SECTIONS) {
const declared = manifest.data?.[section];
const { items, path } = resolveSection(dir, declared, `data.${section}`);
(loaded.data[section] as unknown[]) = items;
if (path) loaded.files.push({ path, digest: digest(items), count: items.length });
}
if (manifest.context) {
const contextPath = join(dir, manifest.context);
if (!existsSync(contextPath)) {
throw new PackageLoadError(`Manifest declares context ${manifest.context} but the file is missing`);
}
loaded.context = parseFile(contextPath) as Record<string, unknown>;
loaded.files.push({ path: manifest.context, digest: digest(loaded.context), count: 1 });
}
loaded.files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
return loaded;
}
function findManifest(dir: string): string {
for (const name of MANIFEST_NAMES) {
const candidate = join(dir, name);
if (existsSync(candidate)) return candidate;
}
throw new PackageLoadError(`No openontology.yaml (or .yml/.json) found in ${dir}`);
}
function resolveSection(
dir: string,
declared: string | object[] | undefined,
label: string
): { items: object[]; path?: string } {
if (declared === undefined) return { items: [] };
if (Array.isArray(declared)) return { items: canonicalObject(declared) };
const path = declared;
const full = isAbsolute(path) ? path : join(dir, path);
if (!existsSync(full)) {
throw new PackageLoadError(`Manifest declares ${label}: ${path} but the file is missing`);
}
const parsed = parseFile(full);
const items = Array.isArray(parsed) ? parsed : [parsed];
return { items: canonicalObject(items as object[]), path };
}
function parseFile(path: string): unknown {
const raw = readFileSync(path, "utf8");
const lower = path.toLowerCase();
if (lower.endsWith(".ndjson") || lower.endsWith(".jsonl")) {
return raw
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith("//"))
.map((line, index) => {
try {
return JSON.parse(line) as unknown;
} catch (error) {
throw new PackageLoadError(
`${path}:${index + 1} is not valid JSON — ${(error as Error).message}`
);
}
});
}
if (lower.endsWith(".json") || lower.endsWith(".jsonld")) {
try {
return JSON.parse(raw) as unknown;
} catch (error) {
throw new PackageLoadError(`${path} is not valid JSON — ${(error as Error).message}`);
}
}
try {
return parseYaml(raw) as unknown;
} catch (error) {
throw new PackageLoadError(`${path} is not valid YAML — ${(error as Error).message}`);
}
}
/**
* Compile a loaded package into the deterministic build artifact: canonical
* objects, a per-file digest table, and the package digest that signing,
* diffing, and publishing all key off.
*/
export function buildOntologyPackage(
loaded: LoadedPackage,
options: { builtAt?: string } = {}
): BuiltPackage {
const files =
loaded.files.length > 0
? [...loaded.files]
: [
...SCHEMA_SECTIONS.map((s) => ({
path: `schema.${s}`,
digest: digest(loaded.schema[s]),
count: loaded.schema[s].length
})),
...DATA_SECTIONS.map((s) => ({
path: `data.${s}`,
digest: digest(loaded.data[s]),
count: loaded.data[s].length
}))
].filter((f) => f.count > 0);
files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
// The digest covers the manifest *without* any previously embedded digest,
// so building twice is idempotent.
const { digest: _ignored, signatures, ...manifestForDigest } = loaded.manifest;
const computed = packageDigest(manifestForDigest, files);
const built: BuiltPackage = {
openontology: loaded.manifest.openontology ?? OPENONTOLOGY_VERSION,
kind: "BuiltOntologyPackage",
manifest: { ...loaded.manifest, digest: computed },
digest: computed,
files,
schema: loaded.schema,
data: loaded.data
};
if (options.builtAt) built.builtAt = options.builtAt;
if (loaded.context) built.context = loaded.context;
if (signatures) built.signatures = signatures;
return built;
}
/** Recompute a built package's digest — used to detect tampering or drift. */
export function verifyPackageDigest(built: BuiltPackage): { ok: boolean; expected: string } {
const { digest: _ignored, signatures: _sigs, ...manifestForDigest } = built.manifest;
const expected = packageDigest(manifestForDigest, built.files);
return { ok: expected === built.digest, expected };
}
function emptySchema(): LoadedPackage["schema"] {
return {
namespaces: [],
entityTypes: [],
properties: [],
relationships: [],
constraints: [],
queries: [],
actions: []
};
}
function emptyData(): LoadedPackage["data"] {
return { entities: [], claims: [], sources: [], evidence: [] };
}
export { SCHEMA_SECTIONS, DATA_SECTIONS };