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,459 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { GET as listOntologies } from "../src/app/api/ontologies/route";
import { GET as getManifest } from "../src/app/api/ontologies/[ontologyId]/manifest/route";
import { GET as getSchema } from "../src/app/api/ontologies/[ontologyId]/schema/route";
import { GET as listEntities } from "../src/app/api/ontologies/[ontologyId]/entities/route";
import { GET as getEntity } from "../src/app/api/ontologies/[ontologyId]/entities/[entityId]/route";
import { GET as listClaims } from "../src/app/api/ontologies/[ontologyId]/claims/route";
import { GET as getClaim } from "../src/app/api/ontologies/[ontologyId]/claims/[claimId]/route";
import { POST as runQuery } from "../src/app/api/ontologies/[ontologyId]/query/route";
import { POST as explain } from "../src/app/api/ontologies/[ontologyId]/explain/route";
import { POST as validate } from "../src/app/api/ontologies/[ontologyId]/validate/route";
import { GET as listChangeSets, POST as createChangeSet } from "../src/app/api/ontologies/[ontologyId]/changesets/route";
import { POST as approve } from "../src/app/api/ontologies/[ontologyId]/changesets/[changeSetId]/approve/route";
import { POST as apply } from "../src/app/api/ontologies/[ontologyId]/changesets/[changeSetId]/apply/route";
import { GET as events } from "../src/app/api/ontologies/[ontologyId]/events/route";
import { GET as openapi } from "../src/app/api/ontologies/openapi/route";
import { resetService } from "../src/lib/ontology-service";
/**
* Contract tests for the OpenOntology reference API.
*
* They call the route handlers directly, so they exercise the real request
* policy engine response path without needing a listening server.
*/
const ONTOLOGY = "ethereum-ecosystem";
const CURATOR = "curator-token";
const AGENT = "agent-token";
const params = <T extends Record<string, string>>(value: T) => ({ params: Promise.resolve(value) });
function request(
path: string,
init: { method?: string; body?: unknown; token?: string; headers?: Record<string, string> } = {}
): Request {
return new Request(`https://logicsrc.com${path}`, {
method: init.method ?? "GET",
headers: {
"content-type": "application/json",
...(init.token ? { authorization: `Bearer ${init.token}` } : {}),
...(init.headers ?? {})
},
...(init.body === undefined ? {} : { body: JSON.stringify(init.body) })
});
}
async function body<T>(response: Response): Promise<T> {
return (await response.json()) as T;
}
beforeEach(() => {
process.env.OPENONTOLOGY_API_TOKEN = CURATOR;
process.env.OPENONTOLOGY_AGENT_TOKEN = AGENT;
resetService();
});
afterEach(() => {
delete process.env.OPENONTOLOGY_API_TOKEN;
delete process.env.OPENONTOLOGY_AGENT_TOKEN;
resetService();
});
describe("reads", () => {
it("lists the ontologies it serves", async () => {
const response = await listOntologies();
expect(response.status).toBe(200);
const payload = await body<{ ontologies: Array<{ id: string }>; persistence: string }>(response);
expect(payload.ontologies[0]?.id).toBe(ONTOLOGY);
expect(["memory", "turso"]).toContain(payload.persistence);
});
it("serves the manifest with a revision ETag", async () => {
const response = await getManifest(request(`/api/ontologies/${ONTOLOGY}/manifest`), params({ ontologyId: ONTOLOGY }));
expect(response.status).toBe(200);
expect(response.headers.get("etag")).toMatch(/data-\d+/);
expect((await body<{ id: string }>(response)).id).toBe(ONTOLOGY);
});
it("404s an unknown ontology and names one that exists", async () => {
const response = await getManifest(request("/api/ontologies/nope/manifest"), params({ ontologyId: "nope" }));
expect(response.status).toBe(404);
const payload = await body<{ error: { code: string; hint?: string } }>(response);
expect(payload.error.code).toBe("OO-A-NOT-FOUND");
expect(payload.error.hint).toContain(ONTOLOGY);
});
it("serves the schema layer", async () => {
const response = await getSchema(request(`/api/ontologies/${ONTOLOGY}/schema`), params({ ontologyId: ONTOLOGY }));
const payload = await body<{ entityTypes: unknown[]; relationships: unknown[] }>(response);
expect(payload.entityTypes.length).toBeGreaterThanOrEqual(10);
expect(payload.relationships.length).toBeGreaterThanOrEqual(12);
});
it("lists and paginates entities", async () => {
const response = await listEntities(
request(`/api/ontologies/${ONTOLOGY}/entities?type=Person&limit=3`),
params({ ontologyId: ONTOLOGY })
);
const payload = await body<{ total: number; entities: Array<{ type: string }> }>(response);
expect(payload.entities).toHaveLength(3);
expect(payload.entities.every((entity) => entity.type === "Person")).toBe(true);
expect(payload.total).toBeGreaterThan(3);
});
it("returns ranked matches with evidence when searching", async () => {
const response = await listEntities(
request(`/api/ontologies/${ONTOLOGY}/entities?q=Avery`),
params({ ontologyId: ONTOLOGY })
);
const payload = await body<{ matches: Array<{ id: string; matchedOn: string; evidence: string }> }>(response);
expect(payload.matches[0]?.id).toContain("avery");
expect(payload.matches[0]?.matchedOn).toBeTruthy();
});
it("returns an entity with its claims", async () => {
const response = await getEntity(
request(`/api/ontologies/${ONTOLOGY}/entities/eth:person:avery-lindqvist`),
params({ ontologyId: ONTOLOGY, entityId: "eth:person:avery-lindqvist" })
);
const payload = await body<{ entity: { canonicalName: string }; claims: unknown[] }>(response);
expect(payload.entity.canonicalName).toBe("Avery Lindqvist");
expect(payload.claims.length).toBeGreaterThan(0);
});
it("filters claims by status", async () => {
const asserted = await listClaims(
request(`/api/ontologies/${ONTOLOGY}/claims?status=asserted&limit=500`),
params({ ontologyId: ONTOLOGY })
);
const proposed = await listClaims(
request(`/api/ontologies/${ONTOLOGY}/claims?status=proposed&limit=500`),
params({ ontologyId: ONTOLOGY })
);
const a = await body<{ total: number }>(asserted);
const p = await body<{ total: number }>(proposed);
expect(a.total).toBeGreaterThan(p.total);
expect(p.total).toBeGreaterThanOrEqual(1);
});
it("returns a claim with its history, sources, and evidence", async () => {
const list = await listClaims(
request(`/api/ontologies/${ONTOLOGY}/claims?limit=1`),
params({ ontologyId: ONTOLOGY })
);
const { claims } = await body<{ claims: Array<{ id: string }> }>(list);
const id = claims[0]!.id;
const response = await getClaim(
request(`/api/ontologies/${ONTOLOGY}/claims/${id}`),
params({ ontologyId: ONTOLOGY, claimId: id })
);
const payload = await body<{ claim: { id: string }; history: unknown[]; sources: unknown[] }>(response);
expect(payload.claim.id).toBe(id);
expect(payload.history.length).toBeGreaterThanOrEqual(1);
expect(payload.sources.length).toBeGreaterThanOrEqual(1);
});
});
describe("query and explain", () => {
it("runs a saved query and returns claim ids per row", async () => {
const response = await runQuery(
request(`/api/ontologies/${ONTOLOGY}/query`, {
method: "POST",
body: { savedQuery: "orgs-behind-a-network" }
}),
params({ ontologyId: ONTOLOGY })
);
const payload = await body<{ rows: Array<{ claims: string[] }>; resultId: string }>(response);
expect(payload.rows.length).toBeGreaterThan(0);
expect(payload.rows[0]!.claims).toHaveLength(4);
});
it("runs an ad-hoc triple-pattern query", async () => {
const response = await runQuery(
request(`/api/ontologies/${ONTOLOGY}/query`, {
method: "POST",
body: {
query: {
match: [{ subject: "?person", predicate: "worksOn", object: "?project" }],
select: ["?person", "?project"],
include: { claimStatus: ["asserted"] },
limit: 5
}
}
}),
params({ ontologyId: ONTOLOGY })
);
const payload = await body<{ rows: unknown[] }>(response);
expect(payload.rows).toHaveLength(5);
});
it("rejects a query with neither savedQuery nor query", async () => {
const response = await runQuery(
request(`/api/ontologies/${ONTOLOGY}/query`, { method: "POST", body: {} }),
params({ ontologyId: ONTOLOGY })
);
expect(response.status).toBe(422);
});
it("explains a row down to its sources", async () => {
const queried = await runQuery(
request(`/api/ontologies/${ONTOLOGY}/query`, { method: "POST", body: { savedQuery: "funded-work" } }),
params({ ontologyId: ONTOLOGY })
);
const { resultId } = await body<{ resultId: string }>(queried);
const response = await explain(
request(`/api/ontologies/${ONTOLOGY}/explain`, { method: "POST", body: { resultId, row: 0 } }),
params({ ontologyId: ONTOLOGY })
);
const payload = await body<{ claims: Array<{ sources: unknown[] }>; ontology: string }>(response);
expect(payload.ontology).toContain(ONTOLOGY);
expect(payload.claims[0]!.sources.length).toBeGreaterThan(0);
});
it("validates the package", async () => {
const response = await validate(
request(`/api/ontologies/${ONTOLOGY}/validate`, { method: "POST", body: { strict: true } }),
params({ ontologyId: ONTOLOGY })
);
const payload = await body<{ ok: boolean; counts: { error: number } }>(response);
expect(payload.ok).toBe(true);
expect(payload.counts.error).toBe(0);
});
});
describe("governance and permissions", () => {
const operations = [
{
op: "assert-claim",
value: {
subject: "eth:person:avery-lindqvist",
predicate: "worksOn",
object: { entity: "eth:project:docs-portal" },
sources: ["eth:source:roadmap-2026"]
}
}
];
it("denies an anonymous proposal and names the missing scope", async () => {
const response = await createChangeSet(
request(`/api/ontologies/${ONTOLOGY}/changesets`, { method: "POST", body: { title: "x", operations } }),
params({ ontologyId: ONTOLOGY })
);
expect(response.status).toBe(403);
const payload = await body<{ error: { code: string; message: string } }>(response);
expect(payload.error.code).toBe("OO-A-DENIED");
expect(payload.error.message).toContain("ontology:claim:propose");
});
it("lets an agent token propose but never apply", async () => {
const created = await createChangeSet(
request(`/api/ontologies/${ONTOLOGY}/changesets`, {
method: "POST",
token: AGENT,
body: { title: "agent proposal", operations, runId: "run_api_1" }
}),
params({ ontologyId: ONTOLOGY })
);
expect(created.status).toBe(201);
const { changeSet } = await body<{ changeSet: { id: string; status: string } }>(created);
expect(changeSet.status).toBe("proposed");
const applied = await apply(
request(`/api/ontologies/${ONTOLOGY}/changesets/${changeSet.id}/apply`, { method: "POST", token: AGENT }),
params({ ontologyId: ONTOLOGY, changeSetId: changeSet.id })
);
expect(applied.status).toBe(403);
expect((await body<{ error: { message: string } }>(applied)).error.message).toMatch(/never apply directly/);
});
it("returns the semantic diff alongside a proposal", async () => {
const created = await createChangeSet(
request(`/api/ontologies/${ONTOLOGY}/changesets`, {
method: "POST",
token: CURATOR,
body: { title: "with diff", operations }
}),
params({ ontologyId: ONTOLOGY })
);
const payload = await body<{ diff: { summary: { claimsAdded: number } } }>(created);
expect(payload.diff.summary.claimsAdded).toBe(1);
});
it("runs the curator loop: propose → approve → apply", async () => {
const created = await createChangeSet(
request(`/api/ontologies/${ONTOLOGY}/changesets`, {
method: "POST",
token: CURATOR,
body: { title: "curator loop", operations }
}),
params({ ontologyId: ONTOLOGY })
);
const { changeSet } = await body<{ changeSet: { id: string } }>(created);
const approved = await approve(
request(`/api/ontologies/${ONTOLOGY}/changesets/${changeSet.id}/approve`, { method: "POST", token: CURATOR }),
params({ ontologyId: ONTOLOGY, changeSetId: changeSet.id })
);
expect(approved.status).toBe(201);
const applied = await apply(
request(`/api/ontologies/${ONTOLOGY}/changesets/${changeSet.id}/apply`, { method: "POST", token: CURATOR }),
params({ ontologyId: ONTOLOGY, changeSetId: changeSet.id })
);
expect(applied.status).toBe(200);
const payload = await body<{ revision: string; addedClaims: string[] }>(applied);
expect(payload.revision).toMatch(/^data-\d+$/);
expect(payload.addedClaims).toHaveLength(1);
});
it("requires approval for a merge, then allows it", async () => {
const mergeOps = [
{ op: "merge-entity", source: "eth:person:s-haddad", target: "eth:person:samir-haddad" }
];
const created = await createChangeSet(
request(`/api/ontologies/${ONTOLOGY}/changesets`, {
method: "POST",
token: CURATOR,
body: { title: "merge", operations: mergeOps }
}),
params({ ontologyId: ONTOLOGY })
);
const { changeSet } = await body<{ changeSet: { id: string } }>(created);
const tooSoon = await apply(
request(`/api/ontologies/${ONTOLOGY}/changesets/${changeSet.id}/apply`, { method: "POST", token: CURATOR }),
params({ ontologyId: ONTOLOGY, changeSetId: changeSet.id })
);
expect(tooSoon.status).toBe(409);
await approve(
request(`/api/ontologies/${ONTOLOGY}/changesets/${changeSet.id}/approve`, { method: "POST", token: CURATOR }),
params({ ontologyId: ONTOLOGY, changeSetId: changeSet.id })
);
const applied = await apply(
request(`/api/ontologies/${ONTOLOGY}/changesets/${changeSet.id}/apply`, { method: "POST", token: CURATOR }),
params({ ontologyId: ONTOLOGY, changeSetId: changeSet.id })
);
expect(applied.status).toBe(200);
});
it("replays a POST carrying the same Idempotency-Key", async () => {
const key = "idem-1";
const first = await createChangeSet(
request(`/api/ontologies/${ONTOLOGY}/changesets`, {
method: "POST",
token: CURATOR,
headers: { "idempotency-key": key },
body: { title: "idempotent", operations }
}),
params({ ontologyId: ONTOLOGY })
);
const second = await createChangeSet(
request(`/api/ontologies/${ONTOLOGY}/changesets`, {
method: "POST",
token: CURATOR,
headers: { "idempotency-key": key },
body: { title: "idempotent", operations }
}),
params({ ontologyId: ONTOLOGY })
);
expect(second.headers.get("idempotency-replayed")).toBe("true");
const a = await body<{ changeSet: { id: string } }>(first);
const b = await body<{ changeSet: { id: string } }>(second);
expect(b.changeSet.id).toBe(a.changeSet.id);
});
it("rejects a change set with no operations", async () => {
const response = await createChangeSet(
request(`/api/ontologies/${ONTOLOGY}/changesets`, {
method: "POST",
token: CURATOR,
body: { title: "empty", operations: [] }
}),
params({ ontologyId: ONTOLOGY })
);
expect(response.status).toBe(422);
});
it("lists change sets with their approval counts", async () => {
await createChangeSet(
request(`/api/ontologies/${ONTOLOGY}/changesets`, {
method: "POST",
token: CURATOR,
body: { title: "listed", operations }
}),
params({ ontologyId: ONTOLOGY })
);
const response = await listChangeSets(
request(`/api/ontologies/${ONTOLOGY}/changesets`),
params({ ontologyId: ONTOLOGY })
);
const payload = await body<{ changeSets: Array<{ title: string; approvals: number }> }>(response);
expect(payload.changeSets.some((entry) => entry.title === "listed")).toBe(true);
});
});
describe("events", () => {
it("returns the event log as JSON", async () => {
const response = await events(
request(`/api/ontologies/${ONTOLOGY}/events?limit=10`),
params({ ontologyId: ONTOLOGY })
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("application/json");
});
it("streams Server-Sent Events when asked", async () => {
const controller = new AbortController();
const response = await events(
new Request(`https://logicsrc.com/api/ontologies/${ONTOLOGY}/events`, {
headers: { accept: "text/event-stream" },
signal: controller.signal
}),
params({ ontologyId: ONTOLOGY })
);
expect(response.headers.get("content-type")).toBe("text/event-stream");
const reader = response.body!.getReader();
const { value } = await reader.read();
const text = new TextDecoder().decode(value);
expect(text).toContain("event: ");
controller.abort();
await reader.cancel();
});
});
describe("openapi", () => {
it("describes every implemented path and points at the published schemas", async () => {
const response = await openapi();
const document = await body<{ openapi: string; paths: Record<string, unknown> }>(response);
expect(document.openapi).toBe("3.1.0");
for (const path of [
"/ontologies",
"/ontologies/{ontologyId}/manifest",
"/ontologies/{ontologyId}/schema",
"/ontologies/{ontologyId}/entities",
"/ontologies/{ontologyId}/entities/{entityId}",
"/ontologies/{ontologyId}/claims",
"/ontologies/{ontologyId}/claims/{claimId}",
"/ontologies/{ontologyId}/query",
"/ontologies/{ontologyId}/explain",
"/ontologies/{ontologyId}/validate",
"/ontologies/{ontologyId}/changesets",
"/ontologies/{ontologyId}/changesets/{changeSetId}",
"/ontologies/{ontologyId}/changesets/{changeSetId}/review",
"/ontologies/{ontologyId}/changesets/{changeSetId}/approve",
"/ontologies/{ontologyId}/changesets/{changeSetId}/apply",
"/ontologies/{ontologyId}/events"
]) {
expect(document.paths, `missing ${path}`).toHaveProperty([path]);
}
expect(JSON.stringify(document)).toContain("https://logicsrc.com/schemas/openontology/claim.schema.json");
});
});

View file

@ -12,6 +12,8 @@
"test:e2e": "playwright test"
},
"dependencies": {
"@logicsrc/openontology": "file:../../packages/openontology",
"@logicsrc/openprd": "file:../../packages/openprd",
"@profullstack/autoblog": "github:profullstack/autoblog#75e54af",
"@profullstack/stack": "^0.1.3",
"@supabase/supabase-js": "^2.105.4",

View file

@ -0,0 +1,40 @@
import {
handle,
apiJson,
readJson,
idempotentReplay,
rememberIdempotent
} from "@/lib/ontology-service";
export const dynamic = "force-dynamic";
export async function POST(
request: Request,
{ params }: { params: Promise<{ ontologyId: string; changeSetId: string }> }
) {
const { ontologyId, changeSetId } = await params;
const id = decodeURIComponent(changeSetId);
const replay = idempotentReplay(request);
if (replay) return replay;
const body = await readJson(request);
return handle(request, ontologyId, async (engine, serviceState) => {
const applied = engine.applyOntologyChangeSet(id, {
skipRejectedOperations: body.skipRejectedOperations === true
});
// libSQL buffers writes; make them durable before we report success.
if (serviceState.flush) await serviceState.flush();
const payload = {
changeSet: applied.changeSet.id,
revision: applied.revision,
addedEntities: applied.addedEntities,
addedClaims: applied.addedClaims,
statusChanges: applied.statusChanges,
skipped: applied.skipped,
events: applied.events.map((event) => ({ id: event.id, type: event.type }))
};
rememberIdempotent(request, payload, 200);
return apiJson(payload, { revision: applied.revision });
});
}

View file

@ -0,0 +1,29 @@
import {
handle,
apiJson,
readJson,
idempotentReplay,
rememberIdempotent
} from "@/lib/ontology-service";
export const dynamic = "force-dynamic";
export async function POST(
request: Request,
{ params }: { params: Promise<{ ontologyId: string; changeSetId: string }> }
) {
const { ontologyId, changeSetId } = await params;
const id = decodeURIComponent(changeSetId);
const replay = idempotentReplay(request);
if (replay) return replay;
const body = await readJson(request);
return handle(request, ontologyId, async (engine, serviceState) => {
const approval = engine.approveOntologyChangeSet(id, {
comment: typeof body.comment === "string" ? body.comment : undefined
});
rememberIdempotent(request, { approval }, 201);
return apiJson({ approval }, { status: 201 });
});
}

View file

@ -0,0 +1,32 @@
import {
handle,
apiJson,
readJson,
idempotentReplay,
rememberIdempotent
} from "@/lib/ontology-service";
export const dynamic = "force-dynamic";
export async function POST(
request: Request,
{ params }: { params: Promise<{ ontologyId: string; changeSetId: string }> }
) {
const { ontologyId, changeSetId } = await params;
const id = decodeURIComponent(changeSetId);
const replay = idempotentReplay(request);
if (replay) return replay;
const body = await readJson(request);
return handle(request, ontologyId, async (engine, serviceState) => {
const state = typeof body.state === "string" ? body.state : "commented";
const review = engine.reviewOntologyChangeSet(id, {
state: state as never,
comment: typeof body.comment === "string" ? body.comment : undefined,
operationDecisions: body.operationDecisions as never
});
rememberIdempotent(request, { review }, 201);
return apiJson({ review }, { status: 201 });
});
}

View file

@ -0,0 +1,23 @@
import { handle, apiJson, apiError } from "@/lib/ontology-service";
export const dynamic = "force-dynamic";
export async function GET(
request: Request,
{ params }: { params: Promise<{ ontologyId: string; changeSetId: string }> }
) {
const { ontologyId, changeSetId } = await params;
const id = decodeURIComponent(changeSetId);
return handle(request, ontologyId, (engine) => {
const changeSet = engine.store.getChangeSet(id);
if (!changeSet) return apiError("OO-A-NOT-FOUND", `Unknown change set ${id}`, 404);
return apiJson({
changeSet,
diff: engine.diffOntologyChangeSet(id),
reviews: engine.store.listReviews(id),
approvals: engine.store.listApprovals(id),
events: engine.listEvents({ changeSet: id })
});
});
}

View file

@ -0,0 +1,54 @@
import {
handle,
apiJson,
apiError,
readJson,
idempotentReplay,
rememberIdempotent
} from "@/lib/ontology-service";
export const dynamic = "force-dynamic";
export async function GET(request: Request, { params }: { params: Promise<{ ontologyId: string }> }) {
const { ontologyId } = await params;
return handle(request, ontologyId, (engine) =>
apiJson({
changeSets: engine.store.listChangeSets().map((changeSet) => ({
id: changeSet.id,
title: changeSet.title,
status: changeSet.status,
createdBy: changeSet.createdBy,
createdAt: changeSet.createdAt,
operations: changeSet.operations.length,
requiredApprovals: changeSet.requiredApprovals ?? 0,
approvals: engine.store.listApprovals(changeSet.id).length
}))
})
);
}
// POST — create a PROPOSED change set. Requires ontology:claim:propose.
export async function POST(request: Request, { params }: { params: Promise<{ ontologyId: string }> }) {
const { ontologyId } = await params;
const replay = idempotentReplay(request);
if (replay) return replay;
const body = await readJson(request);
return handle(request, ontologyId, (engine) => {
const title = typeof body.title === "string" ? body.title : null;
const operations = Array.isArray(body.operations) ? body.operations : null;
if (!title || !operations || operations.length === 0) {
return apiError("OO-E-REQUEST", "title and a non-empty operations array are required", 422);
}
const changeSet = engine.createOntologyChangeSet({
title,
rationale: typeof body.rationale === "string" ? body.rationale : undefined,
runId: typeof body.runId === "string" ? body.runId : undefined,
operations: operations as never
});
const payload = { changeSet, diff: engine.diffOntologyChangeSet(changeSet.id) };
rememberIdempotent(request, payload, 201);
return apiJson(payload, { status: 201, revision: engine.store.revision() });
});
}

View file

@ -0,0 +1,21 @@
import { handle, apiJson } from "@/lib/ontology-service";
export const dynamic = "force-dynamic";
export async function GET(
request: Request,
{ params }: { params: Promise<{ ontologyId: string; claimId: string }> }
) {
const { ontologyId, claimId } = await params;
const id = decodeURIComponent(claimId);
return handle(request, ontologyId, (engine) => {
const claim = engine.getClaim(id);
return apiJson({
claim,
history: engine.claimHistory(id),
sources: (claim.sources ?? []).map((sourceId) => engine.store.getSource(sourceId)).filter(Boolean),
evidence: (claim.evidence ?? []).map((evidenceId) => engine.store.getEvidence(evidenceId)).filter(Boolean)
});
});
}

View file

@ -0,0 +1,19 @@
import { handle, apiJson } from "@/lib/ontology-service";
export const dynamic = "force-dynamic";
export async function GET(request: Request, { params }: { params: Promise<{ ontologyId: string }> }) {
const { ontologyId } = await params;
const url = new URL(request.url);
const statusParam = url.searchParams.get("status") ?? "asserted";
const limit = Math.min(Number(url.searchParams.get("limit") ?? 100), 500);
return handle(request, ontologyId, (engine) => {
const claims = engine.store.listClaims({
subject: url.searchParams.get("subject") ?? undefined,
predicate: url.searchParams.get("predicate") ?? undefined,
status: statusParam.split(",").map((value) => value.trim()) as never
});
return apiJson({ total: claims.length, limit, claims: claims.slice(0, limit) });
});
}

View file

@ -0,0 +1,33 @@
import { handle, apiJson } from "@/lib/ontology-service";
export const dynamic = "force-dynamic";
export async function GET(
request: Request,
{ params }: { params: Promise<{ ontologyId: string; entityId: string }> }
) {
const { ontologyId, entityId } = await params;
const id = decodeURIComponent(entityId);
return handle(request, ontologyId, (engine) => {
const entity = engine.getEntity(id);
const claims = engine.store.listClaims({ subject: entity.id });
return apiJson(
{
entity,
// The old id still resolves after a merge; say so rather than 404ing.
redirectedFrom: entity.id === id ? undefined : id,
claims: claims.map((claim) => ({
id: claim.id,
predicate: claim.predicate,
object: claim.object,
status: claim.status,
confidence: claim.confidence,
validTime: claim.validTime,
sources: claim.sources ?? []
}))
},
{ revision: engine.store.revision() }
);
});
}

View file

@ -0,0 +1,46 @@
import { handle, apiJson } from "@/lib/ontology-service";
export const dynamic = "force-dynamic";
// GET /api/ontologies/{id}/entities?type=&q=&limit=&offset=
export async function GET(request: Request, { params }: { params: Promise<{ ontologyId: string }> }) {
const { ontologyId } = await params;
const url = new URL(request.url);
const type = url.searchParams.get("type") ?? undefined;
const q = url.searchParams.get("q") ?? undefined;
const limit = Math.min(Number(url.searchParams.get("limit") ?? 50), 200);
const offset = Math.max(Number(url.searchParams.get("offset") ?? 0), 0);
return handle(request, ontologyId, (engine) => {
if (q) {
const matches = engine.findEntities({ text: q, type, limit });
return apiJson({
total: matches.length,
matches: matches.map((match) => ({
id: match.entity.id,
type: match.entity.type,
canonicalName: match.entity.canonicalName,
status: match.entity.status ?? "active",
score: match.score,
matchedOn: match.matchedOn,
evidence: match.evidence
}))
});
}
const all = engine.store.listEntities({ type });
return apiJson({
total: all.length,
limit,
offset,
entities: all.slice(offset, offset + limit).map((entity) => ({
id: entity.id,
type: entity.type,
canonicalName: entity.canonicalName,
status: entity.status ?? "active",
aliases: entity.aliases ?? [],
externalIds: entity.externalIds ?? {}
}))
});
});
}

View file

@ -0,0 +1,67 @@
import { getService, apiError, apiJson, engineFor } from "@/lib/ontology-service";
export const dynamic = "force-dynamic";
/**
* GET /api/ontologies/{id}/events
*
* Returns the event log as JSON, or a live SSE stream when the client asks for
* text/event-stream. Same event objects either way the transport does not
* change the schema.
*/
export async function GET(request: Request, { params }: { params: Promise<{ ontologyId: string }> }) {
const { ontologyId } = await params;
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);
}
const url = new URL(request.url);
const wantsStream =
(request.headers.get("accept") ?? "").includes("text/event-stream") || url.searchParams.get("stream") === "1";
const engine = bound.engine;
if (!wantsStream) {
const limit = Math.min(Number(url.searchParams.get("limit") ?? 100), 500);
return apiJson({ events: engine.listEvents({ limit }) });
}
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
const send = (event: unknown, type: string) => {
controller.enqueue(encoder.encode(`event: ${type}\ndata: ${JSON.stringify(event)}\n\n`));
};
// Replay recent history so a late subscriber is not blind to it.
for (const event of engine.listEvents({ limit: 20 })) send(event, event.type);
send({ ontology: ontologyId, revision: engine.store.revision() }, "ready");
const unsubscribe = engine.subscribeOntologyEvents((event) => send(event, event.type));
const keepAlive = setInterval(() => controller.enqueue(encoder.encode(": keep-alive\n\n")), 15000);
request.signal.addEventListener("abort", () => {
clearInterval(keepAlive);
unsubscribe();
try {
controller.close();
} catch {
// already closed
}
});
}
});
return new Response(stream, {
headers: {
"content-type": "text/event-stream",
"cache-control": "no-store, no-transform",
connection: "keep-alive"
}
});
}

View file

@ -0,0 +1,14 @@
import { handle, apiJson, apiError, readJson } from "@/lib/ontology-service";
export const dynamic = "force-dynamic";
export async function POST(request: Request, { params }: { params: Promise<{ ontologyId: string }> }) {
const { ontologyId } = await params;
const body = await readJson(request);
return handle(request, ontologyId, (engine) => {
const resultId = typeof body.resultId === "string" ? body.resultId : null;
if (!resultId) return apiError("OO-E-REQUEST", "resultId is required", 422);
return apiJson(engine.explainOntologyResult(resultId, Number(body.row ?? 0)));
});
}

View file

@ -0,0 +1,10 @@
import { handle, apiJson } from "@/lib/ontology-service";
export const dynamic = "force-dynamic";
export async function GET(request: Request, { params }: { params: Promise<{ ontologyId: string }> }) {
const { ontologyId } = await params;
return handle(request, ontologyId, (engine) =>
apiJson(engine.getOntologyManifest(), { revision: engine.store.revision() })
);
}

View file

@ -0,0 +1,32 @@
import { handle, apiJson, apiError, readJson } from "@/lib/ontology-service";
export const dynamic = "force-dynamic";
// POST /api/ontologies/{id}/query — portable triple-pattern query.
export async function POST(request: Request, { params }: { params: Promise<{ ontologyId: string }> }) {
const { ontologyId } = await params;
const body = await readJson(request);
return handle(request, ontologyId, (engine) => {
const saved = typeof body.savedQuery === "string" ? body.savedQuery : null;
const query = body.query as Record<string, unknown> | undefined;
if (!saved && !query) {
return apiError("OO-E-REQUEST", "Provide savedQuery or query", 422);
}
const result = engine.queryOntology(
(saved ?? query) as never,
(body.params as Record<string, unknown>) ?? undefined
);
return apiJson(
{
resultId: result.id,
columns: result.columns,
rows: result.rows.map((row) => ({ ...row.bindings, claims: row.claims })),
explanation: result.explanation
},
{ revision: engine.store.revision() }
);
});
}

View file

@ -0,0 +1,10 @@
import { handle, apiJson } from "@/lib/ontology-service";
export const dynamic = "force-dynamic";
export async function GET(request: Request, { params }: { params: Promise<{ ontologyId: string }> }) {
const { ontologyId } = await params;
return handle(request, ontologyId, (engine) =>
apiJson(engine.getOntologySchema(), { revision: engine.store.revision() })
);
}

View file

@ -0,0 +1,12 @@
import { handle, apiJson, readJson } from "@/lib/ontology-service";
export const dynamic = "force-dynamic";
export async function POST(request: Request, { params }: { params: Promise<{ ontologyId: string }> }) {
const { ontologyId } = await params;
const body = await readJson(request);
return handle(request, ontologyId, (engine) =>
apiJson(engine.validateOntologyPackage({ strict: body.strict === true }))
);
}

View file

@ -0,0 +1,371 @@
import { getService } from "@/lib/ontology-service";
export const dynamic = "force-dynamic";
/**
* OpenAPI description of the OpenOntology reference API.
*
* Component schemas point at the published JSON Schemas rather than restating
* them, so the API, the packages, and the SDK cannot drift apart (R137).
*/
export async function GET() {
const state = await getService();
const ontologyId = state.ontologyId ?? "{ontologyId}";
const schemaBase = "https://logicsrc.com/schemas/openontology";
const ontologyParam = {
name: "ontologyId",
in: "path",
required: true,
schema: { type: "string" },
example: ontologyId
};
const json = (ref: string) => ({
content: { "application/json": { schema: { $ref: ref } } }
});
const errorResponse = {
description: "Structured error",
content: {
"application/json": {
schema: {
type: "object",
properties: {
error: {
type: "object",
required: ["code", "message"],
properties: {
code: { type: "string", example: "OO-A-DENIED" },
message: { type: "string" },
hint: { type: "string" }
}
}
}
}
}
}
};
const document = {
openapi: "3.1.0",
info: {
title: "LogicSRC OpenOntology reference API",
version: "0.1.0",
description: [
"Reference implementation of the LogicSRC OpenOntology standard.",
"",
"**Auth.** No token is read-only. A bearer token matching OPENONTOLOGY_API_TOKEN acts as a",
"curator; OPENONTOLOGY_AGENT_TOKEN acts as a proposer that can create change sets but can",
"never apply them — that denial keys on actor type, not on scopes.",
"",
"**Writes.** Every mutation goes through a change set: propose, review, approve, apply.",
"Mutating requests accept an Idempotency-Key header. Applying a change set authored against",
"a stale revision fails with 409 rather than overwriting.",
"",
`**Storage.** ${
state.persistence === "turso"
? "Turso/libSQL."
: "In-memory, seeded from the example package: proposals do not survive a restart."
}`
].join("\n"),
license: { name: "MIT" }
},
servers: [{ url: "/api", description: "This deployment" }],
tags: [
{ name: "ontologies" },
{ name: "knowledge" },
{ name: "query" },
{ name: "governance" },
{ name: "events" }
],
paths: {
"/ontologies": {
get: {
tags: ["ontologies"],
summary: "List ontologies",
responses: { "200": { description: "Ontologies", ...json(`${schemaBase}/manifest.schema.json`) } }
}
},
"/ontologies/{ontologyId}/manifest": {
get: {
tags: ["ontologies"],
summary: "Package manifest",
parameters: [ontologyParam],
responses: {
"200": { description: "Manifest", ...json(`${schemaBase}/manifest.schema.json`) },
"404": errorResponse
}
}
},
"/ontologies/{ontologyId}/schema": {
get: {
tags: ["ontologies"],
summary: "Entity types, properties, relationships, constraints, saved queries",
parameters: [ontologyParam],
responses: { "200": { description: "Schema layer" }, "404": errorResponse }
}
},
"/ontologies/{ontologyId}/entities": {
get: {
tags: ["knowledge"],
summary: "List or search entities",
description: "With ?q= this returns ranked candidates and the evidence for each match.",
parameters: [
ontologyParam,
{ name: "type", in: "query", schema: { type: "string" } },
{ name: "q", in: "query", schema: { type: "string" } },
{ name: "limit", in: "query", schema: { type: "integer", maximum: 200, default: 50 } },
{ name: "offset", in: "query", schema: { type: "integer", default: 0 } }
],
responses: { "200": { description: "Entities" }, "404": errorResponse }
}
},
"/ontologies/{ontologyId}/entities/{entityId}": {
get: {
tags: ["knowledge"],
summary: "One entity and its claims",
description: "A merged-away id still resolves; the response reports redirectedFrom.",
parameters: [ontologyParam, { name: "entityId", in: "path", required: true, schema: { type: "string" } }],
responses: {
"200": { description: "Entity", ...json(`${schemaBase}/entity.schema.json`) },
"404": errorResponse
}
}
},
"/ontologies/{ontologyId}/claims": {
get: {
tags: ["knowledge"],
summary: "List claims",
parameters: [
ontologyParam,
{ name: "subject", in: "query", schema: { type: "string" } },
{ name: "predicate", in: "query", schema: { type: "string" } },
{
name: "status",
in: "query",
description: "Comma-separated claim statuses.",
schema: { type: "string", default: "asserted" }
},
{ name: "limit", in: "query", schema: { type: "integer", maximum: 500, default: 100 } }
],
responses: { "200": { description: "Claims", ...json(`${schemaBase}/claim.schema.json`) } }
}
},
"/ontologies/{ontologyId}/claims/{claimId}": {
get: {
tags: ["knowledge"],
summary: "One claim with its history, sources, and evidence",
parameters: [ontologyParam, { name: "claimId", in: "path", required: true, schema: { type: "string" } }],
responses: {
"200": { description: "Claim", ...json(`${schemaBase}/claim.schema.json`) },
"404": errorResponse
}
}
},
"/ontologies/{ontologyId}/query": {
post: {
tags: ["query"],
summary: "Run a portable triple-pattern query",
parameters: [ontologyParam],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: {
savedQuery: { type: "string" },
query: { $ref: `${schemaBase}/query.schema.json` },
params: { type: "object" }
}
},
examples: {
saved: { value: { savedQuery: "people-working-on-topic" } },
adHoc: {
value: {
query: {
match: [{ subject: "?person", predicate: "worksOn", object: "?project" }],
select: ["?person", "?project"],
include: { claimStatus: ["asserted"] }
}
}
}
}
}
}
},
responses: {
"200": { description: "Rows, each carrying the claim ids behind it" },
"413": { ...errorResponse, description: "Query exceeded a server-side limit" },
"422": errorResponse
}
}
},
"/ontologies/{ontologyId}/explain": {
post: {
tags: ["query"],
summary: "Explain one result row",
description: "Answer → claims → evidence → sources, plus the filters that were applied.",
parameters: [ontologyParam],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["resultId"],
properties: { resultId: { type: "string" }, row: { type: "integer", default: 0 } }
}
}
}
},
responses: { "200": { description: "Explanation" }, "404": errorResponse }
}
},
"/ontologies/{ontologyId}/validate": {
post: {
tags: ["ontologies"],
summary: "Validate the package",
parameters: [ontologyParam],
requestBody: {
content: {
"application/json": { schema: { type: "object", properties: { strict: { type: "boolean" } } } }
}
},
responses: { "200": { description: "Validation report" } }
}
},
"/ontologies/{ontologyId}/changesets": {
get: {
tags: ["governance"],
summary: "List change sets",
parameters: [ontologyParam],
responses: { "200": { description: "Change sets" } }
},
post: {
tags: ["governance"],
summary: "Propose a change set",
description: "Creates a PROPOSED change set. Requires ontology:claim:propose.",
parameters: [
ontologyParam,
{ name: "Idempotency-Key", in: "header", schema: { type: "string" } }
],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["title", "operations"],
properties: {
title: { type: "string" },
rationale: { type: "string" },
runId: { type: "string" },
operations: { type: "array", items: { type: "object" } }
}
}
}
}
},
responses: {
"201": { description: "Proposed change set and its semantic diff" },
"403": { ...errorResponse, description: "Missing ontology:claim:propose" },
"422": errorResponse
}
}
},
"/ontologies/{ontologyId}/changesets/{changeSetId}": {
get: {
tags: ["governance"],
summary: "One change set with its diff, reviews, approvals, and events",
parameters: [ontologyParam, { name: "changeSetId", in: "path", required: true, schema: { type: "string" } }],
responses: { "200": { description: "Change set" }, "404": errorResponse }
}
},
"/ontologies/{ontologyId}/changesets/{changeSetId}/review": {
post: {
tags: ["governance"],
summary: "Review a change set",
parameters: [ontologyParam, { name: "changeSetId", in: "path", required: true, schema: { type: "string" } }],
requestBody: {
content: {
"application/json": {
schema: {
type: "object",
properties: {
state: { type: "string", enum: ["commented", "changes-requested", "approved", "rejected"] },
comment: { type: "string" },
operationDecisions: { type: "array", items: { type: "object" } }
}
}
}
}
},
responses: { "201": { description: "Review" }, "403": errorResponse }
}
},
"/ontologies/{ontologyId}/changesets/{changeSetId}/approve": {
post: {
tags: ["governance"],
summary: "Approve a change set",
parameters: [ontologyParam, { name: "changeSetId", in: "path", required: true, schema: { type: "string" } }],
responses: { "201": { description: "Approval" }, "403": errorResponse }
}
},
"/ontologies/{ontologyId}/changesets/{changeSetId}/apply": {
post: {
tags: ["governance"],
summary: "Apply an approved change set",
description:
"Requires ontology:claim:write and any approvals policy demands. Agent actors are denied outright.",
parameters: [
ontologyParam,
{ name: "changeSetId", in: "path", required: true, schema: { type: "string" } },
{ name: "Idempotency-Key", in: "header", schema: { type: "string" } }
],
responses: {
"200": { description: "Applied; returns the resulting revision and events" },
"403": { ...errorResponse, description: "Denied by policy" },
"409": { ...errorResponse, description: "Approval required, or the base revision is stale" }
}
}
},
"/ontologies/{ontologyId}/events": {
get: {
tags: ["events"],
summary: "Event log, or a live SSE stream",
description:
"Send Accept: text/event-stream (or ?stream=1) for Server-Sent Events. The event objects are identical either way.",
parameters: [
ontologyParam,
{ name: "limit", in: "query", schema: { type: "integer", maximum: 500, default: 100 } }
],
responses: {
"200": {
description: "Events",
content: {
"application/json": { schema: { $ref: `${schemaBase}/event.schema.json` } },
"text/event-stream": { schema: { type: "string" } }
}
}
}
}
}
},
components: {
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
description: "Curator or proposer token. Omit for read-only access."
}
}
},
security: [{}, { bearerAuth: [] }]
};
return Response.json(document, {
headers: { "cache-control": "public, max-age=300" }
});
}

View file

@ -0,0 +1,31 @@
import { getService, apiJson, apiError } from "@/lib/ontology-service";
export const dynamic = "force-dynamic";
// GET /api/ontologies — the ontologies this reference service holds.
export async function GET() {
const state = await getService();
if (!state.engine) return apiError("OO-E-UNAVAILABLE", state.error ?? "No ontology is loaded", 503);
const manifest = state.engine.getOntologyManifest();
return apiJson(
{
ontologies: [
{
id: manifest.id,
name: manifest.name,
version: manifest.version,
namespace: manifest.namespace,
license: manifest.license,
revision: state.engine.store.revision()
}
],
persistence: state.persistence,
note:
state.persistence === "memory"
? "In-memory reference service seeded from the example package; proposals do not survive a restart."
: undefined
},
{ revision: state.engine.store.revision() }
);
}

View file

@ -0,0 +1,219 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { getService } from "@/lib/ontology-service";
import { SiteShell } from "@/components/site-shell";
import { Confidence, StatusBadge, formatObject, mono, table, td, th } from "../../../ui";
export const dynamic = "force-dynamic";
export async function generateMetadata({
params
}: {
params: Promise<{ claimId: string }>;
}): Promise<Metadata> {
const { claimId } = await params;
return {
title: `${decodeURIComponent(claimId)} · OpenOntology · LogicSRC`,
description: "A single OpenOntology claim, with the sources, evidence, and history behind it."
};
}
export default async function ClaimPage({
params
}: {
params: Promise<{ claimId: string }>;
}): Promise<ReactNode> {
const { claimId } = await params;
const id = decodeURIComponent(claimId);
const state = await getService();
if (!state.engine) notFound();
const engine = state.engine;
const claim = engine.store.getClaim(id);
if (!claim) notFound();
const manifest = engine.getOntologyManifest();
const subject = engine.store.getEntity(claim.subject);
const object = "entity" in claim.object ? engine.store.getEntity(claim.object.entity) : null;
const sources = (claim.sources ?? []).map((sourceId) => engine.store.getSource(sourceId)).filter(Boolean);
const evidence = (claim.evidence ?? []).map((evidenceId) => engine.store.getEvidence(evidenceId)).filter(Boolean);
const history = engine.store.claimHistory(claim.id);
return (
<SiteShell active="OpenOntology">
<div className="band">
<p style={{ marginBottom: "1rem" }}>
<Link href="/openontology/explore" style={{ color: "#5b6b7a", textDecoration: "none" }}>
Explorer
</Link>
</p>
<div className="section-head">
<p className="eyebrow">Claim</p>
<h2 style={{ fontSize: "1.5rem" }}>
{subject ? (
<Link href={`/openontology/explore/entity/${encodeURIComponent(claim.subject)}`}>
{subject.canonicalName}
</Link>
) : (
claim.subject
)}{" "}
<span style={{ color: "#5b6b7a" }}>{claim.predicate}</span>{" "}
{object ? (
<Link href={`/openontology/explore/entity/${encodeURIComponent(object.id)}`}>
{object.canonicalName}
</Link>
) : (
formatObject(claim.object)
)}
</h2>
</div>
<dl style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 1rem", margin: 0 }}>
<dt style={{ color: "#5b6b7a" }}>Status</dt>
<dd style={{ margin: 0 }}>
<StatusBadge status={claim.status} />
</dd>
<dt style={{ color: "#5b6b7a" }}>Confidence</dt>
<dd style={{ margin: 0 }}>
<Confidence value={claim.confidence} />
</dd>
<dt style={{ color: "#5b6b7a" }}>Valid time</dt>
<dd style={{ margin: 0 }}>
{claim.validTime?.from ? `${claim.validTime.from.slice(0, 10)}${claim.validTime.to?.slice(0, 10) ?? "present"}` : "not stated"}
<span style={{ color: "#8a949e" }}> (when it was true in the world)</span>
</dd>
<dt style={{ color: "#5b6b7a" }}>Recorded</dt>
<dd style={{ margin: 0 }}>
{claim.assertedAt} <span style={{ color: "#8a949e" }}>(when the system learned it)</span>
</dd>
<dt style={{ color: "#5b6b7a" }}>Asserted by</dt>
<dd style={{ margin: 0 }}>
{claim.assertedBy}
{claim.runId ? (
<>
{" "}
· run <code style={mono}>{claim.runId}</code>
</>
) : null}
</dd>
{claim.derivedFrom ? (
<>
<dt style={{ color: "#5b6b7a" }}>Derived from</dt>
<dd style={{ margin: 0 }}>
rule <code style={mono}>{claim.derivedFrom.rule ?? claim.derivedFrom.query}</code> over{" "}
{claim.derivedFrom.inputs?.length ?? 0} input claim(s)
</dd>
</>
) : null}
<dt style={{ color: "#5b6b7a" }}>Id</dt>
<dd style={{ ...mono, margin: 0 }}>{claim.id}</dd>
</dl>
</div>
<div className="band">
<div className="section-head">
<h2>Why this claim is here</h2>
<p>Answer claim evidence source. If a claim cannot show this, it should not be trusted.</p>
</div>
{sources.length === 0 ? (
<p style={{ color: "#41505d" }}>
{claim.firstParty
? "Declared a first-party assertion: no external source, stated explicitly rather than left blank."
: "No sources recorded."}
</p>
) : (
<div style={{ overflowX: "auto" }}>
<table style={table}>
<thead>
<tr>
<th style={th}>Source</th>
<th style={th}>Type</th>
<th style={th}>Licence</th>
<th style={th}>Retrieved</th>
<th style={th}>State</th>
</tr>
</thead>
<tbody>
{sources.map((source) => (
<tr key={source!.id}>
<td style={td}>
<a href={source!.uri} rel="noreferrer">
{source!.title ?? source!.id}
</a>
</td>
<td style={td}>{source!.sourceType}</td>
<td style={td}>{source!.license ?? "unknown"}</td>
<td style={td}>{source!.retrievedAt.slice(0, 10)}</td>
<td style={td}>
{source!.stale ? <StatusBadge status="disputed" /> : <StatusBadge status="asserted" />}
{source!.stale ? (
<span style={{ color: "#7c2d12", marginLeft: "0.4rem" }}>stale</span>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{evidence.length > 0 ? (
<ul style={{ color: "#41505d", marginTop: "1rem", paddingLeft: "1.1rem" }}>
{evidence.map((record) => (
<li key={record!.id}>
<code style={mono}>{record!.selector.type}</code>{" "}
{JSON.stringify(record!.selector).replace(/[{}"]/g, "")}
{record!.excerpt ? <> {record!.excerpt}</> : null}
</li>
))}
</ul>
) : null}
</div>
<div className="band">
<div className="section-head">
<h2>History</h2>
<p>Claims are append-only: a correction adds a transition rather than editing the record.</p>
</div>
<div style={{ overflowX: "auto" }}>
<table style={table}>
<thead>
<tr>
<th style={th}>When</th>
<th style={th}>Status</th>
<th style={th}>By</th>
<th style={th}>Reason</th>
</tr>
</thead>
<tbody>
{history.map((entry, index) => (
<tr key={`${entry.objectId}-${index}`}>
<td style={td}>{entry.at.slice(0, 19)}</td>
<td style={td}>
<StatusBadge status={String(entry.status)} />
</td>
<td style={td}>{entry.by}</td>
<td style={{ ...td, color: "#41505d" }}>{entry.reason ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="band">
<p style={{ color: "#5b6b7a", fontSize: "0.9rem" }}>
Same data over the API:{" "}
<a href={`/api/ontologies/${manifest.id}/claims/${encodeURIComponent(claim.id)}`}>
<code style={mono}>
/api/ontologies/{manifest.id}/claims/{claim.id}
</code>
</a>
</p>
</div>
</SiteShell>
);
}

View file

@ -0,0 +1,201 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { getService } from "@/lib/ontology-service";
import { SiteShell } from "@/components/site-shell";
import { CLAIM_STATUS, Confidence, StatusBadge, formatObject, mono, table, td, th } from "../../../ui";
export const dynamic = "force-dynamic";
export async function generateMetadata({
params
}: {
params: Promise<{ entityId: string }>;
}): Promise<Metadata> {
const { entityId } = await params;
const id = decodeURIComponent(entityId);
const state = await getService();
const entity = state.engine?.store.getEntity(id);
return {
title: `${entity?.canonicalName ?? id} · OpenOntology · LogicSRC`,
description: entity
? `${entity.canonicalName} (${entity.type}) and the source-backed claims about it.`
: "OpenOntology entity"
};
}
export default async function EntityPage({
params
}: {
params: Promise<{ entityId: string }>;
}): Promise<ReactNode> {
const { entityId } = await params;
const id = decodeURIComponent(entityId);
const state = await getService();
if (!state.engine) notFound();
const engine = state.engine;
const entity = engine.store.getEntity(id);
if (!entity) notFound();
const manifest = engine.getOntologyManifest();
const claims = engine.store.listClaims({ subject: entity.id, status: CLAIM_STATUS.map((s) => s.id) as never });
const incoming = engine.store
.listClaims({ status: ["asserted"] })
.filter((claim) => "entity" in claim.object && claim.object.entity === entity.id);
return (
<SiteShell active="OpenOntology">
<div className="band">
<p style={{ marginBottom: "1rem" }}>
<Link href="/openontology/explore" style={{ color: "#5b6b7a", textDecoration: "none" }}>
Explorer
</Link>
</p>
<div className="section-head">
<p className="eyebrow">{entity.type}</p>
<h2>{entity.canonicalName}</h2>
</div>
{entity.id !== id ? (
<p style={{ color: "#7c2d12", background: "#ffedd5", padding: "0.6rem 0.9rem", borderRadius: "0.4rem" }}>
<code style={mono}>{id}</code> was merged into this entity. The old id still resolves
merges keep redirects rather than breaking references.
</p>
) : null}
<dl style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 1rem", margin: 0 }}>
<dt style={{ color: "#5b6b7a" }}>Id</dt>
<dd style={{ ...mono, margin: 0 }}>{entity.id}</dd>
<dt style={{ color: "#5b6b7a" }}>Status</dt>
<dd style={{ margin: 0 }}>
<StatusBadge status={entity.status ?? "active"} />
</dd>
{entity.aliases?.length ? (
<>
<dt style={{ color: "#5b6b7a" }}>Aliases</dt>
<dd style={{ margin: 0 }}>{entity.aliases.join(", ")}</dd>
</>
) : null}
{entity.externalIds && Object.keys(entity.externalIds).length > 0 ? (
<>
<dt style={{ color: "#5b6b7a" }}>External ids</dt>
<dd style={{ margin: 0 }}>
{Object.entries(entity.externalIds).map(([namespace, value]) => (
<code key={namespace} style={{ ...mono, marginRight: "0.75rem" }}>
{namespace}:{value}
</code>
))}
</dd>
</>
) : null}
<dt style={{ color: "#5b6b7a" }}>Created</dt>
<dd style={{ margin: 0 }}>
{entity.createdAt} by {entity.createdBy}
</dd>
</dl>
</div>
<div className="band">
<div className="section-head">
<h2>Claims about this entity</h2>
<p>
Each row shows status, confidence, the domain time it covers, and how many sources back
it. Nothing here is presented as settled unless it says <strong>asserted</strong>.
</p>
</div>
<div style={{ overflowX: "auto" }}>
<table style={table}>
<thead>
<tr>
<th style={th}>Status</th>
<th style={th}>Predicate</th>
<th style={th}>Object</th>
<th style={th}>Confidence</th>
<th style={th}>Valid from</th>
<th style={th}>Recorded</th>
<th style={th}>Sources</th>
</tr>
</thead>
<tbody>
{claims.map((claim) => (
<tr key={claim.id}>
<td style={td}>
<StatusBadge status={claim.status} />
</td>
<td style={td}>
<Link href={`/openontology/explore/claim/${encodeURIComponent(claim.id)}`}>
{claim.predicate}
</Link>
</td>
<td style={{ ...td, ...mono }}>
{"entity" in claim.object ? (
<Link href={`/openontology/explore/entity/${encodeURIComponent(claim.object.entity)}`}>
{claim.object.entity}
</Link>
) : (
formatObject(claim.object)
)}
</td>
<td style={td}>
<Confidence value={claim.confidence} />
</td>
<td style={td}>{claim.validTime?.from?.slice(0, 10) ?? "—"}</td>
<td style={td}>{claim.assertedAt.slice(0, 10)}</td>
<td style={td}>{claim.sources?.length ?? 0}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{incoming.length > 0 ? (
<div className="band">
<div className="section-head">
<h2>Referenced by</h2>
<p>Asserted claims elsewhere in the graph that point at this entity.</p>
</div>
<div style={{ overflowX: "auto" }}>
<table style={table}>
<thead>
<tr>
<th style={th}>Subject</th>
<th style={th}>Predicate</th>
<th style={th}>Claim</th>
</tr>
</thead>
<tbody>
{incoming.slice(0, 25).map((claim) => (
<tr key={claim.id}>
<td style={td}>
<Link href={`/openontology/explore/entity/${encodeURIComponent(claim.subject)}`}>
{engine.store.getEntity(claim.subject)?.canonicalName ?? claim.subject}
</Link>
</td>
<td style={td}>{claim.predicate}</td>
<td style={{ ...td, ...mono }}>
<Link href={`/openontology/explore/claim/${encodeURIComponent(claim.id)}`}>{claim.id}</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
<div className="band">
<p style={{ color: "#5b6b7a", fontSize: "0.9rem" }}>
Same data over the API:{" "}
<a href={`/api/ontologies/${manifest.id}/entities/${encodeURIComponent(entity.id)}`}>
<code style={mono}>
/api/ontologies/{manifest.id}/entities/{entity.id}
</code>
</a>
</p>
</div>
</SiteShell>
);
}

View file

@ -0,0 +1,200 @@
import Link from "next/link";
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { getService } from "@/lib/ontology-service";
import { SiteShell } from "@/components/site-shell";
import { CLAIM_STATUS, card, mono, table, th, td, StatusBadge } from "../ui";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Explore · OpenOntology · LogicSRC",
description:
"A read-only explorer over the OpenOntology example package: entity types, entities, claims with provenance, and the saved queries that answer real questions.",
alternates: { canonical: "/openontology/explore" }
};
export default async function ExplorePage(): Promise<ReactNode> {
const state = await getService();
if (!state.engine) {
return (
<SiteShell active="OpenOntology">
<div className="band">
<div className="section-head">
<h2>Explorer unavailable</h2>
<p>{state.error ?? "No ontology package is loaded."}</p>
</div>
</div>
</SiteShell>
);
}
const engine = state.engine;
const manifest = engine.getOntologyManifest();
const schema = engine.getOntologySchema();
const entities = engine.store.listEntities();
const byType = new Map<string, number>();
for (const entity of entities) byType.set(entity.type, (byType.get(entity.type) ?? 0) + 1);
const allClaims = engine.store.listClaims({
status: ["asserted", "proposed", "disputed", "retracted", "superseded", "derived"]
});
const byStatus = new Map<string, number>();
for (const claim of allClaims) byStatus.set(claim.status, (byStatus.get(claim.status) ?? 0) + 1);
const needsReview = allClaims.filter(
(claim) => claim.status === "proposed" || claim.status === "disputed"
);
return (
<SiteShell active="OpenOntology">
<div className="band">
<div className="section-head">
<p className="eyebrow">Read-only explorer · fixture data</p>
<h2>{manifest.name}</h2>
<p>{manifest.description}</p>
</div>
<p style={{ color: "#5b6b7a", fontSize: "0.95rem" }}>
<code style={mono}>
{manifest.id}@{manifest.version}
</code>{" "}
· {manifest.license} · revision <code style={mono}>{engine.store.revision()}</code> ·{" "}
{state.persistence === "turso" ? "Turso/libSQL" : "in-memory"} ·{" "}
<a href={`/api/ontologies/${manifest.id}/manifest`}>REST</a>{" "}
<a href="/api/ontologies/openapi">OpenAPI</a>
</p>
<p style={{ color: "#5b6b7a", fontSize: "0.95rem" }}>
Every person, organization, and project below is <strong>fictional</strong>. The package
exists to demonstrate the contract.
</p>
</div>
<div className="band">
<div className="section-head">
<h2>Claims by status</h2>
<p>
A clean graph makes uncertain things look settled, so status is never hidden. Only{" "}
<strong>asserted</strong> claims are the current accepted view.
</p>
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.6rem" }}>
{CLAIM_STATUS.map((status) => (
<div key={status.id} style={{ ...card, minWidth: "9rem" }}>
<StatusBadge status={status.id} />
<div style={{ fontSize: "1.6rem", fontWeight: 600, color: "#101418" }}>
{byStatus.get(status.id) ?? 0}
</div>
<div style={{ color: "#5b6b7a", fontSize: "0.85rem" }}>{status.meaning}</div>
</div>
))}
</div>
{needsReview.length > 0 ? (
<p style={{ marginTop: "1rem", color: "#41505d" }}>
{needsReview.length} claim{needsReview.length === 1 ? "" : "s"} awaiting a human decision:{" "}
{needsReview.slice(0, 5).map((claim, index) => (
<span key={claim.id}>
{index > 0 ? ", " : ""}
<Link href={`/openontology/explore/claim/${encodeURIComponent(claim.id)}`}>{claim.id}</Link>
</span>
))}
</p>
) : null}
</div>
<div className="band">
<div className="section-head">
<h2>Entity types</h2>
<p>The identity-bearing nouns of this domain, and how many of each exist.</p>
</div>
<div style={{ overflowX: "auto" }}>
<table style={table}>
<thead>
<tr>
<th style={th}>Type</th>
<th style={th}>Count</th>
<th style={th}>Description</th>
</tr>
</thead>
<tbody>
{schema.entityTypes.map((type) => (
<tr key={type.id}>
<td style={td}>
<Link href={`/openontology/explore?type=${encodeURIComponent(type.id)}`}>
<code style={mono}>{type.id}</code>
</Link>
</td>
<td style={td}>{byType.get(type.id) ?? 0}</td>
<td style={{ ...td, color: "#41505d" }}>{type.description}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="band">
<div className="section-head">
<h2>Entities</h2>
<p>Every entity keeps its id when its name changes, and after a merge the old id still resolves.</p>
</div>
<div style={{ overflowX: "auto" }}>
<table style={table}>
<thead>
<tr>
<th style={th}>Name</th>
<th style={th}>Type</th>
<th style={th}>Status</th>
<th style={th}>Id</th>
</tr>
</thead>
<tbody>
{entities.slice(0, 40).map((entity) => (
<tr key={entity.id}>
<td style={td}>
<Link href={`/openontology/explore/entity/${encodeURIComponent(entity.id)}`}>
{entity.canonicalName}
</Link>
</td>
<td style={td}>{entity.type}</td>
<td style={td}>
<StatusBadge status={entity.status ?? "active"} />
</td>
<td style={{ ...td, ...mono, color: "#5b6b7a" }}>{entity.id}</td>
</tr>
))}
</tbody>
</table>
</div>
{entities.length > 40 ? (
<p style={{ color: "#5b6b7a", marginTop: "0.75rem" }}>
Showing 40 of {entities.length}. The full list is at{" "}
<a href={`/api/ontologies/${manifest.id}/entities?limit=200`}>
<code style={mono}>/api/ontologies/{manifest.id}/entities</code>
</a>
.
</p>
) : null}
</div>
<div className="band">
<div className="section-head">
<h2>Saved queries</h2>
<p>The questions this ontology already knows how to answer.</p>
</div>
<div style={{ display: "grid", gap: "0.75rem" }}>
{schema.queries.map((query) => (
<div key={query.id} style={card}>
<strong style={{ color: "#101418" }}>{query.label ?? query.id}</strong>
<div style={{ color: "#41505d", margin: "0.25rem 0" }}>{query.description}</div>
<code style={{ ...mono, color: "#5b6b7a" }}>
POST /api/ontologies/{manifest.id}/query {"{"} &quot;savedQuery&quot;: &quot;{query.id}&quot; {"}"}
</code>
</div>
))}
</div>
</div>
</SiteShell>
);
}

View file

@ -224,6 +224,14 @@ OpenOntology package is valid.`}</pre>
<h2>Where everything lives</h2>
</div>
<ul style={{ color: "#41505d", lineHeight: 1.9, paddingLeft: "1.1rem" }}>
<li>
<Link href="/openontology/explore">Explorer</Link> browse the example package: types,
entities, claims with provenance, and the history behind each one
</li>
<li>
<a href="/api/ontologies/openapi">REST API</a> OpenAPI description of the reference
service, with SSE events
</li>
<li>
<Link href="/docs/openontology">Specification</Link> the model, packages, claims,
queries, validation, CLI, SDK, conformance

View file

@ -0,0 +1,136 @@
import type { CSSProperties, ReactNode } from "react";
/** Shared presentation for the OpenOntology explorer. */
export const card: CSSProperties = {
border: "1px solid #e3e6e0",
borderRadius: "0.6rem",
padding: "0.9rem 1.05rem",
background: "#fff"
};
export const mono: CSSProperties = {
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: "0.85rem"
};
export const table: CSSProperties = {
width: "100%",
borderCollapse: "collapse",
fontSize: "0.92rem"
};
export const th: CSSProperties = {
textAlign: "left",
padding: "0.5rem 0.75rem 0.5rem 0",
borderBottom: "1px solid #d7dbd4",
color: "#5b6b7a",
fontWeight: 600,
whiteSpace: "nowrap"
};
export const td: CSSProperties = {
padding: "0.5rem 0.75rem 0.5rem 0",
borderBottom: "1px solid #eceee9",
verticalAlign: "top"
};
export const pre: CSSProperties = {
...mono,
background: "#101418",
color: "#e8eef5",
padding: "1rem 1.1rem",
borderRadius: "0.6rem",
overflowX: "auto",
lineHeight: 1.6,
margin: 0
};
export const CLAIM_STATUS: Array<{ id: string; label: string; glyph: string; meaning: string }> = [
{ id: "asserted", label: "asserted", glyph: "✓", meaning: "Current accepted view" },
{ id: "proposed", label: "proposed", glyph: "?", meaning: "Suggested, not accepted" },
{ id: "disputed", label: "disputed", glyph: "!", meaning: "Contradicted" },
{ id: "retracted", label: "retracted", glyph: "×", meaning: "Withdrawn, kept on record" },
{ id: "superseded", label: "superseded", glyph: "→", meaning: "Replaced by a later claim" },
{ id: "derived", label: "derived", glyph: "ƒ", meaning: "Produced by a rule" }
];
const TONE: Record<string, { fg: string; bg: string; glyph: string }> = {
asserted: { fg: "#14532d", bg: "#dcfce7", glyph: "✓" },
active: { fg: "#14532d", bg: "#dcfce7", glyph: "✓" },
applied: { fg: "#14532d", bg: "#dcfce7", glyph: "✓" },
proposed: { fg: "#1e3a8a", bg: "#dbeafe", glyph: "?" },
disputed: { fg: "#7c2d12", bg: "#ffedd5", glyph: "!" },
retracted: { fg: "#7f1d1d", bg: "#fee2e2", glyph: "×" },
rejected: { fg: "#7f1d1d", bg: "#fee2e2", glyph: "×" },
superseded: { fg: "#3f3f46", bg: "#e4e4e7", glyph: "→" },
merged: { fg: "#3f3f46", bg: "#e4e4e7", glyph: "→" },
derived: { fg: "#4c1d95", bg: "#ede9fe", glyph: "ƒ" },
archived: { fg: "#3f3f46", bg: "#e4e4e7", glyph: "▪" }
};
/**
* Status badge.
*
* Colour is never the only signal: every badge carries a glyph and the word,
* so it survives a monochrome screen and a colour-blind reader.
*/
export function StatusBadge({ status }: { status: string }): ReactNode {
const tone = TONE[status] ?? { fg: "#3f3f46", bg: "#e4e4e7", glyph: "·" };
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.3rem",
padding: "0.1rem 0.45rem",
borderRadius: "0.35rem",
background: tone.bg,
color: tone.fg,
fontSize: "0.8rem",
fontWeight: 600,
whiteSpace: "nowrap"
}}
>
<span aria-hidden="true">{tone.glyph}</span>
{status}
</span>
);
}
/** Confidence with its number spelled out — never a bare bar. */
export function Confidence({ value }: { value?: number }): ReactNode {
if (value === undefined) return <span style={{ color: "#8a949e" }}>not stated</span>;
return (
<span title="Confidence is metadata, not proof">
{value.toFixed(2)}
<span
aria-hidden="true"
style={{
display: "inline-block",
width: "3rem",
height: "0.4rem",
marginLeft: "0.4rem",
borderRadius: "0.2rem",
background: "#e4e4e7",
verticalAlign: "middle"
}}
>
<span
style={{
display: "block",
width: `${Math.round(value * 100)}%`,
height: "100%",
borderRadius: "0.2rem",
background: "#5b6b7a"
}}
/>
</span>
</span>
);
}
export function formatObject(object: { entity?: string; value?: unknown }): string {
if (object.entity) return object.entity;
return typeof object.value === "string" ? object.value : JSON.stringify(object.value);
}

View file

@ -0,0 +1,221 @@
import Link from "next/link";
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { SiteShell } from "@/components/site-shell";
import { card, mono, pre } from "../openontology/ui";
export const metadata: Metadata = {
title: "OpenPRD · LogicSRC",
description:
"OpenPRD is a lightweight open standard for product requirements documents: a numbered, committed collection under prd/, one Markdown file each, with front-matter, eight fixed sections, and an enforced lifecycle.",
alternates: { canonical: "/openprd" }
};
const SECTIONS: Array<[string, string]> = [
["Problem", "The user or business problem, and why it matters now."],
["Goals", "What success looks like, as outcomes rather than features."],
["Non-Goals", "Explicitly out of scope, to bound the work."],
["Users", "Who this is for; personas or segments."],
["Requirements", "Numbered R1, R2, … each tagged [P0], [P1], or [P2]."],
["UX Notes", "Flows, states, and constraints that shape the experience."],
["Success Metrics", "How the goals will be measured."],
["Risks & Open Questions", "Known risks and the decisions still owed."]
];
export default function OpenPrdPage(): ReactNode {
return (
<SiteShell active="OpenPRD">
<div className="band">
<div className="section-head">
<p className="eyebrow">LogicSRC standards surface</p>
<h2>OpenPRD</h2>
<p>
A lightweight open standard for product requirements documents authored by humans or AI
agents. A repo keeps a numbered, committed collection under <code style={mono}>prd/</code>
one Markdown file per decision, readable a year later.
</p>
</div>
<p style={{ color: "#41505d" }}>
It borrows the shape of a BIP/EIP/DIP process. Where OpenSpec models a <em>change</em> as a
multi-file bundle, OpenPRD models a <em>product decision</em> as one numbered file you can
read to recover the <em>why</em>.
</p>
<p style={{ color: "#5b6b7a", fontSize: "0.95rem" }}>
Status: <strong>0.2</strong>. A PRD is just a file it needs no service, and no tooling, to
be valid.
</p>
</div>
<div className="band">
<div className="section-head">
<h2>The shape</h2>
<p>Front-matter, then eight sections in a fixed order. All of them required.</p>
</div>
<pre style={pre}>{`---
openprd: "0.2"
id: "0001" # four digits, matches the filename
title: Expand the parked-domain service
status: Draft # Draft|Review|Accepted|Final|Rejected|Withdrawn|Superseded
authors:
- anthony@profullstack.com
created: 2026-07-12
updated: 2026-07-12
tags: [growth]
---
## Problem
## Goals
## Non-Goals
## Users
## Requirements
- R1 [P0] First required capability.
- R2 [P1] Next capability.
## UX Notes
## Success Metrics
## Risks & Open Questions`}</pre>
<div style={{ display: "grid", gap: "0.6rem", marginTop: "1rem" }}>
{SECTIONS.map(([name, detail], index) => (
<div key={name} style={card}>
<strong style={{ color: "#101418" }}>
{index + 1}. {name}
</strong>
<div style={{ color: "#41505d" }}>{detail}</div>
</div>
))}
</div>
<p style={{ color: "#5b6b7a", marginTop: "1rem" }}>
A section may be a single line such as <code style={mono}>_None._</code> but it may not be
missing. That is what keeps every PRD skimmable and diffable.
</p>
</div>
<div className="band">
<div className="section-head">
<h2>Lifecycle, enforced</h2>
<p>Status lives in the front-matter and is the source of truth.</p>
</div>
<pre style={pre}>{`Draft → Review → Accepted → Final
Rejected
Withdrawn
Superseded by NNNN`}</pre>
<ul style={{ color: "#41505d", lineHeight: 1.8, paddingLeft: "1.1rem", marginTop: "1rem" }}>
<li>
<code style={mono}>Draft</code> cannot jump to <code style={mono}>Final</code> the tool
refuses the transition rather than trusting the author to remember.
</li>
<li>
<code style={mono}>Rejected</code>, <code style={mono}>Withdrawn</code>, and{" "}
<code style={mono}>Superseded</code> are terminal. They stay on disk, because the{" "}
<em>why not</em> is part of the record.
</li>
<li>
Moving to <code style={mono}>Superseded</code> requires naming the PRD that replaces it.
</li>
<li>Ids are four digits, monotonically increasing, with no gaps. 0000 is the template.</li>
</ul>
</div>
<div className="band">
<div className="section-head">
<h2>Conformance is four rules</h2>
<p>Everything else the tooling reports is lint, and says so.</p>
</div>
<ol style={{ color: "#41505d", lineHeight: 1.9, paddingLeft: "1.2rem" }}>
<li>
It lives at <code style={mono}>prd/&lt;id&gt;-&lt;slug&gt;.md</code> with a four-digit id.
</li>
<li>
Its front-matter validates against <code style={mono}>openprd-prd.schema.json</code>.
</li>
<li>The id equals the filename&apos;s numeric prefix.</li>
<li>All eight body sections are present, in order.</li>
</ol>
<p style={{ color: "#41505d" }}>
Conformance failures are errors. An empty section, a requirement missing its priority tag,
numbering that skips, a stale index, a one-sided supersession link those are warnings, and{" "}
<code style={mono}>--strict</code> promotes them. Every finding carries a stable code, the
file, the line, and a remediation hint.
</p>
</div>
<div className="band">
<div className="section-head">
<h2>Tooling</h2>
<p>
<code style={mono}>@logicsrc/openprd</code> implements the standard; the CLI drives it.
</p>
</div>
<pre style={pre}>{`logicsrc prd init # template + generated index
logicsrc prd new "Expand the service" # next free number, eight stub sections
logicsrc prd list # id, title, status, tags, requirements
logicsrc prd validate --strict # conformance + lint, exit 1 on error
logicsrc prd index --write # regenerate prd/README.md
logicsrc prd status 0001 Review # refuses illegal transitions
logicsrc prd tasks 0001 --priority P0 # the optional LogicSRC task bridge`}</pre>
<p style={{ color: "#41505d", marginTop: "1rem" }}>
Exit codes are stable for CI: <code style={mono}>0</code> ok, <code style={mono}>1</code>{" "}
invalid, <code style={mono}>2</code> usage, <code style={mono}>3</code> not found.
</p>
</div>
<div className="band">
<div className="section-head">
<h2>The optional task bridge</h2>
<p>Requirements map onto LogicSRC tasks in tooling, not in the standard.</p>
</div>
<p style={{ color: "#41505d" }}>
Each <code style={mono}>R#</code> becomes one <code style={mono}>logicsrc.task</code>{" "}
document, validated against its schema before it is emitted. The board defaults to{" "}
<code style={mono}>/prd/&lt;id&gt;</code>, <code style={mono}>repo</code> carries over, and the
creator DID is derived from the first author (
<code style={mono}>anthony@profullstack.com</code> {" "}
<code style={mono}>anthony.profullstack</code>).
</p>
<p style={{ color: "#5b6b7a" }}>
Nothing requires you to use it. A PRD with no LogicSRC anywhere near it is still a PRD.
</p>
</div>
<div className="band">
<div className="section-head">
<h2>Where everything lives</h2>
</div>
<ul style={{ color: "#41505d", lineHeight: 1.9, paddingLeft: "1.1rem" }}>
<li>
<Link href="/docs/openprd">Specification</Link> layout, front-matter, sections,
lifecycle, conformance, implementation
</li>
<li>
<a
href="https://github.com/profullstack/logicsrc/blob/master/packages/schemas/schemas/openprd-prd.schema.json"
rel="noreferrer"
>
Front-matter JSON Schema
</a>
</li>
<li>
<a
href="https://github.com/profullstack/logicsrc/tree/master/packages/schemas/fixtures/openprd"
rel="noreferrer"
>
Conformance bundle
</a>{" "}
documents that must validate, and documents that must fail with a named code
</li>
<li>
<a href="https://github.com/profullstack/logicsrc/tree/master/prd" rel="noreferrer">
This repo&apos;s own collection
</a>{" "}
dogfooded: it validates with zero errors and zero warnings
</li>
<li>
<Link href="/openontology">OpenOntology</Link> the companion standard for durable,
source-backed domain knowledge
</li>
</ul>
</div>
</SiteShell>
);
}

View file

@ -17,7 +17,8 @@ const STATIC_ROUTES: Array<{
{ path: "/", changeFrequency: "weekly", priority: 1.0 },
{ path: "/docs", changeFrequency: "weekly", priority: 0.9 },
{ path: "/openontology", changeFrequency: "weekly", priority: 0.9 },
{ path: "/docs/openprd", changeFrequency: "weekly", priority: 0.8 },
{ path: "/openprd", changeFrequency: "weekly", priority: 0.9 },
{ path: "/openontology/explore", changeFrequency: "daily", priority: 0.7 },
{ path: "/openspec", changeFrequency: "weekly", priority: 0.8 },
{ path: "/agent-swarm", changeFrequency: "weekly", priority: 0.8 },
{ path: "/agentbyte", changeFrequency: "weekly", priority: 0.8 },

View file

@ -9,7 +9,7 @@ const NAV: Array<{ href: string; label: string; external?: boolean }> = [
{ href: "/agentbyte", label: "AgentByte" },
{ href: "/credential-sharing", label: "Credentials" },
{ href: "/openontology", label: "OpenOntology" },
{ href: "/docs/openprd", label: "OpenPRD" },
{ href: "/openprd", label: "OpenPRD" },
{ href: "/#cli", label: "CLI" },
{ href: "/docs", label: "Docs" },
{ href: "/blog", label: "Blog" },

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>