feat(openontology): Phase 2 + Phase 3 — storage, REST/SSE, MCP, RDF/SHACL, adapters, TUI, explorer (#101)
Some checks are pending
CI / build (push) Waiting to run
test / test (push) Waiting to run

Everything the two shipped PRD phases deferred, minus what is called out below.

Storage (Phase 2)
  @logicsrc/openontology gains a SQLite/Turso adapter. It hydrates the read
  model at open, serves reads synchronously — a query evaluator that awaits per
  triple pattern is unusable — and buffers mutations as SQL that flush() writes
  in one transaction. Versioned idempotent migrations; indexes over subject,
  predicate, entity-valued object, status, both time axes, aliases, and external
  ids; FTS5 for label/alias search. The append-only status log is replayed on
  open, so retractions, supersessions, and merge redirects survive a reopen.

REST + SSE + OpenAPI (Phase 2)
  16 paths under /api/ontologies in logicsrc-web, described at
  /api/ontologies/openapi and referencing the published JSON Schemas rather
  than restating them. No token is read-only; a curator token can apply; an
  agent token can propose and cannot apply. Idempotency-Key on mutations,
  revision ETags, 409 on a stale base revision, and an SSE stream that emits
  the same event objects as the JSON endpoint.

MCP (Phase 2)
  OpenOntology and OpenPRD surfaces on the standards server: spec/manifest/
  schema/queries and PRD spec/index as resources, 11 ontology tools and 6 PRD
  tools, 7 prompts. Read-only by default; OPENONTOLOGY_MCP_WRITABLE=1 buys
  proposals, never applies — the denial is the shared policy layer, not a
  second rule that could drift.

Interoperability (Phase 3)
  RDF/Turtle export and import of the reified profile, plus the plain triple
  for asserted relationships so a consumer wanting only the accepted graph gets
  one. SHACL for 5 of 7 constraint kinds; `unique` and `query` are reported as
  unmapped in both the return value and the generated Turtle, because a shape
  that quietly means something narrower is worse than no shape.

Source adapters (Phase 3)
  CSV, JSON, YAML, NDJSON, Markdown, generic JSON HTTP, and GitHub. All produce
  PROPOSED change-set operations with source, evidence selector, run id, and
  confidence attached; fetch is injected so ingestion is offline and testable.
  Each declares its capabilities, so "nothing was deleted upstream" is never
  confused with "this adapter cannot see deletions" — none of the seven can.

TUI + explorer
  Keyboard-first panels (types, entities, claims, sources, queries, change
  sets, validation, audit) as plain strings that survive SSH and 60 columns;
  status is a glyph and a word, never colour alone; the key bar wraps rather
  than truncating. Wired as `logicsrc ontology tui`. A read-only web explorer
  at /openontology/explore with entity and claim views showing status, both
  clocks, confidence, sources, evidence, and append-only history — plus an
  /openprd page for the companion standard.

Bugs found and fixed while testing
  - the API built a new engine per request, so `explain` could never find a
    resultId from a prior request; engines are now cached per role
  - the TUI status bar called engine.validateOntologyPackage(), appending a
    package.validated event on every repaint; it now uses the pure validator

Verification: 76 new tests (527 total across the monorepo, all passing); full
build green; the libSQL adapter is exercised against real files, the API
through its route handlers, and MCP over an in-memory transport.

Not included: PWA review/approval write flows (they need an auth story this
deployment does not have), OWL/RDFS mappings, SPARQL/Cypher/Datalog query
adapters, and Phase 4 governed actions. The compatibility matrix marks those
"planned", not "supported".

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-28 05:10:33 -07:00 committed by GitHub
parent 296775e003
commit da5f6f8381
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 6939 additions and 23 deletions

View file

@ -0,0 +1,223 @@
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import {
createLibsqlStore,
createOntologyEngine,
loadOntologyPackage,
localActor,
proposerActor,
readOnlyActor,
type Actor,
type OntologyEngine
} from "@logicsrc/openontology";
/**
* The hosted OpenOntology reference service.
*
* Storage: Turso/libSQL when TURSO_DATABASE_URL is set, otherwise an in-memory
* store seeded from the fixture package which is what the public deployment
* runs, so the explorer has real data without a database behind it.
*
* Auth: no token means read-only (R101/R104). A bearer token matching
* OPENONTOLOGY_API_TOKEN is a curator; OPENONTOLOGY_AGENT_TOKEN is a proposer
* that can create change sets but never apply them.
*/
const EXAMPLE_DIR = resolve(process.cwd(), "../../examples/openontology/ethereum-ecosystem");
export interface ServiceState {
engine: OntologyEngine | null;
ontologyId: string | null;
persistence: "turso" | "memory" | "none";
error: string | null;
/** Set when a libSQL store needs flushing after writes. */
flush?: () => Promise<unknown>;
}
export type Role = "reader" | "proposer" | "curator";
let cached: Promise<ServiceState> | null = null;
async function build(): Promise<ServiceState> {
const tursoUrl = process.env.TURSO_DATABASE_URL;
if (!existsSync(EXAMPLE_DIR)) {
return {
engine: null,
ontologyId: null,
persistence: "none",
error: `No ontology package found at ${EXAMPLE_DIR}`
};
}
try {
const pkg = loadOntologyPackage(EXAMPLE_DIR);
if (tursoUrl) {
const { createClient } = (await import("@libsql/client")) as typeof import("@libsql/client");
const client = createClient({ url: tursoUrl, authToken: process.env.TURSO_AUTH_TOKEN });
const store = await createLibsqlStore({ client, seed: pkg });
return {
engine: createOntologyEngine({ store, actor: readOnlyActor("service"), client: "logicsrc-web" }),
ontologyId: pkg.manifest.id,
persistence: "turso",
error: null,
flush: () => store.flush()
};
}
return {
engine: createOntologyEngine({
package: pkg,
actor: readOnlyActor("service"),
client: "logicsrc-web"
}),
ontologyId: pkg.manifest.id,
persistence: "memory",
error: null
};
} catch (error) {
return { engine: null, ontologyId: null, persistence: "none", error: (error as Error).message };
}
}
export function getService(): Promise<ServiceState> {
if (!cached) cached = build();
return cached;
}
/** Reset between tests. */
export function resetService(): void {
cached = null;
engines.clear();
}
export function actorFor(request: Request): { actor: Actor; role: Role } {
const header = request.headers.get("authorization") ?? "";
const token = header.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : "";
const curatorToken = process.env.OPENONTOLOGY_API_TOKEN;
const agentToken = process.env.OPENONTOLOGY_AGENT_TOKEN;
if (curatorToken && token && token === curatorToken) {
return { actor: localActor("api:curator"), role: "curator" };
}
if (agentToken && token && token === agentToken) {
return { actor: proposerActor("agent:api"), role: "proposer" };
}
return { actor: readOnlyActor("api:anonymous", "service"), role: "reader" };
}
/**
* One engine per role, all sharing the process-wide store.
*
* Cached rather than rebuilt per request because an engine holds the query
* result cache that `explain` reads: a fresh engine per request would make
* every explain a 404, and would churn event subscriptions on the store.
*/
const engines = new Map<Role, OntologyEngine>();
export async function engineFor(request: Request): Promise<{ engine: OntologyEngine; state: ServiceState } | null> {
const state = await getService();
if (!state.engine) return null;
const { actor, role } = actorFor(request);
const existing = engines.get(role);
if (existing) return { engine: existing, state };
const engine = createOntologyEngine({ store: state.engine.store, actor, client: "logicsrc-web" });
engines.set(role, engine);
return { engine, state };
}
export interface ApiErrorBody {
error: { code: string; message: string; hint?: string };
}
export function apiError(code: string, message: string, status: number, hint?: string): Response {
return Response.json({ error: { code, message, ...(hint ? { hint } : {}) } } satisfies ApiErrorBody, {
status,
headers: { "cache-control": "no-store" }
});
}
export function apiJson(body: unknown, init: { status?: number; revision?: string } = {}): Response {
const headers: Record<string, string> = { "cache-control": "no-store" };
if (init.revision) headers.etag = `"${init.revision}"`;
return Response.json(body, { status: init.status ?? 200, headers });
}
/** Map an engine error onto an HTTP status. */
export function statusForError(error: unknown): { status: number; code: string } {
const code = (error as { code?: string }).code;
switch (code) {
case "OO-A-DENIED":
return { status: 403, code };
case "OO-A-APPROVAL-REQUIRED":
return { status: 409, code };
case "OO-A-NOT-FOUND":
return { status: 404, code };
case "OO-X-CONFLICT":
return { status: 409, code };
case "OO-Q-LIMIT":
return { status: 413, code };
default:
return { status: 400, code: code ?? "OO-E-REQUEST" };
}
}
export async function handle(
request: Request,
ontologyId: string,
work: (engine: OntologyEngine, state: ServiceState) => Promise<Response> | Response
): Promise<Response> {
const bound = await engineFor(request);
if (!bound) {
const state = await getService();
return apiError("OO-E-UNAVAILABLE", state.error ?? "No ontology is loaded", 503);
}
if (bound.state.ontologyId !== ontologyId) {
return apiError("OO-A-NOT-FOUND", `Unknown ontology ${ontologyId}`, 404, `Try ${bound.state.ontologyId}`);
}
try {
return await work(bound.engine, bound.state);
} catch (error) {
const { status, code } = statusForError(error);
return apiError(code, (error as Error).message, status);
}
}
/* ── idempotency ───────────────────────────────────────────────────────── */
const idempotency = new Map<string, { at: number; body: unknown; status: number }>();
const IDEMPOTENCY_TTL_MS = 10 * 60 * 1000;
export function idempotentReplay(request: Request): Response | null {
const key = request.headers.get("idempotency-key");
if (!key) return null;
const entry = idempotency.get(key);
if (!entry) return null;
if (Date.now() - entry.at > IDEMPOTENCY_TTL_MS) {
idempotency.delete(key);
return null;
}
return Response.json(entry.body, {
status: entry.status,
headers: { "cache-control": "no-store", "idempotency-replayed": "true" }
});
}
export function rememberIdempotent(request: Request, body: unknown, status: number): void {
const key = request.headers.get("idempotency-key");
if (!key) return;
idempotency.set(key, { at: Date.now(), body, status });
}
export async function readJson(request: Request): Promise<Record<string, unknown>> {
try {
return ((await request.json()) ?? {}) as Record<string, unknown>;
} catch {
return {};
}
}

View file

@ -126,7 +126,7 @@ export function renderPageMarkup(): string {
<a href="/agentbyte">AgentByte</a>
<a href="/credential-sharing">Credentials</a>
<a href="/openontology">OpenOntology</a>
<a href="/docs/openprd">OpenPRD</a>
<a href="/openprd">OpenPRD</a>
<a href="#cli">CLI</a>
<a href="/docs">Docs</a>
<a href="/blog">Blog</a>
@ -204,7 +204,7 @@ export function renderPageMarkup(): string {
<p>An open contract for durable, source-backed domain knowledge shared by humans and AI agents.</p>
</div>
<p>Define the things in a domain, connect them with typed claims, preserve where each fact came from, and let agents query or propose changes through governed interfaces. Storage-agnostic, model-provider-neutral, and usable with no account.</p>
<p><a class="button-primary" href="/openontology">Read the specification</a></p>
<p><a class="button-primary" href="/openontology">Read the specification</a> <a href="/openontology/explore">Explore the example</a></p>
</div>
<div class="cli-panel">
<h2>Five nouns</h2>