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

@ -15,8 +15,9 @@
},
"dependencies": {
"@logicsrc/account-core": "file:../account-core",
"@logicsrc/plugin-core": "file:../plugin-core",
"@logicsrc/openontology": "file:../openontology",
"@logicsrc/plugin-coinpay": "file:../../plugins/coinpay",
"@logicsrc/plugin-core": "file:../plugin-core",
"@logicsrc/plugin-credential-sharing": "file:../../plugins/credential-sharing",
"@logicsrc/plugin-email-accounts": "file:../../plugins/email-accounts",
"@logicsrc/plugin-feed-discovery": "file:../../plugins/feed-discovery",

View file

@ -27,6 +27,7 @@ import { boards, tasks } from "./fixtures.js";
import { print, type OutputFormat } from "./format.js";
import { parsePositiveInteger } from "./numeric-options.js";
import { exportOpenSpecSummary, importOpenSpec, writeOpenSpecChange } from "./openspec.js";
import { registerOntologyCommands } from "./ontology.js";
import { defaultPluginRegistry } from "./registry.js";
process.stdout.on("error", (error: NodeJS.ErrnoException) => {
@ -829,6 +830,8 @@ async function runYoloArcade(game: string, repo?: string) {
});
}
registerOntologyCommands(program);
program.parseAsync(process.argv).catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;

View file

@ -0,0 +1,784 @@
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { Command } from "commander";
import { parse as parseYaml, stringify as toYaml } from "yaml";
import {
buildOntologyPackage,
createOntologyEngine,
diffChangeSet,
exportJsonLd,
initOntologyPackage,
loadOntologyPackage,
localActor,
proposerActor,
readOnlyActor,
renderReport,
verifyPackageDigest,
type Actor,
type ChangeOperation,
type OntologyEngine,
type QueryBody,
type ReportFormat
} from "@logicsrc/openontology";
/**
* Exit codes, stable for CI (R144):
* 0 ok · 1 validation failed · 2 usage error · 3 not found · 4 denied/approval
*/
export const EXIT = { ok: 0, invalid: 1, usage: 2, notFound: 3, denied: 4 } as const;
type Format = "table" | "json" | "yaml" | "markdown" | "ndjson";
function emit(data: unknown, format: Format): void {
switch (format) {
case "json":
console.log(JSON.stringify(data, null, 2));
return;
case "yaml":
console.log(toYaml(data));
return;
case "ndjson":
for (const row of Array.isArray(data) ? data : [data]) console.log(JSON.stringify(row));
return;
case "markdown": {
const rows = Array.isArray(data) ? data : [data];
if (rows.length === 0) {
console.log("_No rows._");
return;
}
const columns = [...new Set(rows.flatMap((r) => Object.keys(r as object)))];
console.log(`| ${columns.join(" | ")} |`);
console.log(`| ${columns.map(() => "---").join(" | ")} |`);
for (const row of rows) {
console.log(`| ${columns.map((c) => formatCell((row as Record<string, unknown>)[c])).join(" | ")} |`);
}
return;
}
default: {
const rows = Array.isArray(data) ? data : [data];
if (rows.length === 0) {
console.log("(no rows)");
return;
}
console.table(rows);
}
}
}
function formatCell(value: unknown): string {
if (value === undefined || value === null) return "";
if (typeof value === "object") return JSON.stringify(value);
return String(value).replace(/\|/g, "\\|");
}
function fail(message: string, code: number): never {
console.error(message);
process.exit(code);
}
/**
* Resolve the actor from flags.
*
* `--as agent` deliberately cannot be talked into apply rights the policy
* layer denies agent applies outright, and this is only choosing which
* already-bounded role the command runs as.
*/
function resolveActor(options: { as?: string; actor?: string }): Actor {
const id = options.actor ?? (options.as === "agent" ? "agent:cli" : "local");
switch (options.as) {
case "agent":
return proposerActor(id);
case "reader":
return readOnlyActor(id, "human");
default:
return localActor(id);
}
}
function openEngine(dir: string, options: { as?: string; actor?: string }): OntologyEngine {
try {
return createOntologyEngine({
package: loadOntologyPackage(resolve(dir)),
actor: resolveActor(options),
client: "logicsrc-cli"
});
} catch (error) {
return fail((error as Error).message, EXIT.usage);
}
}
function guard<T>(fn: () => T): T {
try {
return fn();
} catch (error) {
const err = error as Error & { code?: string };
const code =
err.code === "OO-A-DENIED" || err.code === "OO-A-APPROVAL-REQUIRED"
? EXIT.denied
: err.code === "OO-A-NOT-FOUND"
? EXIT.notFound
: EXIT.invalid;
return fail(err.message, code);
}
}
export function registerOntologyCommands(program: Command): void {
const ontology = program
.command("ontology")
.description("LogicSRC OpenOntology: author, validate, query, and govern domain knowledge.");
const actorOptions = <T extends Command>(cmd: T): T =>
cmd
.option("--as <role>", "run as local | agent | reader", "local")
.option("--actor <id>", "actor id recorded on events and claims") as T;
/* ── authoring ──────────────────────────────────────────────────────── */
ontology
.command("init")
.argument("<id>", "package id, lowercase kebab-case")
.option("--dir <dir>", "target directory (defaults to ./<id>)")
.option("--namespace <uri>", "base IRI compact ids canonicalize against")
.option("--maintainer <id>", "maintainer identity (mailto:, did:, https:)")
.option("--license <spdx>", "package license", "CC-BY-4.0")
.description("Create a starter package with example types, data, and a saved query.")
.action((id: string, options) => {
const dir = resolve(options.dir ?? `./${id}`);
const result = initOntologyPackage(dir, {
id,
namespace: options.namespace,
maintainer: options.maintainer,
license: options.license
});
console.log(`Created ${dir}/openontology.yaml`);
console.log(
`Created ${result.counts.entityTypes} entity types, ${result.counts.relationshipTypes} relationship types, ` +
`${result.counts.entities} entities, ${result.counts.claims} claims, ${result.counts.sources} sources.`
);
console.log(`\nNext: logicsrc ontology validate ${dir} --strict`);
});
ontology
.command("validate")
.argument("[dir]", "package directory", ".")
.option("--strict", "fail on unknown types, predicates, and unnamespaced extensions")
.option("--max-excerpt <n>", "excerpt length that triggers a policy finding", "500")
.option("--format <format>", "text, json, yaml, or markdown", "text")
.description("Validate schema, graph, provenance, and package integrity without modifying files.")
.action((dir: string, options) => {
const pkg = guard(() => loadOntologyPackage(resolve(dir)));
const built = buildOntologyPackage(pkg);
const report = createOntologyEngine({ package: pkg, actor: localActor() }).validateOntologyPackage({
strict: options.strict === true,
maxExcerptLength: Number.parseInt(options.maxExcerpt, 10),
expectedDigest: built.digest
});
console.log(renderReport(report, options.format as ReportFormat));
if (!report.ok) process.exit(EXIT.invalid);
});
ontology
.command("lint")
.argument("[dir]", "package directory", ".")
.option("--format <format>", "text, json, yaml, or markdown", "text")
.description("Report warnings, policy findings, and style issues without failing on them.")
.action((dir: string, options) => {
const pkg = guard(() => loadOntologyPackage(resolve(dir)));
const report = createOntologyEngine({ package: pkg, actor: localActor() }).validateOntologyPackage();
const advisory = { ...report, findings: report.findings.filter((f) => f.severity !== "error") };
console.log(renderReport(advisory, options.format as ReportFormat));
});
ontology
.command("build")
.argument("[dir]", "package directory", ".")
.option("--out <file>", "write the canonical JSON build artifact here")
.option("--format <format>", "json or yaml", "json")
.description("Compile to canonical JSON and compute the deterministic package digest.")
.action((dir: string, options) => {
const built = guard(() => buildOntologyPackage(loadOntologyPackage(resolve(dir))));
if (options.out) {
writeFileSync(resolve(options.out), `${JSON.stringify(built, null, 2)}\n`, "utf8");
console.log(`Wrote ${resolve(options.out)}`);
}
console.log(`digest: ${built.digest}`);
for (const file of built.files) console.log(` ${file.digest} ${file.path} (${file.count})`);
});
ontology
.command("inspect")
.argument("[dir]", "package directory", ".")
.option("--format <format>", "table, json, yaml, or markdown", "table")
.description("Show package identity, counts, and integrity at a glance.")
.action((dir: string, options) => {
const pkg = guard(() => loadOntologyPackage(resolve(dir)));
const built = buildOntologyPackage(pkg);
emit(
{
id: pkg.manifest.id,
version: pkg.manifest.version,
namespace: pkg.manifest.namespace,
license: pkg.manifest.license,
entityTypes: pkg.schema.entityTypes.length,
relationshipTypes: pkg.schema.relationships.length,
entities: pkg.data.entities.length,
claims: pkg.data.claims.length,
sources: pkg.data.sources.length,
savedQueries: pkg.schema.queries.length,
digest: built.digest,
digestVerified: verifyPackageDigest(built).ok
},
options.format as Format
);
});
/* ── entities and claims ────────────────────────────────────────────── */
const entity = ontology.command("entity").description("Inspect and resolve entities.");
actorOptions(
entity
.command("get")
.argument("<id>", "entity id")
.option("--dir <dir>", "package directory", ".")
.option("--format <format>", "table, json, yaml, or markdown", "json")
.description("Fetch one entity, following merge redirects.")
).action((id: string, options) => {
const e = openEngine(options.dir, options);
emit(guard(() => e.getEntity(id)), options.format as Format);
});
actorOptions(
entity
.command("list")
.option("--dir <dir>", "package directory", ".")
.option("--type <type>", "filter by entity type")
.option("--limit <n>", "maximum rows", "50")
.option("--format <format>", "table, json, yaml, markdown, or ndjson", "table")
.description("List entities.")
).action((options) => {
const e = openEngine(options.dir, options);
const rows = e.store
.listEntities({ type: options.type, limit: Number.parseInt(options.limit, 10) })
.map((item) => ({ id: item.id, type: item.type, name: item.canonicalName, status: item.status ?? "active" }));
emit(rows, options.format as Format);
});
actorOptions(
entity
.command("find")
.argument("<text>", "name, alias, or id")
.option("--dir <dir>", "package directory", ".")
.option("--type <type>", "restrict to an entity type")
.option("--format <format>", "table, json, yaml, or markdown", "table")
.description("Rank candidate matches with the evidence for each (never a silent match).")
).action((text: string, options) => {
const e = openEngine(options.dir, options);
emit(
e.findEntities({ text, type: options.type }).map((match) => ({
id: match.entity.id,
name: match.entity.canonicalName,
score: match.score,
matchedOn: match.matchedOn,
evidence: match.evidence
})),
options.format as Format
);
});
actorOptions(
entity
.command("merge")
.argument("<source>", "entity id to merge away (kept as a redirect)")
.argument("<target>", "surviving entity id")
.option("--dir <dir>", "package directory", ".")
.option("--reason <text>", "why the two are the same")
.description("Propose a merge. Merges always require curator approval before they apply.")
).action((source: string, target: string, options) => {
const e = openEngine(options.dir, options);
const cs = guard(() =>
e.createOntologyChangeSet({
title: `Merge ${source} into ${target}`,
rationale: options.reason,
operations: [{ op: "merge-entity", source, target, reason: options.reason }]
})
);
console.log(`Proposed change set ${cs.id} (merge requires approval).`);
console.log(`Review with: logicsrc ontology changeset diff ${cs.id} --dir ${options.dir}`);
});
const claim = ontology.command("claim").description("Inspect and propose claims.");
actorOptions(
claim
.command("get")
.argument("<id>", "claim id")
.option("--dir <dir>", "package directory", ".")
.option("--format <format>", "table, json, yaml, or markdown", "json")
.description("Fetch one claim with its effective status.")
).action((id: string, options) => {
const e = openEngine(options.dir, options);
emit(guard(() => e.getClaim(id)), options.format as Format);
});
actorOptions(
claim
.command("list")
.option("--dir <dir>", "package directory", ".")
.option("--subject <id>", "filter by subject entity")
.option("--predicate <id>", "filter by predicate")
.option("--status <list>", "comma-separated claim statuses", "asserted")
.option("--limit <n>", "maximum rows", "50")
.option("--format <format>", "table, json, yaml, markdown, or ndjson", "table")
.description("List claims with their status, time, and source count.")
).action((options) => {
const e = openEngine(options.dir, options);
const rows = e.store
.listClaims({
subject: options.subject,
predicate: options.predicate,
status: options.status.split(",").map((s: string) => s.trim()),
limit: Number.parseInt(options.limit, 10)
})
.map((item) => ({
id: item.id,
subject: item.subject,
predicate: item.predicate,
object: "entity" in item.object ? item.object.entity : item.object.value,
status: item.status,
confidence: item.confidence ?? "",
validFrom: item.validTime?.from ?? "",
sources: item.sources?.length ?? 0
}));
emit(rows, options.format as Format);
});
actorOptions(
claim
.command("history")
.argument("<id>", "claim id")
.option("--dir <dir>", "package directory", ".")
.option("--format <format>", "table, json, yaml, or markdown", "table")
.description("Show the append-only status history of a claim.")
).action((id: string, options) => {
const e = openEngine(options.dir, options);
emit(guard(() => e.claimHistory(id)), options.format as Format);
});
for (const [name, description] of [
["propose", "Propose a new claim as a change set."],
["assert", "Propose a claim for immediate application (still policy-checked)."]
] as const) {
actorOptions(
claim
.command(name)
.requiredOption("--subject <id>", "subject entity id")
.requiredOption("--predicate <id>", "relationship or property id")
.option("--object-entity <id>", "object entity id (relationship claim)")
.option("--object-value <value>", "object value (property claim)")
.option("--dir <dir>", "package directory", ".")
.option("--source <id>", "source id backing the claim")
.option("--first-party", "declare this a manual first-party assertion")
.option("--confidence <n>", "confidence between 0 and 1")
.option("--valid-from <iso>", "start of domain valid time")
.option("--run <id>", "LogicSRC run id (required for agent actors)")
.description(description)
).action((options) => {
if (!options.objectEntity && options.objectValue === undefined) {
fail("Provide --object-entity or --object-value", EXIT.usage);
}
const e = openEngine(options.dir, options);
const value: Record<string, unknown> = {
subject: options.subject,
predicate: options.predicate,
object: options.objectEntity
? { entity: options.objectEntity }
: { value: coerce(options.objectValue) },
...(options.source ? { sources: [options.source] } : {}),
...(options.firstParty ? { firstParty: true } : {}),
...(options.confidence ? { confidence: Number.parseFloat(options.confidence) } : {}),
...(options.validFrom ? { validTime: { from: options.validFrom, to: null } } : {})
};
const cs = guard(() =>
e.createOntologyChangeSet({
title: `${options.subject} ${options.predicate} ${options.objectEntity ?? options.objectValue}`,
operations: [{ op: "assert-claim", value }],
runId: options.run
})
);
// R143: writes land as a proposal; nothing is applied by a bare command.
console.log(`Proposed change set ${cs.id} (status: ${cs.status}).`);
});
}
for (const [name, op, description] of [
["dispute", "dispute-claim", "Dispute an existing claim."],
["retract", "retract-claim", "Retract an existing claim."]
] as const) {
actorOptions(
claim
.command(name)
.argument("<id>", "target claim id")
.option("--dir <dir>", "package directory", ".")
.option("--reason <text>", "why")
.description(description)
).action((id: string, options) => {
const e = openEngine(options.dir, options);
const cs = guard(() =>
e.createOntologyChangeSet({
title: `${name} ${id}`,
rationale: options.reason,
operations: [{ op, target: id, reason: options.reason } as ChangeOperation]
})
);
console.log(`Proposed change set ${cs.id} (status: ${cs.status}).`);
});
}
/* ── query ──────────────────────────────────────────────────────────── */
const query = ontology.command("query").description("Run and explain portable queries.");
actorOptions(
query
.command("list")
.option("--dir <dir>", "package directory", ".")
.option("--format <format>", "table, json, yaml, or markdown", "table")
.description("List saved queries.")
).action((options) => {
const e = openEngine(options.dir, options);
emit(
e.getOntologySchema().queries.map((q) => ({ id: q.id, label: q.label ?? "", description: q.description })),
options.format as Format
);
});
actorOptions(
query
.command("run")
.argument("<query>", "saved query id or path to a query file")
.option("--dir <dir>", "package directory", ".")
.option("--param <key=value...>", "bind a saved-query parameter")
.option("--as-of <iso>", "evaluate against domain valid time")
.option("--status <list>", "comma-separated claim statuses", "asserted")
.option("--limit <n>", "maximum rows")
.option("--format <format>", "table, json, yaml, markdown, or ndjson", "table")
.description("Run a saved or file-based query.")
).action((queryRef: string, options) => {
const e = openEngine(options.dir, options);
const body = loadQuery(e, queryRef);
const result = guard(() =>
e.queryOntology(
{
...body,
...(options.asOf ? { asOf: options.asOf } : {}),
...(options.limit ? { limit: Number.parseInt(options.limit, 10) } : {}),
include: {
...body.include,
claimStatus: options.status.split(",").map((s: string) => s.trim())
}
},
parseParams(options.param)
)
);
emit(
result.rows.map((row) => ({ ...row.bindings, claims: row.claims.length })),
options.format as Format
);
if (result.explanation.truncated) {
console.error("note: results were truncated by the row limit");
}
});
actorOptions(
query
.command("explain")
.argument("<query>", "saved query id or path to a query file")
.option("--dir <dir>", "package directory", ".")
.option("--row <n>", "which result row to explain", "0")
.option("--param <key=value...>", "bind a saved-query parameter")
.option("--format <format>", "table, json, yaml, or markdown", "markdown")
.description("Show the claims, sources, and evidence behind one answer.")
).action((queryRef: string, options) => {
const e = openEngine(options.dir, options);
const result = guard(() => e.queryOntology(loadQuery(e, queryRef), parseParams(options.param)));
const explanation = guard(() =>
e.explainOntologyResult(result.id, Number.parseInt(options.row, 10))
);
if (options.format !== "markdown") {
emit(explanation, options.format as Format);
return;
}
console.log(`### Why this row is present\n`);
console.log(`Ontology: \`${explanation.ontology}\``);
console.log(`Claim statuses included: ${explanation.claimStatus.join(", ")}`);
if (explanation.asOf) console.log(`As of: ${explanation.asOf}`);
console.log("");
explanation.claims.forEach((entry, index) => {
const object = "entity" in entry.claim.object ? entry.claim.object.entity : entry.claim.object.value;
console.log(`${index + 1}. \`${entry.claim.subject}\`${entry.claim.predicate}\`${object}\``);
console.log(` - status: ${entry.claim.status}, confidence: ${entry.claim.confidence ?? "n/a"}`);
console.log(` - asserted by ${entry.claim.assertedBy} at ${entry.claim.assertedAt}`);
for (const source of entry.sources) console.log(` - source: ${source.title ?? source.id} <${source.uri}>`);
for (const ev of entry.evidence) console.log(` - evidence: ${ev.id} (${ev.selector.type})`);
for (const h of entry.history) console.log(` - ${h.at} ${h.status} by ${h.by}`);
});
});
/* ── change sets ────────────────────────────────────────────────────── */
const changeset = ontology.command("changeset").description("Propose, review, approve, and apply changes.");
actorOptions(
changeset
.command("list")
.option("--dir <dir>", "package directory", ".")
.option("--format <format>", "table, json, yaml, or markdown", "table")
.description("List change sets in this session.")
).action((options) => {
const e = openEngine(options.dir, options);
emit(
e.store.listChangeSets().map((cs) => ({
id: cs.id,
title: cs.title,
status: cs.status,
operations: cs.operations.length,
createdBy: cs.createdBy
})),
options.format as Format
);
});
actorOptions(
changeset
.command("create")
.requiredOption("--file <path>", "JSON or YAML file containing operations")
.requiredOption("--title <text>", "change set title")
.option("--dir <dir>", "package directory", ".")
.option("--rationale <text>", "why this change is proposed")
.option("--format <format>", "table, json, yaml, or markdown", "json")
.description("Create a change set from a file of operations.")
).action((options) => {
const e = openEngine(options.dir, options);
const operations = readOperations(options.file);
const cs = guard(() =>
e.createOntologyChangeSet({ title: options.title, rationale: options.rationale, operations })
);
emit(cs, options.format as Format);
});
actorOptions(
changeset
.command("diff")
.argument("<file>", "change set file (JSON or YAML)")
.option("--dir <dir>", "package directory", ".")
.option("--format <format>", "text, json, yaml, or markdown", "text")
.description("Show the semantic impact of a change set: counts, warnings, affected queries.")
).action((file: string, options) => {
const e = openEngine(options.dir, options);
const changeSet = readChangeSet(file);
const diff = guard(() => diffChangeSet(e.store, changeSet));
if (options.format !== "text") {
emit(diff, options.format as Format);
return;
}
console.log(`Change set: ${diff.title}\n`);
for (const [label, count] of Object.entries(diff.summary)) {
if (count > 0) console.log(`+ ${count} ${label.replace(/([A-Z])/g, " $1").toLowerCase()}`);
}
for (const warning of diff.warnings) console.log(`! ${warning.message}`);
if (diff.affectedQueries.length > 0) {
console.log("\nAffected saved queries");
for (const q of diff.affectedQueries) console.log(` ${q.id}: result count ${q.before}${q.after}`);
}
console.log(`\nApproval policy\n ${diff.requiredApprovals} approval(s) required`);
});
actorOptions(
changeset
.command("apply")
.argument("<file>", "change set file (JSON or YAML)")
.option("--dir <dir>", "package directory", ".")
.option("--approve", "record an approval from this actor first")
.option("--format <format>", "table, json, yaml, or markdown", "json")
.description("Apply a change set. Approval policy is enforced; --yolo cannot bypass it.")
).action((file: string, options) => {
const e = openEngine(options.dir, options);
const source = readChangeSet(file);
const created = guard(() =>
e.createOntologyChangeSet({
title: source.title,
rationale: source.rationale,
operations: source.operations,
requiredApprovals: source.requiredApprovals
})
);
if (options.approve) guard(() => e.approveOntologyChangeSet(created.id));
const applied = guard(() => e.applyOntologyChangeSet(created.id));
emit(
{
changeSet: applied.changeSet.id,
revision: applied.revision,
addedEntities: applied.addedEntities.length,
addedClaims: applied.addedClaims.length,
events: applied.events.length
},
options.format as Format
);
console.error(
"note: this in-memory session is not written back to disk; use the SDK or a persistent adapter to persist."
);
});
/* ── interop and audit ──────────────────────────────────────────────── */
actorOptions(
ontology
.command("export")
.option("--dir <dir>", "package directory", ".")
.option("--format <format>", "json or jsonld", "jsonld")
.option("--out <file>", "write to a file instead of stdout")
.description("Export the package, reporting any fields the target format cannot carry.")
).action((options) => {
const pkg = guard(() => loadOntologyPackage(resolve(options.dir)));
const output =
options.format === "json"
? { document: buildOntologyPackage(pkg), lossy: [] as Array<{ objectId: string; fields: string[] }> }
: exportJsonLd(pkg);
// R178: never hide a lossy export behind a success message.
if (output.lossy.length > 0) {
console.error(`warning: ${output.lossy.length} object(s) have fields this format cannot carry:`);
for (const entry of output.lossy.slice(0, 10)) {
console.error(` ${entry.objectId}: ${entry.fields.join(", ")}`);
}
if (output.lossy.length > 10) console.error(` ... and ${output.lossy.length - 10} more`);
}
const text = `${JSON.stringify(output.document, null, 2)}\n`;
if (options.out) {
writeFileSync(resolve(options.out), text, "utf8");
console.log(`Wrote ${resolve(options.out)}`);
return;
}
process.stdout.write(text);
});
actorOptions(
ontology
.command("import")
.requiredOption("--file <path>", "JSON-LD document to import")
.option("--dir <dir>", "package directory", ".")
.option("--propose", "emit the operations as a change set proposal", true)
.option("--format <format>", "table, json, yaml, or markdown", "json")
.description("Import JSON-LD as proposed operations. Imports never apply directly.")
).action((options) => {
const e = openEngine(options.dir, options);
const document = readJsonOrYaml(options.file) as Record<string, unknown>;
const result = guard(() => e.importOntology({ format: "jsonld", document }));
emit(
{ entities: result.entities, claims: result.claims, proposedOperations: result.operations.length },
options.format as Format
);
});
actorOptions(
ontology
.command("audit")
.option("--dir <dir>", "package directory", ".")
.option("--limit <n>", "maximum events", "50")
.option("--format <format>", "table, json, yaml, markdown, or ndjson", "table")
.description("Show the event log for this session.")
).action((options) => {
const e = openEngine(options.dir, options);
emit(
e.listEvents({ limit: Number.parseInt(options.limit, 10) }).map((event) => ({
at: event.at,
type: event.type,
actor: event.actor,
subject: event.subject ?? "",
revision: event.revision ?? ""
})),
options.format as Format
);
});
}
/* ── helpers ──────────────────────────────────────────────────────────── */
function coerce(value: string): unknown {
if (value === "true") return true;
if (value === "false") return false;
if (value !== "" && !Number.isNaN(Number(value))) return Number(value);
return value;
}
function parseParams(pairs: string[] | undefined): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const pair of pairs ?? []) {
const index = pair.indexOf("=");
if (index < 0) fail(`--param expects key=value, got ${JSON.stringify(pair)}`, EXIT.usage);
out[pair.slice(0, index)] = coerce(pair.slice(index + 1));
}
return out;
}
function readJsonOrYaml(path: string): unknown {
const raw = readFileSync(resolve(path), "utf8");
if (path.toLowerCase().endsWith(".json") || path.toLowerCase().endsWith(".jsonld")) {
return JSON.parse(raw) as unknown;
}
return parseYaml(raw) as unknown;
}
function readOperations(path: string): ChangeOperation[] {
const parsed = readJsonOrYaml(path);
const operations = Array.isArray(parsed)
? parsed
: ((parsed as { operations?: unknown[] }).operations ?? []);
if (!Array.isArray(operations) || operations.length === 0) {
fail(`${path} contains no operations`, EXIT.usage);
}
return operations as ChangeOperation[];
}
function readChangeSet(path: string): {
id: string;
title: string;
rationale?: string;
operations: ChangeOperation[];
requiredApprovals?: number;
createdAt: string;
createdBy: string;
status: "proposed";
openontology: string;
kind: "ChangeSet";
} {
const parsed = readJsonOrYaml(path) as Record<string, unknown>;
const operations = Array.isArray(parsed) ? (parsed as unknown as ChangeOperation[]) : readOperations(path);
return {
openontology: "0.1",
kind: "ChangeSet",
id: (parsed.id as string) ?? `changeset:${path}`,
title: (parsed.title as string) ?? "Untitled change set",
rationale: parsed.rationale as string | undefined,
operations,
requiredApprovals: parsed.requiredApprovals as number | undefined,
createdAt: (parsed.createdAt as string) ?? new Date().toISOString(),
createdBy: (parsed.createdBy as string) ?? "local",
status: "proposed"
};
}
function loadQuery(engine: OntologyEngine, ref: string): QueryBody {
const saved = engine.getOntologySchema().queries.find((q) => q.id === ref);
if (saved) return saved.query;
const parsed = readJsonOrYaml(ref) as Record<string, unknown>;
return (parsed.query ?? parsed) as QueryBody;
}