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,253 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, describe, expect, it } from "vitest";
import { buildOntologyPackage, loadOntologyPackage, verifyPackageDigest } from "./package.js";
import { initOntologyPackage } from "./scaffold.js";
import { renderReport, validateOntologyPackage } from "./validate.js";
import { OPENONTOLOGY_VERSION } from "./types.js";
import type { Claim, LoadedPackage } from "./types.js";
const NOW = "2026-07-26T00:00:00Z";
const dirs: string[] = [];
function scaffold(): { dir: string; pkg: LoadedPackage } {
const dir = mkdtempSync(join(tmpdir(), "openontology-"));
dirs.push(dir);
initOntologyPackage(dir, { id: "test-ecosystem", now: NOW });
return { dir, pkg: loadOntologyPackage(dir) };
}
afterAll(() => {
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
});
/** Clone the scaffold and mutate one claim, to isolate a single failure mode. */
function withClaims(pkg: LoadedPackage, mutate: (claims: Claim[]) => void): LoadedPackage {
const copy = structuredClone(pkg);
mutate(copy.data.claims);
return copy;
}
describe("init + validate", () => {
it("scaffolds a package that passes strict validation with no edits (R139)", () => {
const { pkg } = scaffold();
const report = validateOntologyPackage(pkg, { strict: true });
const errors = report.findings.filter((f) => f.severity === "error");
expect(errors).toEqual([]);
expect(report.ok).toBe(true);
expect(report.checked.entities).toBe(8);
expect(report.checked.claims).toBe(14);
});
it("loads YAML manifest, YAML schema files, and NDJSON data together", () => {
const { pkg } = scaffold();
expect(pkg.manifest.kind).toBe("OntologyPackage");
expect(pkg.schema.entityTypes.map((t) => t.id)).toEqual(["Person", "Organization", "Project"]);
expect(pkg.data.sources).toHaveLength(2);
expect(pkg.files.map((f) => f.path)).toContain("data/claims.ndjson");
});
it("builds a deterministic digest that survives a rebuild", () => {
const { pkg } = scaffold();
const first = buildOntologyPackage(pkg);
const second = buildOntologyPackage(loadOntologyPackage(structuredClone(pkg)));
expect(first.digest).toBe(second.digest);
expect(verifyPackageDigest(first).ok).toBe(true);
});
it("detects a tampered built package", () => {
const { pkg } = scaffold();
const built = buildOntologyPackage(pkg);
built.files[0] = { ...built.files[0], digest: `sha256:${"0".repeat(64)}` };
expect(verifyPackageDigest(built).ok).toBe(false);
});
it("reports a missing declared file instead of loading a partial package", () => {
const { dir } = scaffold();
rmSync(join(dir, "data/claims.ndjson"));
expect(() => loadOntologyPackage(dir)).toThrow(/data.claims.*missing/);
});
});
describe("graph validation", () => {
it("rejects a claim whose subject type is outside the relationship domain", () => {
const { pkg } = scaffold();
const broken = withClaims(pkg, (claims) => {
const claim = claims.find((c) => c.predicate === "worksOn") as Claim;
claim.subject = "test:org:northwind"; // an Organization cannot worksOn
});
const report = validateOntologyPackage(broken);
expect(report.ok).toBe(false);
expect(report.findings.map((f) => f.code)).toContain("OO-G-DOMAIN");
});
it("rejects a claim whose object type is outside the relationship range", () => {
const { pkg } = scaffold();
const broken = withClaims(pkg, (claims) => {
const claim = claims.find((c) => c.predicate === "worksOn") as Claim;
claim.object = { entity: "test:org:northwind" };
});
expect(validateOntologyPackage(broken).findings.map((f) => f.code)).toContain("OO-G-RANGE");
});
it("rejects a value object on a relationship predicate", () => {
const { pkg } = scaffold();
const broken = withClaims(pkg, (claims) => {
const claim = claims.find((c) => c.predicate === "worksOn") as Claim;
claim.object = { value: "ZK Prover" };
});
expect(validateOntologyPackage(broken).findings.map((f) => f.code)).toContain("OO-G-OBJECT-KIND");
});
it("rejects a value that does not match its declared datatype", () => {
const { pkg } = scaffold();
const broken = withClaims(pkg, (claims) => {
const claim = claims.find((c) => c.predicate === "homepage") as Claim;
claim.object = { value: 42 };
});
expect(validateOntologyPackage(broken).findings.map((f) => f.code)).toContain("OO-G-DATATYPE");
});
it("rejects dangling entity, source, and claim references", () => {
const { pkg } = scaffold();
const broken = withClaims(pkg, (claims) => {
(claims[0] as Claim).subject = "test:person:nobody";
(claims[1] as Claim).sources = ["test:source:missing"];
(claims[2] as Claim).supersedes = "test:claim:9999";
});
const codes = validateOntologyPackage(broken).findings.map((f) => f.code);
expect(codes.filter((c) => c === "OO-G-DANGLING-REF").length).toBeGreaterThanOrEqual(3);
});
it("rejects a claim with no source and no firstParty declaration (R54)", () => {
const { pkg } = scaffold();
const broken = withClaims(pkg, (claims) => {
const claim = claims[0] as Claim;
delete claim.sources;
delete claim.firstParty;
});
expect(validateOntologyPackage(broken).findings.map((f) => f.code)).toContain("OO-P-NO-SOURCE");
});
it("requires a runId on agent-authored claims (R55)", () => {
const { pkg } = scaffold();
const broken = withClaims(pkg, (claims) => {
(claims[0] as Claim).assertedBy = "agent:research-mapper";
});
expect(validateOntologyPackage(broken).findings.map((f) => f.code)).toContain("OO-P-NO-RUN");
});
it("requires derivedFrom on derived claims (R56)", () => {
const { pkg } = scaffold();
const broken = withClaims(pkg, (claims) => {
(claims[0] as Claim).status = "derived";
});
expect(validateOntologyPackage(broken).findings.map((f) => f.code)).toContain("OO-P-NO-DERIVATION");
});
it("rejects validTime.to before validTime.from", () => {
const { pkg } = scaffold();
const broken = withClaims(pkg, (claims) => {
(claims[0] as Claim).validTime = { from: "2026-05-01T00:00:00Z", to: "2026-01-01T00:00:00Z" };
});
expect(validateOntologyPackage(broken).findings.map((f) => f.code)).toContain("OO-G-TEMPORAL-ORDER");
});
it("catches duplicate ids", () => {
const { pkg } = scaffold();
const broken = structuredClone(pkg);
broken.data.entities.push(structuredClone(broken.data.entities[0]));
expect(validateOntologyPackage(broken).findings.map((f) => f.code)).toContain("OO-G-DUPLICATE-ID");
});
it("treats unknown predicates as warnings by default and errors in strict mode (R75)", () => {
const { pkg } = scaffold();
const broken = withClaims(pkg, (claims) => {
(claims[0] as Claim).predicate = "notDeclared";
});
const lenient = validateOntologyPackage(broken);
const strict = validateOntologyPackage(broken, { strict: true });
expect(lenient.findings.find((f) => f.code === "OO-G-UNKNOWN-PREDICATE")?.severity).toBe("warning");
expect(strict.findings.find((f) => f.code === "OO-G-UNKNOWN-PREDICATE")?.severity).toBe("error");
expect(lenient.ok).toBe(true);
expect(strict.ok).toBe(false);
});
it("flags a schema-invalid object with its path", () => {
const { pkg } = scaffold();
const broken = structuredClone(pkg);
delete (broken.data.entities[0] as Partial<{ canonicalName: string }>).canonicalName;
const report = validateOntologyPackage(broken);
const finding = report.findings.find((f) => f.code === "OO-S-OBJECT");
expect(finding).toBeDefined();
expect(finding?.path).toMatch(/^\/0/);
});
});
describe("provenance policy", () => {
it("raises a policy finding for an over-long excerpt", () => {
const { pkg } = scaffold();
const withEvidence = structuredClone(pkg);
withEvidence.data.evidence.push({
openontology: OPENONTOLOGY_VERSION,
kind: "Evidence",
id: "test:evidence:1",
source: "test:source:team-page",
selector: { type: "line-range", start: 1, end: 2 },
excerpt: "x".repeat(600)
});
const report = validateOntologyPackage(withEvidence, { maxExcerptLength: 500 });
const finding = report.findings.find((f) => f.code === "OO-P-EXCERPT-LENGTH");
expect(finding?.severity).toBe("policy");
// A policy finding is not an error: the package still validates.
expect(report.ok).toBe(true);
});
it("rejects public evidence drawn from a private source", () => {
const { pkg } = scaffold();
const copy = structuredClone(pkg);
copy.data.sources[0].visibility = "private";
copy.data.evidence.push({
openontology: OPENONTOLOGY_VERSION,
kind: "Evidence",
id: "test:evidence:2",
source: copy.data.sources[0].id,
selector: { type: "whole-document" },
visibility: "public"
});
expect(validateOntologyPackage(copy).findings.map((f) => f.code)).toContain("OO-P-VISIBILITY");
});
});
describe("constraints", () => {
it("reports a violation of a declared required-predicate constraint", () => {
const { pkg } = scaffold();
const broken = structuredClone(pkg);
broken.data.claims = broken.data.claims.filter((c) => c.predicate !== "homepage");
const report = validateOntologyPackage(broken);
const violations = report.findings.filter((f) => f.code === "OO-C-REQUIRED-PREDICATE");
expect(violations).toHaveLength(3);
expect(violations[0].severity).toBe("warning");
});
});
describe("report rendering", () => {
it("renders text, json, yaml, and markdown (R71)", () => {
const { pkg } = scaffold();
const report = validateOntologyPackage(pkg);
expect(renderReport(report, "text")).toContain("OpenOntology package is valid.");
expect(JSON.parse(renderReport(report, "json")).ok).toBe(true);
expect(renderReport(report, "yaml")).toContain("ok: true");
expect(renderReport(report, "markdown")).toContain("# Validation passed");
});
it("gives every finding a stable code and a remediation hint where known", () => {
const { pkg } = scaffold();
const broken = withClaims(pkg, (claims) => {
delete (claims[0] as Claim).sources;
});
const finding = validateOntologyPackage(broken).findings.find((f) => f.code === "OO-P-NO-SOURCE");
expect(finding?.hint).toMatch(/firstParty/);
});
});