mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-15 07:17:30 +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
459
apps/logicsrc-web/contract/ontology-api.contract.test.ts
Normal file
459
apps/logicsrc-web/contract/ontology-api.contract.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
}
|
||||
|
|
@ -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 });
|
||||
});
|
||||
}
|
||||
|
|
@ -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 });
|
||||
});
|
||||
}
|
||||
|
|
@ -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 })
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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() });
|
||||
});
|
||||
}
|
||||
|
|
@ -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)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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) });
|
||||
});
|
||||
}
|
||||
|
|
@ -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() }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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 ?? {}
|
||||
}))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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)));
|
||||
});
|
||||
}
|
||||
|
|
@ -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() })
|
||||
);
|
||||
}
|
||||
|
|
@ -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() }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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() })
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }))
|
||||
);
|
||||
}
|
||||
371
apps/logicsrc-web/src/app/api/ontologies/openapi/route.ts
Normal file
371
apps/logicsrc-web/src/app/api/ontologies/openapi/route.ts
Normal 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" }
|
||||
});
|
||||
}
|
||||
31
apps/logicsrc-web/src/app/api/ontologies/route.ts
Normal file
31
apps/logicsrc-web/src/app/api/ontologies/route.ts
Normal 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() }
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
200
apps/logicsrc-web/src/app/openontology/explore/page.tsx
Normal file
200
apps/logicsrc-web/src/app/openontology/explore/page.tsx
Normal 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 {"{"} "savedQuery": "{query.id}" {"}"}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
136
apps/logicsrc-web/src/app/openontology/ui.tsx
Normal file
136
apps/logicsrc-web/src/app/openontology/ui.tsx
Normal 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);
|
||||
}
|
||||
221
apps/logicsrc-web/src/app/openprd/page.tsx
Normal file
221
apps/logicsrc-web/src/app/openprd/page.tsx
Normal 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/<id>-<slug>.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'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/<id></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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
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