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,275 @@
import { toIri } from "./ids.js";
import { OPENONTOLOGY_VERSION } from "./types.js";
import type { BuiltPackage, Claim, Entity, LoadedPackage } from "./types.js";
export const OO = "https://logicsrc.com/ns/openontology#";
export const PROV = "http://www.w3.org/ns/prov#";
/**
* JSON-LD 1.1 context for the core model.
*
* Provenance terms deliberately alias W3C PROV-O where the semantics really
* match (R67/R134) rather than inventing parallel vocabulary.
*/
export function buildContext(manifest: { namespace: string }): Record<string, unknown> {
return {
"@version": 1.1,
oo: OO,
prov: PROV,
rdfs: "http://www.w3.org/2000/01/rdf-schema#",
xsd: "http://www.w3.org/2001/XMLSchema#",
ns: manifest.namespace.endsWith("/") ? manifest.namespace : `${manifest.namespace}/`,
id: "@id",
type: "@type",
label: { "@id": "rdfs:label" },
alias: { "@id": "oo:alias", "@container": "@set" },
externalId: { "@id": "oo:externalId", "@container": "@index" },
status: { "@id": "oo:status" },
Claim: "oo:Claim",
subject: { "@id": "rdf:subject", "@type": "@id" },
predicate: { "@id": "rdf:predicate", "@type": "@id" },
object: { "@id": "rdf:object" },
confidence: { "@id": "oo:confidence", "@type": "xsd:double" },
validFrom: { "@id": "oo:validFrom", "@type": "xsd:dateTime" },
validTo: { "@id": "oo:validTo", "@type": "xsd:dateTime" },
observedAt: { "@id": "oo:observedAt", "@type": "xsd:dateTime" },
assertedAt: { "@id": "prov:generatedAtTime", "@type": "xsd:dateTime" },
assertedBy: { "@id": "prov:wasAttributedTo", "@type": "@id" },
source: { "@id": "prov:wasDerivedFrom", "@type": "@id", "@container": "@set" },
evidence: { "@id": "oo:evidence", "@type": "@id", "@container": "@set" },
run: { "@id": "prov:wasGeneratedBy", "@type": "@id" },
supersedes: { "@id": "oo:supersedes", "@type": "@id" },
disputes: { "@id": "oo:disputes", "@type": "@id" },
rdf: "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
};
}
/** Fields the JSON-LD profile represents losslessly. Anything else is reported. */
const LOSSLESS_ENTITY_FIELDS = new Set([
"openontology",
"kind",
"id",
"type",
"canonicalName",
"labels",
"aliases",
"externalIds",
"status",
"createdAt",
"createdBy",
"supersededBy"
]);
const LOSSLESS_CLAIM_FIELDS = new Set([
"openontology",
"kind",
"id",
"subject",
"predicate",
"object",
"status",
"confidence",
"validTime",
"observedAt",
"assertedAt",
"assertedBy",
"runId",
"sources",
"evidence",
"supersedes",
"disputes",
"ontology"
]);
export interface JsonLdExport {
document: Record<string, unknown>;
/** Fields dropped by this profile, reported rather than silently lost (R135). */
lossy: Array<{ objectId: string; fields: string[] }>;
}
/**
* The compact prefix bound to this package's namespace.
*
* Canonicalizing `ethereum:person:alice` to an IRI drops the prefix, so
* reversing an IRI back to a compact id needs the binding. A package declares
* it via a Namespace object whose uri is the package namespace; otherwise the
* package id is the prefix (the plain "package-qualified" reading).
*/
export function packagePrefix(pkg: BuiltPackage | LoadedPackage): string {
const declared = pkg.schema.namespaces.find((ns) => ns.uri === pkg.manifest.namespace);
return declared?.prefix ?? pkg.manifest.id;
}
export function exportJsonLd(pkg: BuiltPackage | LoadedPackage): JsonLdExport {
const manifest = pkg.manifest;
const iri = (id: string) => toIri(id, { defaultNamespace: manifest.namespace });
const lossy: JsonLdExport["lossy"] = [];
const graph: Array<Record<string, unknown>> = [];
for (const entity of pkg.data.entities) {
const node: Record<string, unknown> = {
id: iri(entity.id),
type: iri(`${entityTypePrefix(manifest.id)}:${entity.type}`),
label: entity.canonicalName,
status: entity.status ?? "active",
"prov:generatedAtTime": entity.createdAt,
"prov:wasAttributedTo": entity.createdBy
};
if (entity.aliases?.length) node.alias = entity.aliases;
if (entity.externalIds) node.externalId = entity.externalIds;
if (entity.labels) node["rdfs:label"] = languageArray(entity.labels);
if (entity.supersededBy) node["oo:supersededBy"] = { id: iri(entity.supersededBy) };
const extra = extraFields(entity as unknown as Record<string, unknown>, LOSSLESS_ENTITY_FIELDS);
if (extra.length) lossy.push({ objectId: entity.id, fields: extra });
graph.push(node);
}
for (const claim of pkg.data.claims) {
const node: Record<string, unknown> = {
id: iri(claim.id),
type: "Claim",
subject: iri(claim.subject),
predicate: iri(`${entityTypePrefix(manifest.id)}:${claim.predicate}`),
status: claim.status,
assertedAt: claim.assertedAt,
assertedBy: claim.assertedBy
};
node.object =
"entity" in claim.object
? { id: iri(claim.object.entity) }
: literal(claim.object);
if (claim.confidence !== undefined) node.confidence = claim.confidence;
if (claim.validTime?.from) node.validFrom = claim.validTime.from;
if (claim.validTime?.to) node.validTo = claim.validTime.to;
if (claim.observedAt) node.observedAt = claim.observedAt;
if (claim.runId) node.run = claim.runId;
if (claim.sources?.length) node.source = claim.sources.map(iri);
if (claim.evidence?.length) node.evidence = claim.evidence.map(iri);
if (claim.supersedes) node.supersedes = iri(claim.supersedes);
if (claim.disputes) node.disputes = iri(claim.disputes);
const extra = extraFields(claim as unknown as Record<string, unknown>, LOSSLESS_CLAIM_FIELDS);
if (extra.length) lossy.push({ objectId: claim.id, fields: extra });
graph.push(node);
}
for (const source of pkg.data.sources) {
graph.push({
id: iri(source.id),
type: "prov:Entity",
"oo:sourceType": source.sourceType,
"oo:uri": source.uri,
"prov:generatedAtTime": source.retrievedAt,
...(source.license ? { "oo:license": source.license } : {}),
...(source.contentHash ? { "oo:contentHash": source.contentHash } : {})
});
}
return {
document: {
"@context": pkg.context ?? buildContext(manifest),
"@graph": graph
},
lossy
};
}
/**
* Import the reified profile produced by `exportJsonLd`, so a package can make
* the JSON JSON-LD JSON round trip the conformance suite asserts.
*/
export function importJsonLd(
document: Record<string, unknown>,
manifest: { id: string; namespace: string; version?: string; prefix?: string }
): { entities: Entity[]; claims: Claim[] } {
const base = manifest.namespace.endsWith("/") ? manifest.namespace : `${manifest.namespace}/`;
const prefix = manifest.prefix ?? manifest.id;
const compact = (value: string): string => {
if (!value.startsWith(base)) return value;
const segments = value.slice(base.length).split("/").map(decodeURIComponent);
return [prefix, ...segments].join(":");
};
const graph = (document["@graph"] as Array<Record<string, unknown>>) ?? [];
const entities: Entity[] = [];
const claims: Claim[] = [];
for (const node of graph) {
const type = node.type as string | undefined;
const id = compact(String(node.id));
if (type === "Claim") {
const object = node.object as Record<string, unknown>;
const claim: Claim = {
openontology: OPENONTOLOGY_VERSION,
kind: "Claim",
id,
subject: compact(String(node.subject)),
predicate: compact(String(node.predicate)).split(":").pop() as string,
object:
object && typeof object === "object" && "id" in object
? { entity: compact(String(object.id)) }
: { value: (object as { "@value"?: unknown })?.["@value"] ?? object },
status: (node.status as Claim["status"]) ?? "asserted",
assertedAt: String(node.assertedAt),
assertedBy: String(node.assertedBy)
};
if (node.confidence !== undefined) claim.confidence = Number(node.confidence);
if (node.validFrom || node.validTo) {
claim.validTime = {
...(node.validFrom ? { from: String(node.validFrom) } : {}),
...(node.validTo ? { to: String(node.validTo) } : {})
};
}
if (node.observedAt) claim.observedAt = String(node.observedAt);
if (node.run) claim.runId = String(node.run);
if (node.source) claim.sources = (node.source as string[]).map(compact);
if (node.evidence) claim.evidence = (node.evidence as string[]).map(compact);
if (node.supersedes) claim.supersedes = compact(String(node.supersedes));
if (node.disputes) claim.disputes = compact(String(node.disputes));
claims.push(claim);
continue;
}
if (type === "prov:Entity" || !type) continue;
const entity: Entity = {
openontology: OPENONTOLOGY_VERSION,
kind: "Entity",
id,
type: compact(String(type)).split(":").pop() as string,
canonicalName: String(node.label ?? id),
createdAt: String(node["prov:generatedAtTime"] ?? ""),
createdBy: String(node["prov:wasAttributedTo"] ?? "")
};
if (node.alias) entity.aliases = node.alias as string[];
if (node.externalId) entity.externalIds = node.externalId as Record<string, string>;
if (node.status && node.status !== "active") entity.status = node.status as Entity["status"];
entities.push(entity);
}
return { entities, claims };
}
function literal(object: { value: unknown; datatype?: string; language?: string }): unknown {
if (object.language) return { "@value": object.value, "@language": object.language };
return object.value;
}
function languageArray(labels: Record<string, string>): Array<{ "@value": string; "@language": string }> {
return Object.entries(labels).map(([language, value]) => ({ "@value": value, "@language": language }));
}
function extraFields(object: Record<string, unknown>, lossless: Set<string>): string[] {
return Object.keys(object).filter((key) => !lossless.has(key) && object[key] !== undefined);
}
function entityTypePrefix(packageId: string): string {
return packageId;
}