mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-09-10 19:26:00 +00:00
feat(openontology): Phase 2 + Phase 3 — storage, REST/SSE, MCP, RDF/SHACL, adapters, TUI, explorer (#101)
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:
parent
296775e003
commit
da5f6f8381
53 changed files with 6939 additions and 23 deletions
223
apps/logicsrc-web/src/lib/ontology-service.ts
Normal file
223
apps/logicsrc-web/src/lib/ontology-service.ts
Normal 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 {};
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue