mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-02 12:54:03 +00:00
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>
531 lines
16 KiB
TypeScript
531 lines
16 KiB
TypeScript
import { evaluateQuery } from "./query.js";
|
|
import { createMemoryStore, type OntologyStore } from "./store.js";
|
|
import { OPENONTOLOGY_VERSION } from "./types.js";
|
|
import type {
|
|
ChangeOperation,
|
|
ChangeSet,
|
|
Claim,
|
|
Entity,
|
|
EventType,
|
|
OntologyEvent
|
|
} from "./types.js";
|
|
|
|
export class ChangeSetConflictError extends Error {
|
|
readonly code = "OO-X-CONFLICT";
|
|
constructor(
|
|
message: string,
|
|
readonly baseRevision: string | undefined,
|
|
readonly currentRevision: string
|
|
) {
|
|
super(message);
|
|
this.name = "ChangeSetConflictError";
|
|
}
|
|
}
|
|
|
|
export class ChangeSetApplyError extends Error {
|
|
readonly code = "OO-X-APPLY";
|
|
constructor(message: string, readonly operationIndex: number) {
|
|
super(message);
|
|
this.name = "ChangeSetApplyError";
|
|
}
|
|
}
|
|
|
|
export interface ApplyContext {
|
|
actorId: string;
|
|
actorType?: "human" | "service" | "agent";
|
|
now: string;
|
|
nextId: (kind: "claim" | "event" | "entity") => string;
|
|
requestId?: string;
|
|
runId?: string;
|
|
client?: string;
|
|
/** Operations a reviewer rejected; they are skipped and reported. */
|
|
skipOperations?: number[];
|
|
}
|
|
|
|
export interface ApplyResult {
|
|
changeSet: ChangeSet;
|
|
revision: string;
|
|
events: OntologyEvent[];
|
|
addedEntities: string[];
|
|
addedClaims: string[];
|
|
statusChanges: Array<{ objectId: string; status: string }>;
|
|
skipped: number[];
|
|
}
|
|
|
|
/**
|
|
* Apply a change set atomically.
|
|
*
|
|
* Every operation is validated against the current store *before* anything is
|
|
* written, so a change set either lands whole or not at all — there is no
|
|
* half-applied state to reason about later.
|
|
*/
|
|
export function applyChangeSet(
|
|
store: OntologyStore,
|
|
changeSet: ChangeSet,
|
|
ctx: ApplyContext
|
|
): ApplyResult {
|
|
const currentRevision = store.revision();
|
|
if (changeSet.baseRevision && changeSet.baseRevision !== currentRevision) {
|
|
throw new ChangeSetConflictError(
|
|
`Change set ${changeSet.id} was authored against ${changeSet.baseRevision} but the store is at ${currentRevision}`,
|
|
changeSet.baseRevision,
|
|
currentRevision
|
|
);
|
|
}
|
|
|
|
const skip = new Set(ctx.skipOperations ?? []);
|
|
const planned: Array<{ index: number; op: ChangeOperation }> = changeSet.operations
|
|
.map((op, index) => ({ op, index }))
|
|
.filter(({ index }) => !skip.has(index));
|
|
|
|
// ── Pre-flight: refuse the whole change set if any operation cannot apply.
|
|
const pendingEntityIds = new Set<string>();
|
|
for (const { op, index } of planned) {
|
|
switch (op.op) {
|
|
case "add-entity": {
|
|
const id = (op.value as { id?: string }).id;
|
|
if (!id) throw new ChangeSetApplyError("add-entity is missing value.id", index);
|
|
if (store.getEntity(id) || pendingEntityIds.has(id)) {
|
|
throw new ChangeSetApplyError(`add-entity ${id} already exists`, index);
|
|
}
|
|
pendingEntityIds.add(id);
|
|
break;
|
|
}
|
|
case "update-metadata":
|
|
case "archive-entity":
|
|
if (!store.getEntity(op.target) && !pendingEntityIds.has(op.target)) {
|
|
throw new ChangeSetApplyError(`${op.op} target ${op.target} does not exist`, index);
|
|
}
|
|
break;
|
|
case "merge-entity":
|
|
if (!store.getEntity(op.source) && !pendingEntityIds.has(op.source)) {
|
|
throw new ChangeSetApplyError(`merge-entity source ${op.source} does not exist`, index);
|
|
}
|
|
if (!store.getEntity(op.target) && !pendingEntityIds.has(op.target)) {
|
|
throw new ChangeSetApplyError(`merge-entity target ${op.target} does not exist`, index);
|
|
}
|
|
if (op.source === op.target) {
|
|
throw new ChangeSetApplyError(`merge-entity cannot merge ${op.source} into itself`, index);
|
|
}
|
|
break;
|
|
case "dispute-claim":
|
|
case "retract-claim":
|
|
case "supersede-claim":
|
|
if (!store.getClaim(op.target)) {
|
|
throw new ChangeSetApplyError(`${op.op} target claim ${op.target} does not exist`, index);
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
const events: OntologyEvent[] = [];
|
|
const addedEntities: string[] = [];
|
|
const addedClaims: string[] = [];
|
|
const statusChanges: Array<{ objectId: string; status: string }> = [];
|
|
|
|
const emit = (type: EventType, subject?: string, data?: Record<string, unknown>) => {
|
|
const event: OntologyEvent = {
|
|
openontology: OPENONTOLOGY_VERSION,
|
|
kind: "Event",
|
|
id: ctx.nextId("event"),
|
|
type,
|
|
ontology: changeSet.ontology,
|
|
at: ctx.now,
|
|
actor: ctx.actorId,
|
|
actorType: ctx.actorType,
|
|
client: ctx.client,
|
|
requestId: ctx.requestId,
|
|
runId: ctx.runId ?? changeSet.runId,
|
|
changeSet: changeSet.id,
|
|
subject,
|
|
data
|
|
};
|
|
events.push(event);
|
|
store.appendEvent(event);
|
|
};
|
|
|
|
for (const { op, index } of planned) {
|
|
switch (op.op) {
|
|
case "add-entity": {
|
|
const entity = materializeEntity(op.value, ctx);
|
|
store.addEntity(entity);
|
|
addedEntities.push(entity.id);
|
|
emit("entity.added", entity.id, { type: entity.type });
|
|
break;
|
|
}
|
|
|
|
case "update-metadata": {
|
|
const updated = store.updateEntityMetadata(op.target, {
|
|
...(op.value as Partial<Entity>),
|
|
updatedAt: ctx.now
|
|
});
|
|
emit("entity.added", updated.id, { updated: Object.keys(op.value) });
|
|
break;
|
|
}
|
|
|
|
case "assert-claim": {
|
|
const claim = materializeClaim(op.value, changeSet, ctx, "asserted");
|
|
store.appendClaim(claim);
|
|
addedClaims.push(claim.id);
|
|
emit("claim.asserted", claim.id, { subject: claim.subject, predicate: claim.predicate });
|
|
break;
|
|
}
|
|
|
|
case "dispute-claim": {
|
|
store.setClaimStatus({
|
|
objectId: op.target,
|
|
status: "disputed",
|
|
at: ctx.now,
|
|
by: ctx.actorId,
|
|
changeSet: changeSet.id,
|
|
reason: op.reason
|
|
});
|
|
statusChanges.push({ objectId: op.target, status: "disputed" });
|
|
if (op.value) {
|
|
const counter = materializeClaim(
|
|
{ ...op.value, disputes: op.target },
|
|
changeSet,
|
|
ctx,
|
|
"asserted"
|
|
);
|
|
store.appendClaim(counter);
|
|
addedClaims.push(counter.id);
|
|
}
|
|
emit("claim.disputed", op.target, { reason: op.reason });
|
|
break;
|
|
}
|
|
|
|
case "retract-claim": {
|
|
store.setClaimStatus({
|
|
objectId: op.target,
|
|
status: "retracted",
|
|
at: ctx.now,
|
|
by: ctx.actorId,
|
|
changeSet: changeSet.id,
|
|
reason: op.reason
|
|
});
|
|
statusChanges.push({ objectId: op.target, status: "retracted" });
|
|
emit("claim.retracted", op.target, { reason: op.reason });
|
|
break;
|
|
}
|
|
|
|
case "supersede-claim": {
|
|
const replacement = materializeClaim(
|
|
{ ...op.value, supersedes: op.target },
|
|
changeSet,
|
|
ctx,
|
|
"asserted"
|
|
);
|
|
store.appendClaim(replacement);
|
|
addedClaims.push(replacement.id);
|
|
store.setClaimStatus({
|
|
objectId: op.target,
|
|
status: "superseded",
|
|
at: ctx.now,
|
|
by: ctx.actorId,
|
|
changeSet: changeSet.id,
|
|
reason: op.reason
|
|
});
|
|
statusChanges.push({ objectId: op.target, status: "superseded" });
|
|
emit("claim.superseded", op.target, { replacedBy: replacement.id });
|
|
break;
|
|
}
|
|
|
|
case "merge-entity": {
|
|
// The losing id is kept forever as a redirect (R42): old references
|
|
// keep resolving, and the merge is reversible via a compensating set.
|
|
store.updateEntityMetadata(op.source, { supersededBy: op.target, updatedAt: ctx.now });
|
|
store.setEntityStatus({
|
|
objectId: op.source,
|
|
status: "merged",
|
|
at: ctx.now,
|
|
by: ctx.actorId,
|
|
changeSet: changeSet.id,
|
|
reason: op.reason
|
|
});
|
|
statusChanges.push({ objectId: op.source, status: "merged" });
|
|
emit("entity.merged", op.source, { into: op.target, reason: op.reason });
|
|
break;
|
|
}
|
|
|
|
case "archive-entity": {
|
|
store.setEntityStatus({
|
|
objectId: op.target,
|
|
status: "archived",
|
|
at: ctx.now,
|
|
by: ctx.actorId,
|
|
changeSet: changeSet.id,
|
|
reason: op.reason
|
|
});
|
|
statusChanges.push({ objectId: op.target, status: "archived" });
|
|
emit("entity.archived", op.target, { reason: op.reason });
|
|
break;
|
|
}
|
|
|
|
case "schema-migration": {
|
|
const schema = store.getSchema();
|
|
const value = op.value as Record<string, unknown[]>;
|
|
for (const section of ["entityTypes", "relationships", "properties", "constraints", "queries", "actions"] as const) {
|
|
for (const item of value[section] ?? []) {
|
|
(schema[section] as unknown[]).push(item);
|
|
}
|
|
}
|
|
emit("schema.migrated", changeSet.id, { breaking: op.breaking === true });
|
|
break;
|
|
}
|
|
|
|
default: {
|
|
const unknown = op as { op: string };
|
|
throw new ChangeSetApplyError(`Unsupported operation ${unknown.op}`, index);
|
|
}
|
|
}
|
|
}
|
|
|
|
const revision = store.bumpRevision();
|
|
const applied: ChangeSet = {
|
|
...changeSet,
|
|
status: "applied",
|
|
appliedAt: ctx.now,
|
|
appliedBy: ctx.actorId,
|
|
resultRevision: revision
|
|
};
|
|
store.putChangeSet(applied);
|
|
emit("changeset.applied", changeSet.id, { revision, operations: planned.length });
|
|
|
|
return {
|
|
changeSet: applied,
|
|
revision,
|
|
events,
|
|
addedEntities,
|
|
addedClaims,
|
|
statusChanges,
|
|
skipped: [...skip]
|
|
};
|
|
}
|
|
|
|
function materializeEntity(value: Record<string, unknown>, ctx: ApplyContext): Entity {
|
|
const input = value as unknown as Partial<Entity>;
|
|
return {
|
|
...input,
|
|
openontology: input.openontology ?? OPENONTOLOGY_VERSION,
|
|
kind: "Entity",
|
|
id: input.id ?? ctx.nextId("entity"),
|
|
type: input.type as string,
|
|
canonicalName: input.canonicalName as string,
|
|
createdAt: input.createdAt ?? ctx.now,
|
|
createdBy: input.createdBy ?? ctx.actorId
|
|
};
|
|
}
|
|
|
|
function materializeClaim(
|
|
value: Record<string, unknown>,
|
|
changeSet: ChangeSet,
|
|
ctx: ApplyContext,
|
|
status: Claim["status"]
|
|
): Claim {
|
|
const input = value as unknown as Partial<Claim>;
|
|
const claim: Claim = {
|
|
...input,
|
|
openontology: input.openontology ?? OPENONTOLOGY_VERSION,
|
|
kind: "Claim",
|
|
id: input.id ?? ctx.nextId("claim"),
|
|
subject: input.subject as string,
|
|
predicate: input.predicate as string,
|
|
object: input.object as Claim["object"],
|
|
status: input.status ?? status,
|
|
assertedAt: input.assertedAt ?? ctx.now,
|
|
assertedBy: input.assertedBy ?? ctx.actorId,
|
|
changeSet: changeSet.id
|
|
};
|
|
if (changeSet.ontology && !claim.ontology) claim.ontology = changeSet.ontology;
|
|
if ((changeSet.runId ?? ctx.runId) && !claim.runId) claim.runId = changeSet.runId ?? ctx.runId;
|
|
return claim;
|
|
}
|
|
|
|
/* ── Semantic diff ─────────────────────────────────────────────────────── */
|
|
|
|
export interface SemanticDiff {
|
|
changeSet: string;
|
|
title: string;
|
|
summary: {
|
|
entitiesAdded: number;
|
|
claimsAdded: number;
|
|
claimsDisputed: number;
|
|
claimsRetracted: number;
|
|
claimsSuperseded: number;
|
|
entitiesMerged: number;
|
|
entitiesArchived: number;
|
|
metadataUpdates: number;
|
|
schemaMigrations: number;
|
|
};
|
|
warnings: Array<{ code: string; message: string; operationIndex?: number }>;
|
|
affectedQueries: Array<{ id: string; before: number; after: number }>;
|
|
requiredApprovals: number;
|
|
}
|
|
|
|
/**
|
|
* What a reviewer sees instead of raw JSON: counts, duplicate-identity
|
|
* warnings, and the before/after row counts of every saved query the change
|
|
* set would move.
|
|
*/
|
|
export function diffChangeSet(
|
|
store: OntologyStore,
|
|
changeSet: ChangeSet,
|
|
options: { simulate?: boolean } = {}
|
|
): SemanticDiff {
|
|
const summary = {
|
|
entitiesAdded: 0,
|
|
claimsAdded: 0,
|
|
claimsDisputed: 0,
|
|
claimsRetracted: 0,
|
|
claimsSuperseded: 0,
|
|
entitiesMerged: 0,
|
|
entitiesArchived: 0,
|
|
metadataUpdates: 0,
|
|
schemaMigrations: 0
|
|
};
|
|
const warnings: SemanticDiff["warnings"] = [];
|
|
|
|
changeSet.operations.forEach((op, index) => {
|
|
switch (op.op) {
|
|
case "add-entity": {
|
|
summary.entitiesAdded += 1;
|
|
const value = op.value as { id?: string; canonicalName?: string; type?: string };
|
|
if (value.canonicalName) {
|
|
const candidates = store
|
|
.findEntities({ text: value.canonicalName, type: value.type, limit: 3 })
|
|
.filter((match) => match.entity.id !== value.id);
|
|
if (candidates.length > 0) {
|
|
warnings.push({
|
|
code: "OO-D-POSSIBLE-DUPLICATE",
|
|
operationIndex: index,
|
|
message: `possible duplicate identity: ${value.canonicalName} resembles ${candidates
|
|
.map((c) => `${c.entity.id} (${c.matchedOn}, ${c.score.toFixed(2)})`)
|
|
.join(", ")}`
|
|
});
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case "assert-claim": {
|
|
summary.claimsAdded += 1;
|
|
const value = op.value as Partial<Claim>;
|
|
if (!value.sources?.length && !value.firstParty) {
|
|
warnings.push({
|
|
code: "OO-D-NO-SOURCE",
|
|
operationIndex: index,
|
|
message: "claim has no source and is not marked firstParty"
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case "dispute-claim":
|
|
summary.claimsDisputed += 1;
|
|
break;
|
|
case "retract-claim":
|
|
summary.claimsRetracted += 1;
|
|
break;
|
|
case "supersede-claim":
|
|
summary.claimsSuperseded += 1;
|
|
break;
|
|
case "merge-entity":
|
|
summary.entitiesMerged += 1;
|
|
warnings.push({
|
|
code: "OO-D-MERGE",
|
|
operationIndex: index,
|
|
message: `merging ${op.source} into ${op.target} is reversible only via a compensating change set`
|
|
});
|
|
break;
|
|
case "archive-entity":
|
|
summary.entitiesArchived += 1;
|
|
break;
|
|
case "update-metadata":
|
|
summary.metadataUpdates += 1;
|
|
break;
|
|
case "schema-migration":
|
|
summary.schemaMigrations += 1;
|
|
if (op.breaking) {
|
|
warnings.push({
|
|
code: "OO-D-BREAKING",
|
|
operationIndex: index,
|
|
message: "breaking schema migration requires maintainer approval and a major version bump"
|
|
});
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
});
|
|
|
|
const affectedQueries: SemanticDiff["affectedQueries"] = [];
|
|
if (options.simulate !== false) {
|
|
const savedQueries = store.getSchema().queries;
|
|
const before = new Map<string, number>();
|
|
for (const saved of savedQueries) {
|
|
try {
|
|
before.set(saved.id, evaluateQuery(store.view(), saved.query).rows.length);
|
|
} catch {
|
|
// A query that cannot run today cannot report a delta; skip it.
|
|
}
|
|
}
|
|
|
|
// Simulate against a throwaway copy so review never mutates the store.
|
|
const sandbox = cloneStoreForSimulation(store);
|
|
try {
|
|
applyChangeSet(sandbox, { ...changeSet, baseRevision: undefined }, {
|
|
actorId: "simulation",
|
|
now: changeSet.createdAt,
|
|
nextId: simulationIds()
|
|
});
|
|
for (const saved of savedQueries) {
|
|
if (!before.has(saved.id)) continue;
|
|
try {
|
|
const after = evaluateQuery(sandbox.view(), saved.query).rows.length;
|
|
const priorCount = before.get(saved.id) as number;
|
|
if (after !== priorCount) {
|
|
affectedQueries.push({ id: saved.id, before: priorCount, after });
|
|
}
|
|
} catch {
|
|
// ignore per-query simulation failures
|
|
}
|
|
}
|
|
} catch (error) {
|
|
warnings.push({
|
|
code: "OO-D-SIMULATION-FAILED",
|
|
message: `change set does not apply cleanly: ${(error as Error).message}`
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
changeSet: changeSet.id,
|
|
title: changeSet.title,
|
|
summary,
|
|
warnings,
|
|
affectedQueries,
|
|
requiredApprovals: changeSet.requiredApprovals ?? 1
|
|
};
|
|
}
|
|
|
|
function simulationIds(): ApplyContext["nextId"] {
|
|
let n = 0;
|
|
return (kind) => `sim:${kind}:${++n}`;
|
|
}
|
|
|
|
/** Deep-copy the store's data into a throwaway store so review never mutates state. */
|
|
function cloneStoreForSimulation(store: OntologyStore): OntologyStore {
|
|
const schema = store.getSchema();
|
|
return createMemoryStore({
|
|
manifest: store.getManifest(),
|
|
schema: structuredClone(schema),
|
|
data: {
|
|
entities: structuredClone(store.listEntities()),
|
|
claims: structuredClone(store.listClaims({ status: ["asserted", "proposed", "disputed", "retracted", "superseded", "derived"] })),
|
|
sources: [],
|
|
evidence: []
|
|
},
|
|
files: []
|
|
});
|
|
}
|