From da5f6f8381a6f1f555e334c230fc6bc57fc7aa39 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 28 Jul 2026 05:10:33 -0700 Subject: [PATCH] =?UTF-8?q?feat(openontology):=20Phase=202=20+=20Phase=203?= =?UTF-8?q?=20=E2=80=94=20storage,=20REST/SSE,=20MCP,=20RDF/SHACL,=20adapt?= =?UTF-8?q?ers,=20TUI,=20explorer=20(#101)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 8 +- .../contract/ontology-api.contract.test.ts | 459 +++++++++++ apps/logicsrc-web/package.json | 2 + .../changesets/[changeSetId]/apply/route.ts | 40 + .../changesets/[changeSetId]/approve/route.ts | 29 + .../changesets/[changeSetId]/review/route.ts | 32 + .../changesets/[changeSetId]/route.ts | 23 + .../[ontologyId]/changesets/route.ts | 54 ++ .../[ontologyId]/claims/[claimId]/route.ts | 21 + .../ontologies/[ontologyId]/claims/route.ts | 19 + .../[ontologyId]/entities/[entityId]/route.ts | 33 + .../ontologies/[ontologyId]/entities/route.ts | 46 ++ .../ontologies/[ontologyId]/events/route.ts | 67 ++ .../ontologies/[ontologyId]/explain/route.ts | 14 + .../ontologies/[ontologyId]/manifest/route.ts | 10 + .../ontologies/[ontologyId]/query/route.ts | 32 + .../ontologies/[ontologyId]/schema/route.ts | 10 + .../ontologies/[ontologyId]/validate/route.ts | 12 + .../src/app/api/ontologies/openapi/route.ts | 371 +++++++++ .../src/app/api/ontologies/route.ts | 31 + .../explore/claim/[claimId]/page.tsx | 219 +++++ .../explore/entity/[entityId]/page.tsx | 201 +++++ .../src/app/openontology/explore/page.tsx | 200 +++++ .../src/app/openontology/page.tsx | 8 + apps/logicsrc-web/src/app/openontology/ui.tsx | 136 ++++ apps/logicsrc-web/src/app/openprd/page.tsx | 221 +++++ apps/logicsrc-web/src/app/sitemap.ts | 3 +- .../src/components/site-shell.tsx | 2 +- apps/logicsrc-web/src/lib/ontology-service.ts | 223 ++++++ apps/logicsrc-web/src/lib/page-markup.ts | 4 +- docs/openontology-interoperability.md | 43 +- docs/openontology.md | 46 +- package-lock.json | 196 +++++ packages/cli/src/ontology.ts | 21 + packages/logicsrc-mcp/package.json | 4 +- packages/logicsrc-mcp/src/openontology.ts | 564 +++++++++++++ packages/logicsrc-mcp/src/openprd.ts | 278 +++++++ packages/logicsrc-mcp/src/server.ts | 7 +- packages/logicsrc-mcp/src/standards.test.ts | 226 ++++++ packages/openontology/package.json | 19 +- packages/openontology/src/adapters.test.ts | 272 +++++++ packages/openontology/src/adapters.ts | 752 ++++++++++++++++++ packages/openontology/src/index.ts | 33 + packages/openontology/src/libsql.test.ts | 184 +++++ packages/openontology/src/libsql.ts | 713 +++++++++++++++++ packages/openontology/src/rdf.test.ts | 114 +++ packages/openontology/src/rdf.ts | 355 +++++++++ packages/openontology/src/shacl.ts | 209 +++++ packages/openontology/src/test-helpers.ts | 16 + packages/tui/package.json | 3 +- packages/tui/src/index.ts | 1 + packages/tui/src/openontology.test.ts | 94 +++ packages/tui/src/openontology.ts | 282 +++++++ 53 files changed, 6939 insertions(+), 23 deletions(-) create mode 100644 apps/logicsrc-web/contract/ontology-api.contract.test.ts create mode 100644 apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/changesets/[changeSetId]/apply/route.ts create mode 100644 apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/changesets/[changeSetId]/approve/route.ts create mode 100644 apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/changesets/[changeSetId]/review/route.ts create mode 100644 apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/changesets/[changeSetId]/route.ts create mode 100644 apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/changesets/route.ts create mode 100644 apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/claims/[claimId]/route.ts create mode 100644 apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/claims/route.ts create mode 100644 apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/entities/[entityId]/route.ts create mode 100644 apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/entities/route.ts create mode 100644 apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/events/route.ts create mode 100644 apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/explain/route.ts create mode 100644 apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/manifest/route.ts create mode 100644 apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/query/route.ts create mode 100644 apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/schema/route.ts create mode 100644 apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/validate/route.ts create mode 100644 apps/logicsrc-web/src/app/api/ontologies/openapi/route.ts create mode 100644 apps/logicsrc-web/src/app/api/ontologies/route.ts create mode 100644 apps/logicsrc-web/src/app/openontology/explore/claim/[claimId]/page.tsx create mode 100644 apps/logicsrc-web/src/app/openontology/explore/entity/[entityId]/page.tsx create mode 100644 apps/logicsrc-web/src/app/openontology/explore/page.tsx create mode 100644 apps/logicsrc-web/src/app/openontology/ui.tsx create mode 100644 apps/logicsrc-web/src/app/openprd/page.tsx create mode 100644 apps/logicsrc-web/src/lib/ontology-service.ts create mode 100644 packages/logicsrc-mcp/src/openontology.ts create mode 100644 packages/logicsrc-mcp/src/openprd.ts create mode 100644 packages/logicsrc-mcp/src/standards.test.ts create mode 100644 packages/openontology/src/adapters.test.ts create mode 100644 packages/openontology/src/adapters.ts create mode 100644 packages/openontology/src/libsql.test.ts create mode 100644 packages/openontology/src/libsql.ts create mode 100644 packages/openontology/src/rdf.test.ts create mode 100644 packages/openontology/src/rdf.ts create mode 100644 packages/openontology/src/shacl.ts create mode 100644 packages/openontology/src/test-helpers.ts create mode 100644 packages/tui/src/openontology.test.ts create mode 100644 packages/tui/src/openontology.ts diff --git a/README.md b/README.md index ac9d511..5dbad63 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,13 @@ OpenOntology package is valid. Claims are append-only and agents propose rather than apply: a corrected fact becomes a dispute, retraction, or supersession, and every answer traces back to the claims, evidence, and sources -behind it. See also [governance](docs/openontology-governance.md) and +behind it. + +Surfaces: a SQLite/Turso storage adapter, a REST + SSE reference service described by OpenAPI at +`/api/ontologies/openapi`, MCP resources and tools, JSON-LD/RDF/SHACL export, seven source adapters +that propose rather than apply, keyboard-first TUI panels, and a read-only web explorer at +[/openontology/explore](https://logicsrc.com/openontology/explore). See also +[governance](docs/openontology-governance.md) and [interoperability](docs/openontology-interoperability.md). ## MCP diff --git a/apps/logicsrc-web/contract/ontology-api.contract.test.ts b/apps/logicsrc-web/contract/ontology-api.contract.test.ts new file mode 100644 index 0000000..53ffeac --- /dev/null +++ b/apps/logicsrc-web/contract/ontology-api.contract.test.ts @@ -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 = >(value: T) => ({ params: Promise.resolve(value) }); + +function request( + path: string, + init: { method?: string; body?: unknown; token?: string; headers?: Record } = {} +): 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(response: Response): Promise { + 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 }>(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"); + }); +}); diff --git a/apps/logicsrc-web/package.json b/apps/logicsrc-web/package.json index 14ec82f..b02743f 100644 --- a/apps/logicsrc-web/package.json +++ b/apps/logicsrc-web/package.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", diff --git a/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/changesets/[changeSetId]/apply/route.ts b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/changesets/[changeSetId]/apply/route.ts new file mode 100644 index 0000000..29f34ef --- /dev/null +++ b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/changesets/[changeSetId]/apply/route.ts @@ -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 }); + }); +} diff --git a/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/changesets/[changeSetId]/approve/route.ts b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/changesets/[changeSetId]/approve/route.ts new file mode 100644 index 0000000..6c24af9 --- /dev/null +++ b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/changesets/[changeSetId]/approve/route.ts @@ -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 }); + }); +} diff --git a/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/changesets/[changeSetId]/review/route.ts b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/changesets/[changeSetId]/review/route.ts new file mode 100644 index 0000000..ba963e8 --- /dev/null +++ b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/changesets/[changeSetId]/review/route.ts @@ -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 }); + }); +} diff --git a/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/changesets/[changeSetId]/route.ts b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/changesets/[changeSetId]/route.ts new file mode 100644 index 0000000..170c990 --- /dev/null +++ b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/changesets/[changeSetId]/route.ts @@ -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 }) + }); + }); +} diff --git a/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/changesets/route.ts b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/changesets/route.ts new file mode 100644 index 0000000..e3c8103 --- /dev/null +++ b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/changesets/route.ts @@ -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() }); + }); +} diff --git a/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/claims/[claimId]/route.ts b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/claims/[claimId]/route.ts new file mode 100644 index 0000000..9d4594f --- /dev/null +++ b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/claims/[claimId]/route.ts @@ -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) + }); + }); +} diff --git a/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/claims/route.ts b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/claims/route.ts new file mode 100644 index 0000000..f43ff23 --- /dev/null +++ b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/claims/route.ts @@ -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) }); + }); +} diff --git a/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/entities/[entityId]/route.ts b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/entities/[entityId]/route.ts new file mode 100644 index 0000000..2e87ef1 --- /dev/null +++ b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/entities/[entityId]/route.ts @@ -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() } + ); + }); +} diff --git a/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/entities/route.ts b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/entities/route.ts new file mode 100644 index 0000000..6156a65 --- /dev/null +++ b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/entities/route.ts @@ -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 ?? {} + })) + }); + }); +} diff --git a/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/events/route.ts b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/events/route.ts new file mode 100644 index 0000000..c56a5c9 --- /dev/null +++ b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/events/route.ts @@ -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" + } + }); +} diff --git a/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/explain/route.ts b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/explain/route.ts new file mode 100644 index 0000000..48383bf --- /dev/null +++ b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/explain/route.ts @@ -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))); + }); +} diff --git a/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/manifest/route.ts b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/manifest/route.ts new file mode 100644 index 0000000..b22779e --- /dev/null +++ b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/manifest/route.ts @@ -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() }) + ); +} diff --git a/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/query/route.ts b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/query/route.ts new file mode 100644 index 0000000..80fa36b --- /dev/null +++ b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/query/route.ts @@ -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 | 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) ?? 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() } + ); + }); +} diff --git a/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/schema/route.ts b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/schema/route.ts new file mode 100644 index 0000000..d3cf72c --- /dev/null +++ b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/schema/route.ts @@ -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() }) + ); +} diff --git a/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/validate/route.ts b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/validate/route.ts new file mode 100644 index 0000000..ef5877d --- /dev/null +++ b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/validate/route.ts @@ -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 })) + ); +} diff --git a/apps/logicsrc-web/src/app/api/ontologies/openapi/route.ts b/apps/logicsrc-web/src/app/api/ontologies/openapi/route.ts new file mode 100644 index 0000000..d63875a --- /dev/null +++ b/apps/logicsrc-web/src/app/api/ontologies/openapi/route.ts @@ -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" } + }); +} diff --git a/apps/logicsrc-web/src/app/api/ontologies/route.ts b/apps/logicsrc-web/src/app/api/ontologies/route.ts new file mode 100644 index 0000000..090179c --- /dev/null +++ b/apps/logicsrc-web/src/app/api/ontologies/route.ts @@ -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() } + ); +} diff --git a/apps/logicsrc-web/src/app/openontology/explore/claim/[claimId]/page.tsx b/apps/logicsrc-web/src/app/openontology/explore/claim/[claimId]/page.tsx new file mode 100644 index 0000000..43258ec --- /dev/null +++ b/apps/logicsrc-web/src/app/openontology/explore/claim/[claimId]/page.tsx @@ -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 { + 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 { + 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 ( + +
+

+ + ← Explorer + +

+
+

Claim

+

+ {subject ? ( + + {subject.canonicalName} + + ) : ( + claim.subject + )}{" "} + —{claim.predicate}→{" "} + {object ? ( + + {object.canonicalName} + + ) : ( + formatObject(claim.object) + )} +

+
+ +
+
Status
+
+ +
+
Confidence
+
+ +
+
Valid time
+
+ {claim.validTime?.from ? `${claim.validTime.from.slice(0, 10)} → ${claim.validTime.to?.slice(0, 10) ?? "present"}` : "not stated"} + (when it was true in the world) +
+
Recorded
+
+ {claim.assertedAt} (when the system learned it) +
+
Asserted by
+
+ {claim.assertedBy} + {claim.runId ? ( + <> + {" "} + · run {claim.runId} + + ) : null} +
+ {claim.derivedFrom ? ( + <> +
Derived from
+
+ rule {claim.derivedFrom.rule ?? claim.derivedFrom.query} over{" "} + {claim.derivedFrom.inputs?.length ?? 0} input claim(s) +
+ + ) : null} +
Id
+
{claim.id}
+
+
+ +
+
+

Why this claim is here

+

Answer → claim → evidence → source. If a claim cannot show this, it should not be trusted.

+
+ + {sources.length === 0 ? ( +

+ {claim.firstParty + ? "Declared a first-party assertion: no external source, stated explicitly rather than left blank." + : "No sources recorded."} +

+ ) : ( +
+ + + + + + + + + + + + {sources.map((source) => ( + + + + + + + + ))} + +
SourceTypeLicenceRetrievedState
+ + {source!.title ?? source!.id} + + {source!.sourceType}{source!.license ?? "unknown"}{source!.retrievedAt.slice(0, 10)} + {source!.stale ? : } + {source!.stale ? ( + stale + ) : null} +
+
+ )} + + {evidence.length > 0 ? ( +
    + {evidence.map((record) => ( +
  • + {record!.selector.type}{" "} + {JSON.stringify(record!.selector).replace(/[{}"]/g, "")} + {record!.excerpt ? <> — “{record!.excerpt}” : null} +
  • + ))} +
+ ) : null} +
+ +
+
+

History

+

Claims are append-only: a correction adds a transition rather than editing the record.

+
+
+ + + + + + + + + + + {history.map((entry, index) => ( + + + + + + + ))} + +
WhenStatusByReason
{entry.at.slice(0, 19)} + + {entry.by}{entry.reason ?? ""}
+
+
+ +
+

+ Same data over the API:{" "} + + + /api/ontologies/{manifest.id}/claims/{claim.id} + + +

+
+
+ ); +} diff --git a/apps/logicsrc-web/src/app/openontology/explore/entity/[entityId]/page.tsx b/apps/logicsrc-web/src/app/openontology/explore/entity/[entityId]/page.tsx new file mode 100644 index 0000000..0ce999f --- /dev/null +++ b/apps/logicsrc-web/src/app/openontology/explore/entity/[entityId]/page.tsx @@ -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 { + 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 { + 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 ( + +
+

+ + ← Explorer + +

+
+

{entity.type}

+

{entity.canonicalName}

+
+ + {entity.id !== id ? ( +

+ {id} was merged into this entity. The old id still resolves — + merges keep redirects rather than breaking references. +

+ ) : null} + +
+
Id
+
{entity.id}
+
Status
+
+ +
+ {entity.aliases?.length ? ( + <> +
Aliases
+
{entity.aliases.join(", ")}
+ + ) : null} + {entity.externalIds && Object.keys(entity.externalIds).length > 0 ? ( + <> +
External ids
+
+ {Object.entries(entity.externalIds).map(([namespace, value]) => ( + + {namespace}:{value} + + ))} +
+ + ) : null} +
Created
+
+ {entity.createdAt} by {entity.createdBy} +
+
+
+ +
+
+

Claims about this entity

+

+ 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 asserted. +

+
+
+ + + + + + + + + + + + + + {claims.map((claim) => ( + + + + + + + + + + ))} + +
StatusPredicateObjectConfidenceValid fromRecordedSources
+ + + + {claim.predicate} + + + {"entity" in claim.object ? ( + + {claim.object.entity} + + ) : ( + formatObject(claim.object) + )} + + + {claim.validTime?.from?.slice(0, 10) ?? "—"}{claim.assertedAt.slice(0, 10)}{claim.sources?.length ?? 0}
+
+
+ + {incoming.length > 0 ? ( +
+
+

Referenced by

+

Asserted claims elsewhere in the graph that point at this entity.

+
+
+ + + + + + + + + + {incoming.slice(0, 25).map((claim) => ( + + + + + + ))} + +
SubjectPredicateClaim
+ + {engine.store.getEntity(claim.subject)?.canonicalName ?? claim.subject} + + {claim.predicate} + {claim.id} +
+
+
+ ) : null} + + +
+ ); +} diff --git a/apps/logicsrc-web/src/app/openontology/explore/page.tsx b/apps/logicsrc-web/src/app/openontology/explore/page.tsx new file mode 100644 index 0000000..7bf1b27 --- /dev/null +++ b/apps/logicsrc-web/src/app/openontology/explore/page.tsx @@ -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 { + const state = await getService(); + + if (!state.engine) { + return ( + +
+
+

Explorer unavailable

+

{state.error ?? "No ontology package is loaded."}

+
+
+
+ ); + } + + const engine = state.engine; + const manifest = engine.getOntologyManifest(); + const schema = engine.getOntologySchema(); + const entities = engine.store.listEntities(); + + const byType = new Map(); + 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(); + 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 ( + +
+
+

Read-only explorer · fixture data

+

{manifest.name}

+

{manifest.description}

+
+

+ + {manifest.id}@{manifest.version} + {" "} + · {manifest.license} · revision {engine.store.revision()} ·{" "} + {state.persistence === "turso" ? "Turso/libSQL" : "in-memory"} ·{" "} + REST{" "} + OpenAPI +

+

+ Every person, organization, and project below is fictional. The package + exists to demonstrate the contract. +

+
+ +
+
+

Claims by status

+

+ A clean graph makes uncertain things look settled, so status is never hidden. Only{" "} + asserted claims are the current accepted view. +

+
+
+ {CLAIM_STATUS.map((status) => ( +
+ +
+ {byStatus.get(status.id) ?? 0} +
+
{status.meaning}
+
+ ))} +
+ {needsReview.length > 0 ? ( +

+ {needsReview.length} claim{needsReview.length === 1 ? "" : "s"} awaiting a human decision:{" "} + {needsReview.slice(0, 5).map((claim, index) => ( + + {index > 0 ? ", " : ""} + {claim.id} + + ))} +

+ ) : null} +
+ +
+
+

Entity types

+

The identity-bearing nouns of this domain, and how many of each exist.

+
+
+ + + + + + + + + + {schema.entityTypes.map((type) => ( + + + + + + ))} + +
TypeCountDescription
+ + {type.id} + + {byType.get(type.id) ?? 0}{type.description}
+
+
+ +
+
+

Entities

+

Every entity keeps its id when its name changes, and after a merge the old id still resolves.

+
+
+ + + + + + + + + + + {entities.slice(0, 40).map((entity) => ( + + + + + + + ))} + +
NameTypeStatusId
+ + {entity.canonicalName} + + {entity.type} + + {entity.id}
+
+ {entities.length > 40 ? ( +

+ Showing 40 of {entities.length}. The full list is at{" "} + + /api/ontologies/{manifest.id}/entities + + . +

+ ) : null} +
+ +
+
+

Saved queries

+

The questions this ontology already knows how to answer.

+
+
+ {schema.queries.map((query) => ( +
+ {query.label ?? query.id} +
{query.description}
+ + POST /api/ontologies/{manifest.id}/query {"{"} "savedQuery": "{query.id}" {"}"} + +
+ ))} +
+
+
+ ); +} diff --git a/apps/logicsrc-web/src/app/openontology/page.tsx b/apps/logicsrc-web/src/app/openontology/page.tsx index 3141b51..207b9b1 100644 --- a/apps/logicsrc-web/src/app/openontology/page.tsx +++ b/apps/logicsrc-web/src/app/openontology/page.tsx @@ -224,6 +224,14 @@ OpenOntology package is valid.`}

Where everything lives

    +
  • + Explorer — browse the example package: types, + entities, claims with provenance, and the history behind each one +
  • +
  • + REST API — OpenAPI description of the reference + service, with SSE events +
  • Specification — the model, packages, claims, queries, validation, CLI, SDK, conformance diff --git a/apps/logicsrc-web/src/app/openontology/ui.tsx b/apps/logicsrc-web/src/app/openontology/ui.tsx new file mode 100644 index 0000000..374ab4b --- /dev/null +++ b/apps/logicsrc-web/src/app/openontology/ui.tsx @@ -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 = { + 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 ( + + + {status} + + ); +} + +/** Confidence with its number spelled out — never a bare bar. */ +export function Confidence({ value }: { value?: number }): ReactNode { + if (value === undefined) return not stated; + return ( + + {value.toFixed(2)} + + ); +} + +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); +} diff --git a/apps/logicsrc-web/src/app/openprd/page.tsx b/apps/logicsrc-web/src/app/openprd/page.tsx new file mode 100644 index 0000000..d9081cf --- /dev/null +++ b/apps/logicsrc-web/src/app/openprd/page.tsx @@ -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 ( + +
    +
    +

    LogicSRC standards surface

    +

    OpenPRD

    +

    + A lightweight open standard for product requirements documents authored by humans or AI + agents. A repo keeps a numbered, committed collection under prd/ + — one Markdown file per decision, readable a year later. +

    +
    +

    + It borrows the shape of a BIP/EIP/DIP process. Where OpenSpec models a change as a + multi-file bundle, OpenPRD models a product decision as one numbered file you can + read to recover the why. +

    +

    + Status: 0.2. A PRD is just a file — it needs no service, and no tooling, to + be valid. +

    +
    + +
    +
    +

    The shape

    +

    Front-matter, then eight sections in a fixed order. All of them required.

    +
    +
    {`---
    +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`}
    +
    + {SECTIONS.map(([name, detail], index) => ( +
    + + {index + 1}. {name} + +
    {detail}
    +
    + ))} +
    +

    + A section may be a single line such as _None._ — but it may not be + missing. That is what keeps every PRD skimmable and diffable. +

    +
    + +
    +
    +

    Lifecycle, enforced

    +

    Status lives in the front-matter and is the source of truth.

    +
    +
    {`Draft  →  Review  →  Accepted  →  Final
    +                  ↘  Rejected
    +                  ↘  Withdrawn
    +                  ↘  Superseded by NNNN`}
    +
      +
    • + Draft cannot jump to Final — the tool + refuses the transition rather than trusting the author to remember. +
    • +
    • + Rejected, Withdrawn, and{" "} + Superseded are terminal. They stay on disk, because the{" "} + why not is part of the record. +
    • +
    • + Moving to Superseded requires naming the PRD that replaces it. +
    • +
    • Ids are four digits, monotonically increasing, with no gaps. 0000 is the template.
    • +
    +
    + +
    +
    +

    Conformance is four rules

    +

    Everything else the tooling reports is lint, and says so.

    +
    +
      +
    1. + It lives at prd/<id>-<slug>.md with a four-digit id. +
    2. +
    3. + Its front-matter validates against openprd-prd.schema.json. +
    4. +
    5. The id equals the filename's numeric prefix.
    6. +
    7. All eight body sections are present, in order.
    8. +
    +

    + 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{" "} + --strict promotes them. Every finding carries a stable code, the + file, the line, and a remediation hint. +

    +
    + +
    +
    +

    Tooling

    +

    + @logicsrc/openprd implements the standard; the CLI drives it. +

    +
    +
    {`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`}
    +

    + Exit codes are stable for CI: 0 ok, 1{" "} + invalid, 2 usage, 3 not found. +

    +
    + +
    +
    +

    The optional task bridge

    +

    Requirements map onto LogicSRC tasks — in tooling, not in the standard.

    +
    +

    + Each R# becomes one logicsrc.task{" "} + document, validated against its schema before it is emitted. The board defaults to{" "} + /prd/<id>, repo carries over, and the + creator DID is derived from the first author ( + anthony@profullstack.com →{" "} + anthony.profullstack). +

    +

    + Nothing requires you to use it. A PRD with no LogicSRC anywhere near it is still a PRD. +

    +
    + +
    +
    +

    Where everything lives

    +
    +
      +
    • + Specification — layout, front-matter, sections, + lifecycle, conformance, implementation +
    • +
    • + + Front-matter JSON Schema + +
    • +
    • + + Conformance bundle + {" "} + — documents that must validate, and documents that must fail with a named code +
    • +
    • + + This repo's own collection + {" "} + — dogfooded: it validates with zero errors and zero warnings +
    • +
    • + OpenOntology — the companion standard for durable, + source-backed domain knowledge +
    • +
    +
    +
    + ); +} diff --git a/apps/logicsrc-web/src/app/sitemap.ts b/apps/logicsrc-web/src/app/sitemap.ts index 60e4bda..ec26fa3 100644 --- a/apps/logicsrc-web/src/app/sitemap.ts +++ b/apps/logicsrc-web/src/app/sitemap.ts @@ -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 }, diff --git a/apps/logicsrc-web/src/components/site-shell.tsx b/apps/logicsrc-web/src/components/site-shell.tsx index 0885822..57a536a 100644 --- a/apps/logicsrc-web/src/components/site-shell.tsx +++ b/apps/logicsrc-web/src/components/site-shell.tsx @@ -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" }, diff --git a/apps/logicsrc-web/src/lib/ontology-service.ts b/apps/logicsrc-web/src/lib/ontology-service.ts new file mode 100644 index 0000000..7b53735 --- /dev/null +++ b/apps/logicsrc-web/src/lib/ontology-service.ts @@ -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; +} + +export type Role = "reader" | "proposer" | "curator"; + +let cached: Promise | null = null; + +async function build(): Promise { + 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 { + 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(); + +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 = { "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 +): Promise { + 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(); +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> { + try { + return ((await request.json()) ?? {}) as Record; + } catch { + return {}; + } +} diff --git a/apps/logicsrc-web/src/lib/page-markup.ts b/apps/logicsrc-web/src/lib/page-markup.ts index d148c8b..b2536e2 100644 --- a/apps/logicsrc-web/src/lib/page-markup.ts +++ b/apps/logicsrc-web/src/lib/page-markup.ts @@ -126,7 +126,7 @@ export function renderPageMarkup(): string { AgentByte Credentials OpenOntology - OpenPRD + OpenPRD CLI Docs Blog @@ -204,7 +204,7 @@ export function renderPageMarkup(): string {

    An open contract for durable, source-backed domain knowledge shared by humans and AI agents.

    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.

    -

    Read the specification

    +

    Read the specification Explore the example

    Five nouns

    diff --git a/docs/openontology-interoperability.md b/docs/openontology-interoperability.md index 01af393..be421c3 100644 --- a/docs/openontology-interoperability.md +++ b/docs/openontology-interoperability.md @@ -68,15 +68,34 @@ Lookup works by exact id, canonical name, alias, or external id. `findEntities` `sameAs` is a reviewable claim, not an implicit merge. Two records only become one through an approved `merge-entity` operation, and the losing id survives as a redirect. -## RDF, SHACL, OWL +## RDF and Turtle -Planned for a later phase, deliberately not faked in 0.1: +```bash +logicsrc ontology export --dir ./ethereum-ecosystem --format turtle --out graph.ttl +``` -- **RDF/Turtle** — export and import of the losslessly mappable subset, using the same reified-claim shape as JSON-LD. -- **SHACL** — the constraint kinds with genuinely equivalent semantics (`required-predicate`, `cardinality`, `unique`, `allowed-values`, `domain-range`) map to shapes. Query-based constraints do not, and will be reported as unmapped. -- **OWL/RDFS** — an optional mapping for consumers needing formal reasoning. OpenOntology itself infers nothing: transitivity, symmetry, and inverses apply only when the schema declares them *and* a query asks. +Claims are reified, exactly as in JSON-LD, and an **asserted relationship claim additionally emits the plain triple** — so a consumer that only wants the current accepted graph gets one without unpacking provenance. -Until those ship, the compatibility matrix below says "planned", not "supported". Claiming compatibility that has not been implemented and tested is the thing this document exists to prevent. +Import parses the profile this exporter produces rather than pretending to be a general Turtle parser. Anything it cannot interpret is listed in `unsupported`, never dropped silently. + +## SHACL + +```bash +logicsrc ontology export --dir ./ethereum-ecosystem --format shacl --out shapes.ttl +``` + +Five constraint kinds map onto SHACL Core: `required-predicate`, `cardinality`, `allowed-values`, `domain-range`, and `temporal-bounds`. Severity carries across (`error` → `sh:Violation`, `warning` → `sh:Warning`). + +Two do **not**, and are reported as unmapped in the returned value *and* as comments in the generated Turtle: + +- `unique` — graph-wide uniqueness has no portable SHACL Core equivalent; it needs a `sh:SPARQLConstraint`. +- `query` — an OpenOntology saved query is a triple-pattern AST, not SPARQL. + +A shape that silently means something narrower than the constraint it came from is worse than no shape, so those stay unmapped until the mapping is real. + +## OWL/RDFS + +Still planned. An optional mapping for consumers needing formal reasoning. OpenOntology itself infers nothing: transitivity, symmetry, and inverses apply only when the schema declares them *and* a query asks. ## Compatibility matrix @@ -88,14 +107,18 @@ Until those ship, the compatibility matrix below says "planned", not "supported" | NDJSON | **supported** | Streaming entity/claim/source/evidence files | | JSON-LD 1.1 export | **supported** | Reified claims, PROV-O aliases, lossy report | | JSON-LD 1.1 import | **supported** | Round-trips the reference profile | -| PROV-O | **partial** | Provenance terms aliased; full mapping later | -| RDF / Turtle | planned | Phase 3 | -| SHACL | planned | Phase 3, constraint subset only | +| PROV-O | **partial** | Provenance terms aliased in JSON-LD and Turtle; full mapping later | +| RDF / Turtle export | **supported** | Reified claims + plain triples for asserted relationships | +| RDF / Turtle import | **supported** | Round-trips the reference profile; reports what it cannot read | +| SHACL | **partial** | 5 of 7 constraint kinds; `unique` and `query` reported as unmapped | | OWL / RDFS | planned | Optional, for external reasoners | | SPARQL | planned | Query AST → SPARQL adapter | | Cypher | planned | Query AST → Cypher adapter | | Datalog | planned | Query AST → Datalog adapter | -| SQLite / Turso | **supported** | Reference storage adapters | +| SQLite / Turso | **supported** | `createLibsqlStore`, versioned migrations, FTS5 entity search | +| REST + OpenAPI | **supported** | 16 paths, described at `/api/ontologies/openapi` | +| Server-Sent Events | **supported** | Same event objects as the JSON endpoint | +| MCP | **supported** | Resources, tools, and prompts; writes propose, never apply | | Neo4j / vector DBs | not required | Optional adapters; never mandatory | ## Query portability diff --git a/docs/openontology.md b/docs/openontology.md index ce929f7..9c2c63e 100644 --- a/docs/openontology.md +++ b/docs/openontology.md @@ -272,9 +272,11 @@ logicsrc ontology entity get|list|find|merge logicsrc ontology claim get|list|history|propose|assert|dispute|retract logicsrc ontology query run|explain|list logicsrc ontology changeset list|create|diff|apply -logicsrc ontology import|export|audit +logicsrc ontology import|export|audit|tui ``` +`logicsrc ontology tui` renders keyboard-first panels — types, entities, claims, sources, queries, change sets, validation, audit — as plain strings that survive SSH, tmux, and a 60-column terminal. Claim status is shown as a glyph *and* the word, never colour alone. + Read commands take `--format table|json|yaml|markdown|ndjson`. Write commands produce a **proposal** by default. Exit codes are stable for CI: `0` ok, `1` validation failed, `2` usage error, `3` not found, `4` denied or approval required. `--as local|agent|reader` selects the actor role. It cannot grant an agent apply rights; the policy layer denies those outright. @@ -302,9 +304,49 @@ engine.applyOntologyChangeSet(changeSet.id); Storage, source adapters, query engine, identity, policy, events, and signatures are all injectable interfaces. The clock and id factory are injectable too, so a build, an applied change set, and a test run produce byte-identical output under both Node.js and Bun. +## Storage + +The store is an interface, so nothing about the model depends on where it lives. + +```ts +import { createClient } from "@libsql/client"; +import { createLibsqlStore, createOntologyEngine } from "@logicsrc/openontology"; + +const store = await createLibsqlStore({ client: createClient({ url }), seed: pkg }); +const engine = createOntologyEngine({ store, actor }); +// … apply a change set … +await store.flush(); // one transaction, then it is durable +``` + +The libSQL adapter hydrates the read model at open, serves reads synchronously, and buffers mutations as SQL that `flush()` writes in a single transaction. Callers that mutate must await it — the REST layer does after every applied change set. Everything persisted is append-only, so a crash before flush loses the last change set rather than corrupting history. + +Migrations are versioned and idempotent. Indexes cover entity ids, types, aliases, external ids, subject, predicate, entity-valued object, status, valid time, recorded time, and sources; FTS5 backs label and alias search. + +## REST, SSE, and MCP + +The reference service is described by OpenAPI at `/api/ontologies/openapi` and shares the published JSON Schemas rather than restating them. + +```txt +GET /api/ontologies +GET /api/ontologies/{id}/manifest | /schema | /entities | /entities/{entityId} +GET /api/ontologies/{id}/claims | /claims/{claimId} +POST /api/ontologies/{id}/query | /explain | /validate +GET /api/ontologies/{id}/changesets POST to propose +GET /api/ontologies/{id}/changesets/{id} POST .../review | /approve | /apply +GET /api/ontologies/{id}/events Accept: text/event-stream for SSE +``` + +No token is read-only. A curator token can apply; an agent token can propose and **cannot** apply. Mutating requests accept `Idempotency-Key`; a change set authored against a stale revision fails with 409 rather than overwriting. + +The MCP server exposes the spec, manifest, schema, and saved queries as resources, plus tools for validate, get/find entities, query, explain, export, and propose. Write tools default to proposals, and applying is denied to agent actors by the same policy the SDK enforces — not by a separate rule that could drift. + +## Ingestion + +Seven source adapters — CSV, JSON, YAML, NDJSON, Markdown, a generic JSON HTTP endpoint, and GitHub — turn foreign data into **proposed** change-set operations with sources and evidence selectors attached. Each declares what it can and cannot do, so "nothing was deleted upstream" is never confused with "this adapter cannot see deletions." See [interoperability](./openontology-interoperability.md#source-adapters). + ## Interoperability -JSON Schema Draft 2020-12 is the canonical contract. JSON-LD 1.1 is the interoperability profile, aliasing W3C PROV-O for provenance where the semantics genuinely match. Exports report every field the target format cannot carry rather than dropping it silently. See [OpenOntology interoperability](./openontology-interoperability.md). +JSON Schema Draft 2020-12 is the canonical contract. JSON-LD 1.1 and RDF/Turtle are interoperability profiles, aliasing W3C PROV-O for provenance where the semantics genuinely match, and SHACL covers five of the seven constraint kinds. Exports report every field the target format cannot carry rather than dropping it silently. See [OpenOntology interoperability](./openontology-interoperability.md). ## Conformance diff --git a/package-lock.json b/package-lock.json index 9be9042..607511f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -136,6 +136,8 @@ "name": "@logicsrc/web", "version": "0.1.0", "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", @@ -1853,6 +1855,64 @@ "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", "license": "MIT" }, + "node_modules/@libsql/client": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.17.4.tgz", + "integrity": "sha512-lYayFWasDV78A+TjlEhr6ubb3odBV6OHjb+wdp8VQcyWWAEIjuwbCHaraEUS4m4yWoo0BvZo96It4VdzZRmRWw==", + "license": "MIT", + "dependencies": { + "@libsql/core": "^0.17.4", + "@libsql/hrana-client": "^0.10.0", + "js-base64": "^3.7.5", + "libsql": "^0.5.28", + "promise-limit": "^2.7.0" + } + }, + "node_modules/@libsql/core": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@libsql/core/-/core-0.17.4.tgz", + "integrity": "sha512-LqF9gIvnJ38nmAH1y/ChizHqDO/MO1wLgA96XrraulEEbqXxLjleSH92YWTolbuJKgPUmGu4aJk9W3UnAcxLOQ==", + "license": "MIT", + "dependencies": { + "js-base64": "^3.7.5" + } + }, + "node_modules/@libsql/darwin-arm64": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/darwin-arm64/-/darwin-arm64-0.5.29.tgz", + "integrity": "sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/darwin-x64": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/darwin-x64/-/darwin-x64-0.5.29.tgz", + "integrity": "sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/hrana-client": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@libsql/hrana-client/-/hrana-client-0.10.0.tgz", + "integrity": "sha512-OoA4EMqRAC7kn7V2P6EQqRcpZf2W+AjsNIyCizBg339Tq/aMC7sRnzs3SklderhmQWAqEzvv8A2vhxVmWpkVvw==", + "license": "MIT", + "dependencies": { + "@libsql/isomorphic-ws": "^0.1.5", + "js-base64": "^3.7.5" + } + }, "node_modules/@libsql/isomorphic-fetch": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/@libsql/isomorphic-fetch/-/isomorphic-fetch-0.3.1.tgz", @@ -1872,6 +1932,97 @@ "ws": "^8.13.0" } }, + "node_modules/@libsql/linux-arm-gnueabihf": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm-gnueabihf/-/linux-arm-gnueabihf-0.5.29.tgz", + "integrity": "sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm-musleabihf": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm-musleabihf/-/linux-arm-musleabihf-0.5.29.tgz", + "integrity": "sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm64-gnu": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-gnu/-/linux-arm64-gnu-0.5.29.tgz", + "integrity": "sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm64-musl": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-musl/-/linux-arm64-musl-0.5.29.tgz", + "integrity": "sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-gnu": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-gnu/-/linux-x64-gnu-0.5.29.tgz", + "integrity": "sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-musl": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-musl/-/linux-x64-musl-0.5.29.tgz", + "integrity": "sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/win32-x64-msvc": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.5.29.tgz", + "integrity": "sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@logicsrc/account-core": { "resolved": "packages/account-core", "link": true @@ -4873,6 +5024,47 @@ "libsodium": "^0.7.16" } }, + "node_modules/libsql": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/libsql/-/libsql-0.5.29.tgz", + "integrity": "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==", + "cpu": [ + "x64", + "arm64", + "wasm32", + "arm" + ], + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "@neon-rs/load": "^0.0.4", + "detect-libc": "2.0.2" + }, + "optionalDependencies": { + "@libsql/darwin-arm64": "0.5.29", + "@libsql/darwin-x64": "0.5.29", + "@libsql/linux-arm-gnueabihf": "0.5.29", + "@libsql/linux-arm-musleabihf": "0.5.29", + "@libsql/linux-arm64-gnu": "0.5.29", + "@libsql/linux-arm64-musl": "0.5.29", + "@libsql/linux-x64-gnu": "0.5.29", + "@libsql/linux-x64-musl": "0.5.29", + "@libsql/win32-x64-msvc": "0.5.29" + } + }, + "node_modules/libsql/node_modules/detect-libc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -7589,6 +7781,8 @@ "name": "@profullstack/logicsrc-mcp", "version": "0.1.0", "dependencies": { + "@logicsrc/openontology": "file:../openontology", + "@logicsrc/openprd": "file:../openprd", "@logicsrc/validators": "file:../validators", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.1.13" @@ -7605,6 +7799,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@libsql/client": "^0.17.4", "@logicsrc/validators": "file:../validators", "yaml": "^2.8.1" }, @@ -7650,6 +7845,7 @@ "name": "@logicsrc/tui", "version": "0.1.0", "dependencies": { + "@logicsrc/openontology": "file:../openontology", "@logicsrc/plugin-coinpay": "file:../../plugins/coinpay", "@logicsrc/plugin-core": "file:../plugin-core", "@logicsrc/plugin-email-accounts": "file:../../plugins/email-accounts", diff --git a/packages/cli/src/ontology.ts b/packages/cli/src/ontology.ts index d97c869..17d38c1 100644 --- a/packages/cli/src/ontology.ts +++ b/packages/cli/src/ontology.ts @@ -2,6 +2,7 @@ import { readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { Command } from "commander"; import { parse as parseYaml, stringify as toYaml } from "yaml"; +import { renderOntologyKeyHelp, renderOntologyTui, type OntologyPanel } from "@logicsrc/tui"; import { buildOntologyPackage, createOntologyEngine, @@ -687,6 +688,26 @@ export function registerOntologyCommands(program: Command): void { ); }); + actorOptions( + ontology + .command("tui") + .option("--dir ", "package directory", ".") + .option("--panel ", "types|entities|claims|sources|queries|changesets|violations|audit", "entities") + .option("--width ", "terminal width", String(process.stdout.columns || 78)) + .option("--rows ", "detail rows", "10") + .description("Render the keyboard-first ontology panels.") + ).action((options) => { + const e = openEngine(options.dir, options); + console.log( + renderOntologyTui(e, { + panel: options.panel as OntologyPanel, + width: Number.parseInt(options.width, 10), + rows: Number.parseInt(options.rows, 10) + }) + ); + console.log(renderOntologyKeyHelp()); + }); + actorOptions( ontology .command("audit") diff --git a/packages/logicsrc-mcp/package.json b/packages/logicsrc-mcp/package.json index d62de69..eb664c8 100644 --- a/packages/logicsrc-mcp/package.json +++ b/packages/logicsrc-mcp/package.json @@ -1,7 +1,7 @@ { "name": "@profullstack/logicsrc-mcp", "version": "0.1.0", - "description": "MCP server for LogicSRC standards, schemas, prompts, and validators.", + "description": "MCP server for LogicSRC standards, schemas, prompts, validators, OpenOntology, and OpenPRD.", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -13,6 +13,8 @@ "test": "vitest run src" }, "dependencies": { + "@logicsrc/openontology": "file:../openontology", + "@logicsrc/openprd": "file:../openprd", "@logicsrc/validators": "file:../validators", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.1.13" diff --git a/packages/logicsrc-mcp/src/openontology.ts b/packages/logicsrc-mcp/src/openontology.ts new file mode 100644 index 0000000..103643a --- /dev/null +++ b/packages/logicsrc-mcp/src/openontology.ts @@ -0,0 +1,564 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { + createOntologyEngine, + exportJsonLd, + exportTurtle, + constraintsToShacl, + loadOntologyPackage, + readOnlyActor, + proposerActor, + renderReport, + type OntologyEngine +} from "@logicsrc/openontology"; + +/** + * OpenOntology surface for the MCP server. + * + * Read tools are always available. Write tools exist but are capability-scoped + * and default to *proposal* behaviour (R158): the server runs as a read-only + * actor unless OPENONTOLOGY_MCP_WRITABLE is set, and even then an agent actor + * can only propose — applying is denied by the policy layer, not by this file. + */ + +const SPEC = `# LogicSRC OpenOntology + +An open contract for durable, source-backed domain knowledge shared by humans +and AI agents. Five nouns: Type, Entity, Claim, Source, Change set. + +- Claims are the canonical fact record and are append-only. A correction is a + dispute, retraction, or supersession — never an edit. +- Every claim carries status, confidence, valid time, recorded time, and the + sources it rests on. Confidence is metadata, never permission. +- Queries use a portable triple-pattern AST with asOf and per-status filtering, + and every answer can be traced to the claims, evidence, and sources behind it. +- Agents propose; humans apply. An agent holding every scope still cannot apply. + +Full specification: https://logicsrc.com/docs/openontology`; + +interface OntologyContext { + engine: OntologyEngine | null; + packageDir: string | null; + error: string | null; + writable: boolean; +} + +function loadContext(): OntologyContext { + const dir = process.env.OPENONTOLOGY_PACKAGE ?? null; + const writable = process.env.OPENONTOLOGY_MCP_WRITABLE === "1"; + + if (!dir) { + return { engine: null, packageDir: null, writable, error: "OPENONTOLOGY_PACKAGE is not set" }; + } + const path = resolve(dir); + if (!existsSync(path)) { + return { engine: null, packageDir: path, writable, error: `${path} does not exist` }; + } + + try { + const pkg = loadOntologyPackage(path); + const actor = writable ? proposerActor("agent:mcp") : readOnlyActor("agent:mcp"); + return { + engine: createOntologyEngine({ package: pkg, actor, client: "logicsrc-mcp" }), + packageDir: path, + writable, + error: null + }; + } catch (error) { + return { engine: null, packageDir: path, writable, error: (error as Error).message }; + } +} + +function textResult(text: string) { + return { content: [{ type: "text" as const, text }] }; +} + +function errorResult(text: string) { + return { content: [{ type: "text" as const, text }], isError: true }; +} + +export function registerOpenOntology(server: McpServer): void { + const context = loadContext(); + + const requireEngine = (): OntologyEngine | null => context.engine; + + const notConfigured = () => + errorResult( + `No ontology package is loaded: ${context.error ?? "unknown reason"}. ` + + "Set OPENONTOLOGY_PACKAGE to a directory containing openontology.yaml." + ); + + /* ── resources ─────────────────────────────────────────────────────── */ + + server.registerResource( + "openontology-spec", + "logicsrc://openontology/spec", + { + title: "LogicSRC OpenOntology specification", + description: "The OpenOntology model in brief: five nouns, claims, provenance, governance.", + mimeType: "text/markdown" + }, + async () => ({ + contents: [{ uri: "logicsrc://openontology/spec", mimeType: "text/markdown", text: SPEC }] + }) + ); + + if (context.engine) { + const engine = context.engine; + const manifest = engine.getOntologyManifest(); + + server.registerResource( + "openontology-manifest", + `ontology://${manifest.id}/manifest`, + { + title: `${manifest.name} manifest`, + description: "Package identity, namespace, licence, and maintainers.", + mimeType: "application/json" + }, + async () => ({ + contents: [ + { + uri: `ontology://${manifest.id}/manifest`, + mimeType: "application/json", + text: JSON.stringify(manifest, null, 2) + } + ] + }) + ); + + server.registerResource( + "openontology-schema", + `ontology://${manifest.id}/schema`, + { + title: `${manifest.name} schema`, + description: "Entity types, properties, relationship types, constraints, and saved queries.", + mimeType: "application/json" + }, + async () => ({ + contents: [ + { + uri: `ontology://${manifest.id}/schema`, + mimeType: "application/json", + text: JSON.stringify(engine.getOntologySchema(), null, 2) + } + ] + }) + ); + + server.registerResource( + "openontology-queries", + `ontology://${manifest.id}/queries`, + { + title: `${manifest.name} saved queries`, + description: "The questions this ontology already knows how to answer.", + mimeType: "application/json" + }, + async () => ({ + contents: [ + { + uri: `ontology://${manifest.id}/queries`, + mimeType: "application/json", + text: JSON.stringify(engine.getOntologySchema().queries, null, 2) + } + ] + }) + ); + } + + /* ── read tools ────────────────────────────────────────────────────── */ + + server.registerTool( + "ontology_status", + { + title: "OpenOntology status", + description: "Reports which ontology package this server has loaded and whether writes are enabled.", + annotations: { readOnlyHint: true, openWorldHint: false } + }, + async () => + textResult( + JSON.stringify( + { + loaded: Boolean(context.engine), + packageDir: context.packageDir, + error: context.error, + writable: context.writable, + note: "Write tools propose change sets; applying is denied to agent actors by policy." + }, + null, + 2 + ) + ) + ); + + server.registerTool( + "ontology_validate", + { + title: "Validate the loaded ontology package", + description: "Runs schema, graph, provenance, policy, and constraint validation.", + inputSchema: { strict: z.boolean().optional().describe("Treat unknown types and predicates as errors.") }, + annotations: { readOnlyHint: true, openWorldHint: false } + }, + async ({ strict }) => { + const engine = requireEngine(); + if (!engine) return notConfigured(); + const report = engine.validateOntologyPackage({ strict: strict === true }); + return textResult(renderReport(report, "markdown")); + } + ); + + server.registerTool( + "ontology_get_entity", + { + title: "Get an entity", + description: "Fetches one entity by id, following merge redirects.", + inputSchema: { id: z.string().describe("Entity id, e.g. eth:person:avery-lindqvist") }, + annotations: { readOnlyHint: true, openWorldHint: false } + }, + async ({ id }) => { + const engine = requireEngine(); + if (!engine) return notConfigured(); + try { + return textResult(JSON.stringify(engine.getEntity(id), null, 2)); + } catch (error) { + return errorResult((error as Error).message); + } + } + ); + + server.registerTool( + "ontology_find_entities", + { + title: "Find entities", + description: "Ranked candidate matches with the evidence for each — never a silent single match.", + inputSchema: { + text: z.string().optional(), + type: z.string().optional(), + limit: z.number().int().positive().max(100).optional() + }, + annotations: { readOnlyHint: true, openWorldHint: false } + }, + async ({ text, type, limit }) => { + const engine = requireEngine(); + if (!engine) return notConfigured(); + const matches = engine.findEntities({ text, type, limit }); + return textResult( + JSON.stringify( + matches.map((match) => ({ + id: match.entity.id, + name: match.entity.canonicalName, + type: match.entity.type, + score: match.score, + matchedOn: match.matchedOn, + evidence: match.evidence + })), + null, + 2 + ) + ); + } + ); + + server.registerTool( + "ontology_query", + { + title: "Run a portable query", + description: + "Runs a saved query by id, or an ad-hoc triple-pattern query. Results include the claim ids behind each row.", + inputSchema: { + savedQuery: z.string().optional().describe("Saved query id."), + query: z.string().optional().describe("JSON query body with match/where/select/include."), + asOf: z.string().optional().describe("ISO instant to evaluate domain valid time against."), + limit: z.number().int().positive().max(500).optional() + }, + annotations: { readOnlyHint: true, openWorldHint: false } + }, + async ({ savedQuery, query, asOf, limit }) => { + const engine = requireEngine(); + if (!engine) return notConfigured(); + if (!savedQuery && !query) return errorResult("Provide savedQuery or query."); + + try { + const body = savedQuery + ? savedQuery + : ({ ...(JSON.parse(query as string) as Record) } as never); + const result = engine.queryOntology(body as never); + const rows = result.rows.slice(0, limit ?? 50); + const manifest = engine.getOntologyManifest(); + + // R160: factual answers carry the ontology version and claim ids. + return textResult( + JSON.stringify( + { + ontology: `${manifest.id}@${manifest.version}`, + resultId: result.id, + columns: result.columns, + claimStatus: result.explanation.claimStatus, + asOf: asOf ?? result.explanation.asOf ?? null, + rows: rows.map((row) => ({ ...row.bindings, claims: row.claims })), + truncated: result.explanation.truncated || rows.length < result.rows.length + }, + null, + 2 + ) + ); + } catch (error) { + return errorResult((error as Error).message); + } + } + ); + + server.registerTool( + "ontology_explain", + { + title: "Explain an answer", + description: "Traces one result row to its claims, evidence, sources, and status history.", + inputSchema: { + resultId: z.string().describe("resultId returned by ontology_query."), + row: z.number().int().nonnegative().optional() + }, + annotations: { readOnlyHint: true, openWorldHint: false } + }, + async ({ resultId, row }) => { + const engine = requireEngine(); + if (!engine) return notConfigured(); + try { + return textResult(JSON.stringify(engine.explainOntologyResult(resultId, row ?? 0), null, 2)); + } catch (error) { + return errorResult((error as Error).message); + } + } + ); + + server.registerTool( + "ontology_export", + { + title: "Export the ontology", + description: "Exports as JSON-LD, RDF/Turtle, or SHACL shapes, reporting anything the format cannot carry.", + inputSchema: { format: z.enum(["jsonld", "turtle", "shacl"]) }, + annotations: { readOnlyHint: true, openWorldHint: false } + }, + async ({ format }) => { + const engine = requireEngine(); + if (!engine) return notConfigured(); + const pkg = engine.buildOntologyPackage(); + + if (format === "turtle") { + const exported = exportTurtle(pkg); + return textResult( + `${exported.turtle}\n# lossy: ${exported.lossy.length} object(s) carry fields Turtle cannot express` + ); + } + if (format === "shacl") { + const exported = constraintsToShacl(pkg); + return textResult(exported.turtle); + } + const exported = exportJsonLd(pkg); + return textResult( + JSON.stringify({ document: exported.document, lossy: exported.lossy }, null, 2) + ); + } + ); + + /* ── write tools (proposal-only) ───────────────────────────────────── */ + + server.registerTool( + "ontology_propose_claim", + { + title: "Propose a claim", + description: + "Creates a PROPOSED change set asserting one claim. Never applies it — a human with write scope does that.", + inputSchema: { + subject: z.string(), + predicate: z.string(), + objectEntity: z.string().optional(), + objectValue: z.string().optional(), + source: z.string().optional().describe("Source id backing the claim."), + confidence: z.number().min(0).max(1).optional(), + runId: z.string().optional(), + rationale: z.string().optional() + }, + annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false } + }, + async (input) => { + const engine = requireEngine(); + if (!engine) return notConfigured(); + if (!input.objectEntity && input.objectValue === undefined) { + return errorResult("Provide objectEntity or objectValue."); + } + + try { + const changeSet = engine.createOntologyChangeSet({ + title: `${input.subject} ${input.predicate} ${input.objectEntity ?? input.objectValue}`, + rationale: input.rationale, + runId: input.runId, + operations: [ + { + op: "assert-claim", + value: { + subject: input.subject, + predicate: input.predicate, + object: input.objectEntity ? { entity: input.objectEntity } : { value: input.objectValue }, + ...(input.source ? { sources: [input.source] } : {}), + ...(input.confidence !== undefined ? { confidence: input.confidence } : {}) + } + } + ] + }); + return textResult( + JSON.stringify( + { changeSet: changeSet.id, status: changeSet.status, requiredApprovals: changeSet.requiredApprovals }, + null, + 2 + ) + ); + } catch (error) { + return errorResult((error as Error).message); + } + } + ); + + server.registerTool( + "ontology_create_changeset", + { + title: "Create a change set", + description: "Creates a PROPOSED change set from a JSON array of operations.", + inputSchema: { + title: z.string(), + operations: z.string().describe("JSON array of change-set operations."), + rationale: z.string().optional(), + runId: z.string().optional() + }, + annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false } + }, + async ({ title, operations, rationale, runId }) => { + const engine = requireEngine(); + if (!engine) return notConfigured(); + try { + const parsed = JSON.parse(operations) as never[]; + const changeSet = engine.createOntologyChangeSet({ title, rationale, runId, operations: parsed }); + const diff = engine.diffOntologyChangeSet(changeSet.id); + return textResult(JSON.stringify({ changeSet: changeSet.id, status: changeSet.status, diff }, null, 2)); + } catch (error) { + return errorResult((error as Error).message); + } + } + ); + + server.registerTool( + "ontology_validate_changeset", + { + title: "Validate a change set", + description: "Validates the package as it would look after a change set applies.", + inputSchema: { changeSet: z.string() }, + annotations: { readOnlyHint: true, openWorldHint: false } + }, + async ({ changeSet }) => { + const engine = requireEngine(); + if (!engine) return notConfigured(); + try { + return textResult(renderReport(engine.validateOntologyChangeSet(changeSet), "markdown")); + } catch (error) { + return errorResult((error as Error).message); + } + } + ); + + server.registerTool( + "ontology_apply_changeset", + { + title: "Apply a change set", + description: + "Applies an approved change set. Requires write scope and approvals; agent actors are denied by policy.", + inputSchema: { changeSet: z.string() }, + annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false } + }, + async ({ changeSet }) => { + const engine = requireEngine(); + if (!engine) return notConfigured(); + try { + const applied = engine.applyOntologyChangeSet(changeSet); + return textResult( + JSON.stringify({ changeSet: applied.changeSet.id, revision: applied.revision }, null, 2) + ); + } catch (error) { + // The denial is the point: surface it verbatim. + return errorResult(`Not applied: ${(error as Error).message}`); + } + } + ); + + /* ── prompts ───────────────────────────────────────────────────────── */ + + const prompts: Array<[string, string, string]> = [ + [ + "design_ontology", + "Design an OpenOntology package for a domain", + `Design a LogicSRC OpenOntology package for the domain the user describes. + +Produce entity types, properties, and relationship types first — nouns before facts. +For each relationship type declare from/to, cardinality, and whether it is temporal. +Do not infer transitivity, symmetry, or inverses unless you declare them. +Then propose 3-5 saved queries that answer the questions people actually ask. +Return YAML matching the OpenOntology schemas.` + ], + [ + "map_sources_to_claims", + "Turn source material into proposed claims", + `Read the source material and produce PROPOSED claims. + +Every claim needs: subject, predicate, a typed object, a source id, and an evidence +selector pointing at the exact location it came from. Set confidence honestly — it is +metadata, not persuasion. Never invent an entity id you have not seen; propose a new +entity explicitly instead. Output change-set operations, not applied state.` + ], + [ + "resolve_entities", + "Decide whether two entity records are the same thing", + `Compare the candidate entity records. + +List the evidence for and against them being the same thing. Weigh stable external ids +above name similarity. Recommend merge, keep-separate, or needs-more-evidence — and say +which. Remember that merging two different people is worse than keeping duplicates, and +that a merge needs curator approval.` + ], + [ + "review_ontology_changeset", + "Review a proposed change set", + `Review this OpenOntology change set as a curator. + +Check: does each claim cite a source? Do the domain and range hold? Is the confidence +justified by the evidence? Does any add-entity duplicate something already present? +Are merges and retractions reversible and justified? Report per-operation accept/reject +with reasons, then an overall recommendation.` + ], + [ + "explain_ontology_answer", + "Explain why the ontology returned an answer", + `Explain this query result to someone who does not trust it yet. + +Walk from the answer to the claims that produced it, then to the evidence and sources. +State the claim statuses included, the asOf time, and any filters applied. Name what is +uncertain — low confidence, a single source, a stale source, a dispute — rather than +presenting the row as settled fact.` + ] + ]; + + for (const [name, title, text] of prompts) { + server.registerPrompt( + name, + { title, description: title }, + async () => ({ messages: [{ role: "user" as const, content: { type: "text" as const, text } }] }) + ); + } +} + +/** Read the packaged spec doc when it is available on disk. */ +export function readSpecDoc(path: string): string | null { + try { + return existsSync(path) ? readFileSync(path, "utf8") : null; + } catch { + return null; + } +} diff --git a/packages/logicsrc-mcp/src/openprd.ts b/packages/logicsrc-mcp/src/openprd.ts new file mode 100644 index 0000000..e7140c5 --- /dev/null +++ b/packages/logicsrc-mcp/src/openprd.ts @@ -0,0 +1,278 @@ +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { + findPrd, + loadPrdCollection, + nextPrdNumber, + nextStatuses, + prdToTasks, + renderDocument, + renderIndex, + renderReport, + summarize, + validatePrdCollection, + validateTasks, + type PrdCollection +} from "@logicsrc/openprd"; + +/** + * OpenPRD surface for the MCP server. + * + * Everything here is read-only. Creating or moving a PRD writes to a repo, and + * that belongs to the CLI where a human sees the diff — not to a tool an agent + * can call unattended. + */ + +const SPEC = `# OpenPRD + +A lightweight standard for product requirements documents. A repo keeps a +numbered, committed collection under prd/, one Markdown file each. + +- One file per PRD: prd/-.md, four-digit ids, no gaps, 0000 reserved + for the template. +- Front-matter carries openprd, id, title, status, authors, and optional repo, + dates, discussion, implementation, tags, supersedes, superseded-by. +- The body has eight required sections in order: Problem, Goals, Non-Goals, + Users, Requirements, UX Notes, Success Metrics, Risks & Open Questions. +- Requirements are numbered R1, R2, … each tagged [P0], [P1], or [P2]. +- Lifecycle: Draft → Review → Accepted → Final, or Rejected / Withdrawn / + Superseded. Status lives in front-matter and is the source of truth. + +Full specification: https://logicsrc.com/docs/openprd`; + +function textResult(text: string) { + return { content: [{ type: "text" as const, text }] }; +} + +function errorResult(text: string) { + return { content: [{ type: "text" as const, text }], isError: true }; +} + +function openCollection(): { collection: PrdCollection | null; dir: string | null; error: string | null } { + const dir = process.env.OPENPRD_DIR ?? "./prd"; + const path = resolve(dir); + if (!existsSync(path)) { + return { collection: null, dir: path, error: `${path} does not exist; set OPENPRD_DIR` }; + } + try { + return { collection: loadPrdCollection(path), dir: path, error: null }; + } catch (error) { + return { collection: null, dir: path, error: (error as Error).message }; + } +} + +export function registerOpenPrd(server: McpServer): void { + server.registerResource( + "openprd-spec", + "logicsrc://openprd/spec", + { + title: "OpenPRD specification", + description: "Numbered PRDs: layout, front-matter, the eight sections, and the lifecycle.", + mimeType: "text/markdown" + }, + async () => ({ + contents: [{ uri: "logicsrc://openprd/spec", mimeType: "text/markdown", text: SPEC }] + }) + ); + + server.registerResource( + "openprd-index", + "prd://index", + { + title: "PRD index", + description: "The current collection index, generated from the PRDs on disk.", + mimeType: "text/markdown" + }, + async () => { + const { collection, error } = openCollection(); + const text = collection ? renderIndex(collection) : `No PRD collection: ${error}`; + return { contents: [{ uri: "prd://index", mimeType: "text/markdown", text }] }; + } + ); + + server.registerTool( + "prd_list", + { + title: "List PRDs", + description: "Lists the PRDs in the collection with id, title, status, tags, and requirement count.", + inputSchema: { status: z.string().optional().describe("Comma-separated statuses to include.") }, + annotations: { readOnlyHint: true, openWorldHint: false } + }, + async ({ status }) => { + const { collection, error } = openCollection(); + if (!collection) return errorResult(error ?? "no collection"); + const wanted = status?.split(",").map((value) => value.trim()); + const rows = collection.documents + .map(summarize) + .filter((row) => !wanted || wanted.includes(row.status)); + return textResult(JSON.stringify(rows, null, 2)); + } + ); + + server.registerTool( + "prd_show", + { + title: "Show a PRD", + description: "Shows one PRD's front-matter, sections, and parsed requirements.", + inputSchema: { + ref: z.string().describe("Id, number, slug, or filename."), + format: z.enum(["text", "json", "markdown"]).optional() + }, + annotations: { readOnlyHint: true, openWorldHint: false } + }, + async ({ ref, format }) => { + const { collection, error } = openCollection(); + if (!collection) return errorResult(error ?? "no collection"); + const doc = findPrd(collection, ref); + if (!doc) return errorResult(`No PRD matching "${ref}"`); + return textResult(renderDocument(doc, format ?? "text")); + } + ); + + server.registerTool( + "prd_validate", + { + title: "Validate the PRD collection", + description: + "Checks conformance — filename, front-matter, id match, the eight sections in order — plus collection rules.", + inputSchema: { strict: z.boolean().optional() }, + annotations: { readOnlyHint: true, openWorldHint: false } + }, + async ({ strict }) => { + const { collection, error } = openCollection(); + if (!collection) return errorResult(error ?? "no collection"); + const report = validatePrdCollection(collection, { + strict: strict === true, + expectedIndex: renderIndex(collection) + }); + return textResult(renderReport(report, "markdown")); + } + ); + + server.registerTool( + "prd_next_id", + { + title: "Next free PRD id", + description: "Returns the next four-digit id. Numbers are assigned at creation, never reserved.", + annotations: { readOnlyHint: true, openWorldHint: false } + }, + async () => { + const { collection, error } = openCollection(); + if (!collection) return errorResult(error ?? "no collection"); + return textResult(nextPrdNumber(collection)); + } + ); + + server.registerTool( + "prd_next_statuses", + { + title: "Allowed lifecycle moves", + description: "Given a PRD, lists the statuses it may legally move to next.", + inputSchema: { ref: z.string() }, + annotations: { readOnlyHint: true, openWorldHint: false } + }, + async ({ ref }) => { + const { collection, error } = openCollection(); + if (!collection) return errorResult(error ?? "no collection"); + const doc = findPrd(collection, ref); + if (!doc) return errorResult(`No PRD matching "${ref}"`); + const allowed = nextStatuses(doc.frontMatter.status); + return textResult( + JSON.stringify( + { + id: doc.frontMatter.id, + status: doc.frontMatter.status, + allowedNext: allowed, + terminal: allowed.length === 0 + }, + null, + 2 + ) + ); + } + ); + + server.registerTool( + "prd_tasks", + { + title: "Map requirements to LogicSRC tasks", + description: "Turns each R# into one logicsrc.task document, validated before it is returned.", + inputSchema: { + ref: z.string(), + priority: z.string().optional().describe("Comma-separated priorities, e.g. P0,P1"), + creator: z.string().optional() + }, + annotations: { readOnlyHint: true, openWorldHint: false } + }, + async ({ ref, priority, creator }) => { + const { collection, error } = openCollection(); + if (!collection) return errorResult(error ?? "no collection"); + const doc = findPrd(collection, ref); + if (!doc) return errorResult(`No PRD matching "${ref}"`); + + const priorities = priority?.split(",").map((value) => value.trim()) as + | Array<"P0" | "P1" | "P2"> + | undefined; + const { tasks, skipped } = prdToTasks(doc, { creator, priorities }); + const problems = validateTasks(tasks); + if (problems.length > 0) { + return errorResult(`Generated tasks failed validation: ${JSON.stringify(problems, null, 2)}`); + } + return textResult(JSON.stringify({ tasks, skipped }, null, 2)); + } + ); + + server.registerPrompt( + "write_prd", + { title: "Draft an OpenPRD document", description: "Draft a conforming PRD for a product decision." }, + async () => ({ + messages: [ + { + role: "user" as const, + content: { + type: "text" as const, + text: `Draft an OpenPRD document for the change the user describes. + +Front-matter: openprd "0.2", a four-digit id matching the filename, an imperative +title starting with a verb, status Draft, and at least one author. + +Then all eight sections, in this order, none omitted: +Problem, Goals, Non-Goals, Users, Requirements, UX Notes, Success Metrics, +Risks & Open Questions. A section may be a single line such as _None._ + +Requirements are numbered R1, R2, … contiguously, each tagged [P0], [P1], or [P2], +one capability per line. Goals are outcomes, not features. Non-Goals bound the work. +Risks & Open Questions must name the decisions still owed rather than pretending +they are settled.` + } + } + ] + }) + ); + + server.registerPrompt( + "review_prd", + { title: "Review a PRD", description: "Review a PRD for shape, clarity, and honesty." }, + async () => ({ + messages: [ + { + role: "user" as const, + content: { + type: "text" as const, + text: `Review this PRD. + +Check the shape first: all eight sections present and in order, requirements numbered +contiguously with priority tags, front-matter complete. + +Then the substance: are the Goals outcomes rather than features? Do the Non-Goals +actually bound the work? Is every P0 requirement testable? Do the Success Metrics +measure the Goals? Do the Risks name real decisions still owed, or is that section +decoration? Say what you would change and why.` + } + } + ] + }) + ); +} diff --git a/packages/logicsrc-mcp/src/server.ts b/packages/logicsrc-mcp/src/server.ts index 46161df..c077470 100644 --- a/packages/logicsrc-mcp/src/server.ts +++ b/packages/logicsrc-mcp/src/server.ts @@ -1,6 +1,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { assertSchemaKind, parseDocument, schemas, validate, type SchemaKind } from "@logicsrc/validators"; +import { registerOpenOntology } from "./openontology.js"; +import { registerOpenPrd } from "./openprd.js"; const docs = { "communication-accounts": `LogicSRC Communication Accounts defines shared contracts for connecting social and email identities, granting scoped human/agent/plugin access, evaluating policy gates, brokering credentials, and auditing every account action without exposing raw secrets.`, @@ -25,7 +27,7 @@ export function createLogicSrcMcpServer() { tools: {}, prompts: {} }, - instructions: "Use this server for LogicSRC standards, schema resources, validation, and draft object generation. Treat CommandBoard.run as a reference implementation, not the standards identity." + instructions: "Use this server for LogicSRC standards, schema resources, validation, draft object generation, OpenOntology knowledge (entities, claims, provenance, governed change sets), and OpenPRD product requirements documents. Treat CommandBoard.run as a reference implementation, not the standards identity. Ontology and PRD write tools propose; they never apply." } ); @@ -151,6 +153,9 @@ export function createLogicSrcMcpServer() { }) ); + registerOpenOntology(server); + registerOpenPrd(server); + return server; } diff --git a/packages/logicsrc-mcp/src/standards.test.ts b/packages/logicsrc-mcp/src/standards.test.ts new file mode 100644 index 0000000..a80eeaa --- /dev/null +++ b/packages/logicsrc-mcp/src/standards.test.ts @@ -0,0 +1,226 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { initOntologyPackage } from "@logicsrc/openontology"; +import { createLogicSrcMcpServer } from "./server.js"; + +const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +const dirs: string[] = []; + +let ontologyDir: string; + +beforeAll(() => { + ontologyDir = mkdtempSync(join(tmpdir(), "mcp-ontology-")); + dirs.push(ontologyDir); + initOntologyPackage(ontologyDir, { id: "test-ecosystem", now: "2026-07-26T00:00:00Z" }); + process.env.OPENONTOLOGY_PACKAGE = ontologyDir; + process.env.OPENPRD_DIR = join(REPO, "prd"); +}); + +afterAll(() => { + delete process.env.OPENONTOLOGY_PACKAGE; + delete process.env.OPENPRD_DIR; + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); +}); + +async function connect() { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = createLogicSrcMcpServer(); + const client = new Client({ name: "test-client", version: "0.1.0" }); + await server.connect(serverTransport); + await client.connect(clientTransport); + return client; +} + +function toolText(result: unknown): string { + const content = (result as { content?: Array<{ type: string; text?: string }> }).content ?? []; + return content.map((entry) => entry.text ?? "").join("\n"); +} + +/** Resource contents are text-or-blob in the SDK types; these fixtures are text. */ +function resourceText(contents: unknown): string { + const entry = (contents as Array<{ text?: string }>)[0]; + return entry?.text ?? ""; +} + +function isError(result: unknown): boolean { + return (result as { isError?: boolean }).isError === true; +} + +describe("MCP: OpenOntology", () => { + it("exposes the spec, manifest, schema, and saved queries as resources", async () => { + const client = await connect(); + const uris = (await client.listResources()).resources.map((resource) => resource.uri); + expect(uris).toContain("logicsrc://openontology/spec"); + expect(uris).toContain("ontology://test-ecosystem/manifest"); + expect(uris).toContain("ontology://test-ecosystem/schema"); + expect(uris).toContain("ontology://test-ecosystem/queries"); + }); + + it("serves the ontology schema resource", async () => { + const client = await connect(); + const resource = await client.readResource({ uri: "ontology://test-ecosystem/schema" }); + expect(resourceText(resource.contents)).toContain("worksOn"); + }); + + it("runs a query and returns claim ids with the ontology version (R160)", async () => { + const client = await connect(); + const result = await client.callTool({ name: "ontology_query", arguments: { savedQuery: "contributors" } }); + const payload = JSON.parse(toolText(result)) as { + ontology: string; + rows: Array<{ claims: string[] }>; + resultId: string; + }; + expect(payload.ontology).toBe("test-ecosystem@0.1.0"); + expect(payload.rows.length).toBeGreaterThan(0); + expect(payload.rows[0]!.claims.length).toBeGreaterThan(0); + }); + + it("explains a result down to sources", async () => { + const client = await connect(); + const query = await client.callTool({ name: "ontology_query", arguments: { savedQuery: "contributors" } }); + const { resultId } = JSON.parse(toolText(query)) as { resultId: string }; + + const explained = await client.callTool({ + name: "ontology_explain", + arguments: { resultId, row: 0 } + }); + const payload = JSON.parse(toolText(explained)) as { claims: Array<{ sources: unknown[] }> }; + expect(payload.claims[0]!.sources.length).toBeGreaterThan(0); + }); + + it("validates the loaded package", async () => { + const client = await connect(); + const result = await client.callTool({ name: "ontology_validate", arguments: { strict: true } }); + expect(toolText(result)).toContain("Validation passed"); + }); + + it("finds entities with ranked evidence rather than a silent match", async () => { + const client = await connect(); + const result = await client.callTool({ name: "ontology_find_entities", arguments: { text: "Alice" } }); + const matches = JSON.parse(toolText(result)) as Array<{ id: string; matchedOn: string; score: number }>; + expect(matches[0]!.id).toBe("test:person:alice"); + expect(matches[0]!.matchedOn).toBeTruthy(); + }); + + it("refuses to propose when the server is read-only (the default)", async () => { + const client = await connect(); + const denied = await client.callTool({ + name: "ontology_propose_claim", + arguments: { + subject: "test:person:alice", + predicate: "worksOn", + objectEntity: "test:project:docs-portal", + source: "test:source:repo" + } + }); + expect(isError(denied)).toBe(true); + expect(toolText(denied)).toContain("ontology:claim:propose"); + }); + + it("proposes a change set when writes are enabled — and still cannot apply it", async () => { + process.env.OPENONTOLOGY_MCP_WRITABLE = "1"; + try { + const client = await connect(); + const proposed = await client.callTool({ + name: "ontology_propose_claim", + arguments: { + subject: "test:person:alice", + predicate: "worksOn", + objectEntity: "test:project:docs-portal", + source: "test:source:repo", + runId: "run_mcp_1" + } + }); + const payload = JSON.parse(toolText(proposed)) as { changeSet: string; status: string }; + expect(payload.status).toBe("proposed"); + + // Enabling writes buys proposals, not applies: the actor is an agent. + const applied = await client.callTool({ + name: "ontology_apply_changeset", + arguments: { changeSet: payload.changeSet } + }); + expect(isError(applied)).toBe(true); + expect(toolText(applied)).toMatch(/never apply directly/); + } finally { + delete process.env.OPENONTOLOGY_MCP_WRITABLE; + } + }); + + it("exports Turtle and SHACL", async () => { + const client = await connect(); + const turtle = await client.callTool({ name: "ontology_export", arguments: { format: "turtle" } }); + expect(toolText(turtle)).toContain("@prefix oo:"); + + const shacl = await client.callTool({ name: "ontology_export", arguments: { format: "shacl" } }); + expect(toolText(shacl)).toContain("sh:NodeShape"); + }); + + it("registers the ontology prompts", async () => { + const client = await connect(); + const names = (await client.listPrompts()).prompts.map((prompt) => prompt.name); + expect(names).toEqual( + expect.arrayContaining([ + "design_ontology", + "map_sources_to_claims", + "resolve_entities", + "review_ontology_changeset", + "explain_ontology_answer" + ]) + ); + }); +}); + +describe("MCP: OpenPRD", () => { + it("exposes the spec and the generated index", async () => { + const client = await connect(); + const uris = (await client.listResources()).resources.map((resource) => resource.uri); + expect(uris).toContain("logicsrc://openprd/spec"); + expect(uris).toContain("prd://index"); + + const index = await client.readResource({ uri: "prd://index" }); + expect(resourceText(index.contents)).toContain("| ID | Title | Status | Tags |"); + }); + + it("lists this repo's PRDs", async () => { + const client = await connect(); + const result = await client.callTool({ name: "prd_list", arguments: {} }); + const rows = JSON.parse(toolText(result)) as Array<{ id: string; title: string; requirements: number }>; + expect(rows[0]!.id).toBe("0001"); + expect(rows[0]!.requirements).toBe(210); + }); + + it("validates the collection", async () => { + const client = await connect(); + const result = await client.callTool({ name: "prd_validate", arguments: {} }); + expect(toolText(result)).toContain("validation passed"); + }); + + it("reports the next free id and the allowed lifecycle moves", async () => { + const client = await connect(); + expect(toolText(await client.callTool({ name: "prd_next_id", arguments: {} }))).toBe("0002"); + + const moves = await client.callTool({ name: "prd_next_statuses", arguments: { ref: "0001" } }); + const payload = JSON.parse(toolText(moves)) as { status: string; allowedNext: string[] }; + expect(payload.status).toBe("Draft"); + expect(payload.allowedNext).toEqual(["Review", "Withdrawn"]); + }); + + it("maps requirements to validated tasks", async () => { + const client = await connect(); + const result = await client.callTool({ name: "prd_tasks", arguments: { ref: "0001", priority: "P0" } }); + const payload = JSON.parse(toolText(result)) as { tasks: Array<{ type: string }> }; + expect(payload.tasks.length).toBeGreaterThan(100); + expect(payload.tasks[0]!.type).toBe("logicsrc.task"); + }); + + it("registers the PRD prompts", async () => { + const client = await connect(); + const names = (await client.listPrompts()).prompts.map((prompt) => prompt.name); + expect(names).toEqual(expect.arrayContaining(["write_prd", "review_prd"])); + }); +}); diff --git a/packages/openontology/package.json b/packages/openontology/package.json index 69a378e..ed56a18 100644 --- a/packages/openontology/package.json +++ b/packages/openontology/package.json @@ -15,14 +15,27 @@ "directory": "packages/openontology" }, "homepage": "https://logicsrc.com/openontology", - "keywords": ["logicsrc", "openontology", "ontology", "knowledge-graph", "provenance", "agents", "json-schema"], - "publishConfig": { "access": "public" }, - "files": ["dist"], + "keywords": [ + "logicsrc", + "openontology", + "ontology", + "knowledge-graph", + "provenance", + "agents", + "json-schema" + ], + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], "scripts": { "build": "tsc -p tsconfig.json", "test": "vitest run src" }, "dependencies": { + "@libsql/client": "^0.17.4", "@logicsrc/validators": "file:../validators", "yaml": "^2.8.1" }, diff --git a/packages/openontology/src/adapters.test.ts b/packages/openontology/src/adapters.test.ts new file mode 100644 index 0000000..7f33baa --- /dev/null +++ b/packages/openontology/src/adapters.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it } from "vitest"; +import { + csvAdapter, + githubAdapter, + httpApiAdapter, + jsonAdapter, + listAdapters, + markdownAdapter, + ndjsonAdapter, + parseCsv, + yamlAdapter, + type FetchLike, + type IngestContext +} from "./adapters.js"; +import { createOntologyEngine } from "./engine.js"; +import { loadPrdFixturePackage } from "./test-helpers.js"; +import { proposerActor } from "./policy.js"; + +const ctx: IngestContext = { + prefix: "test", + actor: "agent:importer", + runId: "run_import_1", + now: "2026-07-26T00:00:00Z", + confidence: 0.6, + license: "CC-BY-4.0" +}; + +const MAPPING = { + entityType: "Person", + idField: "handle", + nameField: "name", + idSegment: "person", + aliasField: "aliases", + externalIds: { github: "github" }, + properties: { role: "role" }, + relationships: { worksOn: { field: "projects", targetSegment: "project" } } +}; + +describe("adapter capabilities", () => { + it("declares what every adapter can and cannot do (R118)", () => { + const adapters = listAdapters(); + expect(adapters.map((a) => a.id).sort()).toEqual([ + "csv", + "github", + "http-api", + "json", + "markdown", + "ndjson", + "yaml" + ]); + for (const adapter of adapters) { + expect(adapter.capabilities).toHaveProperty("publicData"); + expect(adapter.capabilities).toHaveProperty("deletions"); + } + // None of the shipped adapters can see upstream deletions — say so. + expect(adapters.every((a) => a.capabilities.deletions === false)).toBe(true); + }); +}); + +describe("CSV", () => { + const csv = `handle,name,role,github,projects +alice,Alice Reyes,Protocol engineer,areyes,"zk-prover,ledger-indexer" +bob,Bob Nakamura,Indexer lead,bnak,ledger-indexer +`; + + it("parses quoted fields and embedded separators", () => { + const rows = parseCsv(csv); + expect(rows).toHaveLength(2); + expect(rows[0]!.projects).toBe("zk-prover,ledger-indexer"); + }); + + it("handles escaped quotes", () => { + const rows = parseCsv('a,b\n"say ""hi""",2\n'); + expect(rows[0]!.a).toBe('say "hi"'); + }); + + it("maps rows to entities and claims", () => { + const result = csvAdapter.ingest({ uri: "https://example.org/people.csv", content: csv, mapping: MAPPING }, ctx); + expect(result.entities.map((e) => e.id)).toEqual(["test:person:alice", "test:person:bob"]); + expect(result.entities[0]!.externalIds).toEqual({ github: "areyes" }); + + const worksOn = result.claims.filter((c) => c.predicate === "worksOn"); + expect(worksOn).toHaveLength(3); + expect(worksOn[0]!.object).toEqual({ entity: "test:project:zk-prover" }); + }); + + it("proposes rather than asserts, and records the run (R113/R114)", () => { + const result = csvAdapter.ingest({ uri: "https://example.org/people.csv", content: csv, mapping: MAPPING }, ctx); + expect(result.claims.every((c) => c.status === "proposed")).toBe(true); + expect(result.claims.every((c) => c.runId === "run_import_1")).toBe(true); + expect(result.claims.every((c) => c.confidence === 0.6)).toBe(true); + }); + + it("attaches source and evidence with a line selector", () => { + const result = csvAdapter.ingest({ uri: "https://example.org/people.csv", content: csv, mapping: MAPPING }, ctx); + expect(result.sources[0]!.contentHash).toMatch(/^sha256:/); + expect(result.sources[0]!.license).toBe("CC-BY-4.0"); + expect(result.evidence[0]!.selector).toEqual({ type: "line-range", start: 2, end: 2 }); + expect(result.claims[0]!.sources).toEqual([result.sources[0]!.id]); + }); + + it("skips a row with no id and says so", () => { + const result = csvAdapter.ingest( + { uri: "x.csv", content: "handle,name\n,Nobody\nalice,Alice\n", mapping: MAPPING }, + ctx + ); + expect(result.entities).toHaveLength(1); + expect(result.warnings[0]).toMatch(/no handle/); + }); +}); + +describe("JSON, YAML, NDJSON", () => { + const records = [{ handle: "alice", name: "Alice Reyes" }]; + + it("reads a JSON array", () => { + const result = jsonAdapter.ingest( + { uri: "x.json", content: JSON.stringify(records), mapping: MAPPING }, + ctx + ); + expect(result.entities[0]!.canonicalName).toBe("Alice Reyes"); + }); + + it("finds the array inside a wrapper object", () => { + const result = jsonAdapter.ingest( + { uri: "x.json", content: JSON.stringify({ data: records }), mapping: MAPPING }, + ctx + ); + expect(result.entities).toHaveLength(1); + }); + + it("reads a YAML sequence", () => { + const result = yamlAdapter.ingest( + { uri: "x.yaml", content: "- handle: alice\n name: Alice Reyes\n", mapping: MAPPING }, + ctx + ); + expect(result.entities[0]!.id).toBe("test:person:alice"); + }); + + it("reports a bad NDJSON line instead of aborting the file", () => { + const result = ndjsonAdapter.ingest( + { uri: "x.ndjson", content: `${JSON.stringify(records[0])}\nnot json\n`, mapping: MAPPING }, + ctx + ); + expect(result.entities).toHaveLength(1); + expect(result.warnings[0]).toMatch(/line 2 is not valid JSON/); + }); +}); + +describe("Markdown", () => { + const md = `# Title + +## ZK Prover + +See [the repo](https://example.org/zk) and [docs](https://example.org/docs). + +## Ledger Indexer + +No links here. +`; + + it("turns headings into entities and links into claims", () => { + const result = markdownAdapter.ingest({ uri: "x.md", content: md, entityType: "Project" }, ctx); + expect(result.entities.map((e) => e.canonicalName)).toEqual(["ZK Prover", "Ledger Indexer"]); + expect(result.claims).toHaveLength(2); + expect(result.claims[0]!.object).toEqual({ value: "https://example.org/zk" }); + }); + + it("warns when the document has no headings at the requested level", () => { + const result = markdownAdapter.ingest({ uri: "x.md", content: "just prose\n", entityType: "Project" }, ctx); + expect(result.warnings[0]).toMatch(/no level-2 headings/); + }); +}); + +describe("HTTP and GitHub", () => { + const fetchOk = (bodies: Record): FetchLike => + async (url) => { + const key = Object.keys(bodies).find((k) => url.includes(k)); + if (!key) return { ok: false, status: 404, text: async () => "{}" }; + return { ok: true, status: 200, text: async () => JSON.stringify(bodies[key]) }; + }; + + it("reads a JSON endpoint through an injected fetch", async () => { + const result = await httpApiAdapter.ingest( + { + url: "https://example.org/api/people", + mapping: MAPPING, + path: "items", + fetch: fetchOk({ "/api/people": { items: [{ handle: "alice", name: "Alice Reyes" }] } }) + }, + ctx + ); + expect(result.entities[0]!.id).toBe("test:person:alice"); + expect(result.evidence[0]!.selector).toMatchObject({ type: "api-field" }); + }); + + it("throws on a non-OK response rather than proposing nothing silently", async () => { + const fetchFail: FetchLike = async () => ({ ok: false, status: 503, text: async () => "" }); + await expect( + httpApiAdapter.ingest({ url: "https://example.org/x", mapping: MAPPING, fetch: fetchFail }, ctx) + ).rejects.toThrow(/HTTP 503/); + }); + + it("maps a GitHub repo and its contributors", async () => { + const result = await githubAdapter.ingest( + { + repo: "example/zk-prover", + fetch: fetchOk({ + "/repos/example/zk-prover/contributors": [{ login: "areyes", contributions: 42 }], + "/repos/example/zk-prover": { + full_name: "example/zk-prover", + name: "zk-prover", + language: "Rust", + license: { spdx_id: "Apache-2.0" }, + html_url: "https://example.org/zk-prover" + } + }) + }, + ctx + ); + + expect(result.entities.map((e) => e.type)).toEqual(["Codebase", "Person"]); + expect(result.sources[0]!.license).toBe("Apache-2.0"); + expect(result.claims.find((c) => c.predicate === "language")?.object).toEqual({ value: "Rust" }); + expect(result.claims.find((c) => c.predicate === "contributesTo")?.object).toEqual({ + entity: "test:code:zk-prover" + }); + }); + + it("warns when a repository declares no licence", async () => { + const result = await githubAdapter.ingest( + { + repo: "example/unlicensed", + fetch: fetchOk({ + "/repos/example/unlicensed/contributors": [], + "/repos/example/unlicensed": { name: "unlicensed", full_name: "example/unlicensed" } + }) + }, + ctx + ); + expect(result.warnings.some((w) => /no SPDX licence/.test(w))).toBe(true); + expect(result.sources[0]!.license).toBe("CC-BY-4.0"); + }); +}); + +describe("ingestion feeds the governed path", () => { + it("produces operations a proposer agent can turn into a change set", () => { + const engine = createOntologyEngine({ + package: loadPrdFixturePackage(), + actor: proposerActor("agent:importer"), + clock: () => ctx.now + }); + + const result = csvAdapter.ingest( + { + uri: "https://example.org/people.csv", + content: "handle,name\ndave,Dave Okonkwo\n", + mapping: { entityType: "Person", idField: "handle", nameField: "name", idSegment: "person" } + }, + ctx + ); + + const changeSet = engine.createOntologyChangeSet({ + title: "Import people.csv", + operations: result.operations, + runId: ctx.runId + }); + + expect(changeSet.status).toBe("proposed"); + // An imported entity does not exist until a human applies the change set. + expect(() => engine.getEntity("test:person:dave")).toThrow(/Unknown entity/); + }); +}); diff --git a/packages/openontology/src/adapters.ts b/packages/openontology/src/adapters.ts new file mode 100644 index 0000000..0256e5a --- /dev/null +++ b/packages/openontology/src/adapters.ts @@ -0,0 +1,752 @@ +import { parse as parseYaml } from "yaml"; +import { digest } from "./canonical.js"; +import { OPENONTOLOGY_VERSION } from "./types.js"; +import type { ChangeOperation, Claim, Entity, Evidence, EvidenceSelector, Source } from "./types.js"; + +/** + * Source adapters turn foreign data into **proposals**. + * + * Nothing here writes to a store. Every adapter returns entities, claims, + * sources, evidence, and the change-set operations that would introduce them — + * a human or a policy decides whether they land (R113). + * + * Each adapter declares what it can and cannot do (R118), so a caller knows + * whether "no deletions" means "nothing was deleted" or "this adapter cannot + * see deletions." + */ + +export interface AdapterCapabilities { + /** Can read publicly available data. */ + publicData: boolean; + /** Can read private data given credentials. */ + privateData: boolean; + /** Can fetch only what changed since a marker. */ + incremental: boolean; + /** Can detect that a record disappeared upstream. */ + deletions: boolean; + /** Reports the licence of what it ingested. */ + license: boolean; +} + +export interface IngestResult { + adapter: string; + sources: Source[]; + entities: Entity[]; + claims: Claim[]; + evidence: Evidence[]; + /** Ready for `createOntologyChangeSet({ operations })`. */ + operations: ChangeOperation[]; + warnings: string[]; + capabilities: AdapterCapabilities; +} + +export interface IngestContext { + /** Compact id prefix for generated ids. */ + prefix: string; + /** Actor recorded on generated claims. */ + actor: string; + /** Required when the actor is an agent. */ + runId?: string; + now: string; + /** Confidence stamped on generated claims. */ + confidence?: number; + license?: string; +} + +/** How a flat record becomes an entity plus its claims. */ +export interface RecordMapping { + entityType: string; + /** Field holding the stable local id. */ + idField: string; + /** Field holding the display name. Defaults to `idField`. */ + nameField?: string; + /** Segment used in generated ids: `::`. */ + idSegment?: string; + aliasField?: string; + /** External id namespace → field. */ + externalIds?: Record; + /** Property predicate → field. */ + properties?: Record; + /** Relationship predicate → field holding the target's local id. */ + relationships?: Record; +} + +export interface SourceAdapter { + id: string; + description: string; + capabilities: AdapterCapabilities; + ingest(input: Input, ctx: IngestContext): Promise | IngestResult; +} + +const READ_ONLY_PUBLIC: AdapterCapabilities = { + publicData: true, + privateData: false, + incremental: false, + deletions: false, + license: true +}; + +/* ── shared record → proposal machinery ─────────────────────────────────── */ + +function slug(value: string): string { + return String(value) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 64); +} + +interface BuildOptions { + records: Array>; + mapping: RecordMapping; + source: Source; + ctx: IngestContext; + adapter: string; + selectorFor?: (index: number) => EvidenceSelector; +} + +function buildProposal(options: BuildOptions): IngestResult { + const { records, mapping, source, ctx, adapter } = options; + const entities: Entity[] = []; + const claims: Claim[] = []; + const evidence: Evidence[] = []; + const warnings: string[] = []; + + const segment = mapping.idSegment ?? slug(mapping.entityType); + let claimSeq = 0; + let evidenceSeq = 0; + + const nextClaimId = () => `${ctx.prefix}:claim:${adapter}-${String(++claimSeq).padStart(4, "0")}`; + + records.forEach((record, index) => { + const rawId = record[mapping.idField]; + if (rawId === undefined || rawId === null || String(rawId).trim() === "") { + warnings.push(`record ${index} has no ${mapping.idField}; skipped`); + return; + } + + const entityId = `${ctx.prefix}:${segment}:${slug(String(rawId))}`; + const name = String(record[mapping.nameField ?? mapping.idField] ?? rawId); + + const selector = options.selectorFor?.(index) ?? { type: "json-pointer", pointer: `/${index}` }; + const evidenceId = `${ctx.prefix}:evidence:${adapter}-${String(++evidenceSeq).padStart(4, "0")}`; + evidence.push({ + openontology: OPENONTOLOGY_VERSION, + kind: "Evidence", + id: evidenceId, + source: source.id, + selector + }); + + const entity: Entity = { + openontology: OPENONTOLOGY_VERSION, + kind: "Entity", + id: entityId, + type: mapping.entityType, + canonicalName: name, + createdAt: ctx.now, + createdBy: ctx.actor + }; + + if (mapping.aliasField && record[mapping.aliasField]) { + const raw = record[mapping.aliasField]; + entity.aliases = Array.isArray(raw) + ? raw.map(String) + : String(raw) + .split(",") + .map((alias) => alias.trim()) + .filter(Boolean); + } + + if (mapping.externalIds) { + const externalIds: Record = {}; + for (const [namespace, field] of Object.entries(mapping.externalIds)) { + const value = record[field]; + if (value !== undefined && value !== null && String(value) !== "") { + externalIds[namespace] = String(value); + } + } + if (Object.keys(externalIds).length > 0) entity.externalIds = externalIds; + } + + entities.push(entity); + + const base = { + openontology: OPENONTOLOGY_VERSION, + kind: "Claim" as const, + status: "proposed" as const, + assertedAt: ctx.now, + assertedBy: ctx.actor, + sources: [source.id], + evidence: [evidenceId], + ...(ctx.runId ? { runId: ctx.runId } : {}), + ...(ctx.confidence !== undefined ? { confidence: ctx.confidence } : {}) + }; + + for (const [predicate, field] of Object.entries(mapping.properties ?? {})) { + const value = record[field]; + if (value === undefined || value === null || value === "") continue; + claims.push({ ...base, id: nextClaimId(), subject: entityId, predicate, object: { value } }); + } + + for (const [predicate, config] of Object.entries(mapping.relationships ?? {})) { + const raw = record[config.field]; + if (raw === undefined || raw === null || String(raw) === "") continue; + const targets = Array.isArray(raw) + ? raw.map(String) + : String(raw) + .split(config.separator ?? ",") + .map((value) => value.trim()) + .filter(Boolean); + + for (const target of targets) { + claims.push({ + ...base, + id: nextClaimId(), + subject: entityId, + predicate, + object: { entity: `${ctx.prefix}:${config.targetSegment}:${slug(target)}` } + }); + } + } + }); + + const operations: ChangeOperation[] = [ + ...entities.map((entity) => ({ op: "add-entity" as const, value: entity as unknown as Record })), + ...claims.map((claim) => ({ op: "assert-claim" as const, value: claim as unknown as Record })) + ]; + + return { + adapter, + sources: [source], + entities, + claims, + evidence, + operations, + warnings, + capabilities: READ_ONLY_PUBLIC + }; +} + +function makeSource( + id: string, + sourceType: string, + uri: string, + ctx: IngestContext, + content: string, + extra: Partial = {} +): Source { + return { + openontology: OPENONTOLOGY_VERSION, + kind: "Source", + id, + sourceType, + uri, + retrievedAt: ctx.now, + contentHash: digest(content), + license: ctx.license ?? "unknown", + ...extra + }; +} + +/* ── file adapters ──────────────────────────────────────────────────────── */ + +export interface FileInput { + /** Where the content came from, recorded on the Source. */ + uri: string; + content: string; + mapping: RecordMapping; +} + +export const csvAdapter: SourceAdapter = { + id: "csv", + description: "Rows of a CSV file become entities and claims.", + capabilities: READ_ONLY_PUBLIC, + ingest(input, ctx) { + const rows = parseCsv(input.content); + return buildProposal({ + adapter: "csv", + records: rows, + mapping: input.mapping, + ctx, + source: makeSource(`${ctx.prefix}:source:csv-${digest(input.uri).slice(7, 15)}`, "csv", input.uri, ctx, input.content, { + mediaType: "text/csv" + }), + // Row 1 is the header, so record 0 lives on line 2. + selectorFor: (index) => ({ type: "line-range", start: index + 2, end: index + 2 }) + }); + } +}; + +export const jsonAdapter: SourceAdapter = { + id: "json", + description: "A JSON array (or an object with an array field) becomes entities and claims.", + capabilities: READ_ONLY_PUBLIC, + ingest(input, ctx) { + const parsed = JSON.parse(input.content) as unknown; + const records = toRecords(parsed); + return buildProposal({ + adapter: "json", + records, + mapping: input.mapping, + ctx, + source: makeSource( + `${ctx.prefix}:source:json-${digest(input.uri).slice(7, 15)}`, + "json", + input.uri, + ctx, + input.content, + { mediaType: "application/json" } + ) + }); + } +}; + +export const yamlAdapter: SourceAdapter = { + id: "yaml", + description: "A YAML sequence becomes entities and claims.", + capabilities: READ_ONLY_PUBLIC, + ingest(input, ctx) { + const records = toRecords(parseYaml(input.content) as unknown); + return buildProposal({ + adapter: "yaml", + records, + mapping: input.mapping, + ctx, + source: makeSource( + `${ctx.prefix}:source:yaml-${digest(input.uri).slice(7, 15)}`, + "yaml", + input.uri, + ctx, + input.content, + { mediaType: "application/yaml" } + ) + }); + } +}; + +export const ndjsonAdapter: SourceAdapter = { + id: "ndjson", + description: "Newline-delimited JSON records become entities and claims.", + capabilities: READ_ONLY_PUBLIC, + ingest(input, ctx) { + const records: Array> = []; + input.content.split("\n").forEach((line, index) => { + const trimmed = line.trim(); + if (!trimmed) return; + try { + records.push(JSON.parse(trimmed) as Record); + } catch { + // Reported below rather than aborting the whole file. + records.push({ __parseError: index + 1 }); + } + }); + + const bad = records.filter((record) => "__parseError" in record); + const clean = records.filter((record) => !("__parseError" in record)); + + const result = buildProposal({ + adapter: "ndjson", + records: clean, + mapping: input.mapping, + ctx, + source: makeSource( + `${ctx.prefix}:source:ndjson-${digest(input.uri).slice(7, 15)}`, + "ndjson", + input.uri, + ctx, + input.content, + { mediaType: "application/x-ndjson" } + ), + selectorFor: (index) => ({ type: "line-range", start: index + 1, end: index + 1 }) + }); + + for (const record of bad) { + result.warnings.push(`line ${String(record.__parseError)} is not valid JSON; skipped`); + } + return result; + } +}; + +export interface MarkdownInput { + uri: string; + content: string; + entityType: string; + idSegment?: string; + /** Heading level that starts a new entity. Defaults to 2 (`##`). */ + headingLevel?: number; +} + +export const markdownAdapter: SourceAdapter = { + id: "markdown", + description: "Each heading in a Markdown document becomes an entity; its links become claims.", + capabilities: READ_ONLY_PUBLIC, + ingest(input, ctx) { + const level = input.headingLevel ?? 2; + const marker = "#".repeat(level); + const source = makeSource( + `${ctx.prefix}:source:markdown-${digest(input.uri).slice(7, 15)}`, + "markdown", + input.uri, + ctx, + input.content, + { mediaType: "text/markdown" } + ); + + const entities: Entity[] = []; + const claims: Claim[] = []; + const evidence: Evidence[] = []; + const segment = input.idSegment ?? slug(input.entityType); + + let current: { id: string; line: number } | null = null; + let seq = 0; + + input.content.split("\n").forEach((line, index) => { + const heading = new RegExp(`^${marker}\\s+(.+?)\\s*$`).exec(line); + if (heading && !line.startsWith(`${marker}#`)) { + const name = heading[1] as string; + const id = `${ctx.prefix}:${segment}:${slug(name)}`; + const evidenceId = `${ctx.prefix}:evidence:markdown-${String(++seq).padStart(4, "0")}`; + evidence.push({ + openontology: OPENONTOLOGY_VERSION, + kind: "Evidence", + id: evidenceId, + source: source.id, + selector: { type: "line-range", start: index + 1, end: index + 1 } + }); + entities.push({ + openontology: OPENONTOLOGY_VERSION, + kind: "Entity", + id, + type: input.entityType, + canonicalName: name, + createdAt: ctx.now, + createdBy: ctx.actor + }); + current = { id, line: index + 1 }; + return; + } + + if (!current) return; + for (const match of line.matchAll(/\[([^\]]+)\]\(([^)]+)\)/g)) { + const evidenceId = `${ctx.prefix}:evidence:markdown-${String(++seq).padStart(4, "0")}`; + evidence.push({ + openontology: OPENONTOLOGY_VERSION, + kind: "Evidence", + id: evidenceId, + source: source.id, + selector: { type: "line-range", start: index + 1, end: index + 1 } + }); + claims.push({ + openontology: OPENONTOLOGY_VERSION, + kind: "Claim", + id: `${ctx.prefix}:claim:markdown-${String(seq).padStart(4, "0")}`, + subject: current.id, + predicate: "references", + object: { value: match[2] as string }, + status: "proposed", + assertedAt: ctx.now, + assertedBy: ctx.actor, + sources: [source.id], + evidence: [evidenceId], + ...(ctx.runId ? { runId: ctx.runId } : {}), + ...(ctx.confidence !== undefined ? { confidence: ctx.confidence } : {}) + }); + } + }); + + return { + adapter: "markdown", + sources: [source], + entities, + claims, + evidence, + operations: [ + ...entities.map((entity) => ({ op: "add-entity" as const, value: entity as unknown as Record })), + ...claims.map((claim) => ({ op: "assert-claim" as const, value: claim as unknown as Record })) + ], + warnings: entities.length === 0 ? [`no level-${level} headings found in ${input.uri}`] : [], + capabilities: READ_ONLY_PUBLIC + }; + } +}; + +/* ── HTTP adapters ──────────────────────────────────────────────────────── */ + +export type FetchLike = (url: string, init?: { headers?: Record }) => Promise<{ + ok: boolean; + status: number; + text: () => Promise; +}>; + +export interface HttpInput { + url: string; + mapping: RecordMapping; + headers?: Record; + /** JSON pointer-ish path to the array in the response, e.g. "data.items". */ + path?: string; + fetch: FetchLike; +} + +export const httpApiAdapter: SourceAdapter = { + id: "http-api", + description: "A JSON HTTP endpoint becomes entities and claims.", + capabilities: { publicData: true, privateData: true, incremental: false, deletions: false, license: false }, + async ingest(input, ctx) { + const response = await input.fetch(input.url, { headers: input.headers }); + if (!response.ok) { + throw new Error(`${input.url} returned HTTP ${response.status}`); + } + const body = await response.text(); + const parsed = JSON.parse(body) as unknown; + const target = input.path + ? input.path.split(".").reduce((value, key) => (value as Record)?.[key], parsed) + : parsed; + + const result = buildProposal({ + adapter: "http-api", + records: toRecords(target), + mapping: input.mapping, + ctx, + source: makeSource( + `${ctx.prefix}:source:http-${digest(input.url).slice(7, 15)}`, + "api-response", + input.url, + ctx, + body, + { mediaType: "application/json" } + ), + selectorFor: (index) => ({ type: "api-field", field: `${input.path ?? "$"}[${index}]`, endpoint: input.url }) + }); + + result.capabilities = httpApiAdapter.capabilities; + if (!ctx.license) { + result.warnings.push("no licence declared for this endpoint; source licence recorded as unknown"); + } + return result; + } +}; + +export interface GithubInput { + /** `owner/name`. */ + repo: string; + fetch: FetchLike; + token?: string; + apiBase?: string; +} + +/** + * GitHub repository → a codebase entity plus its contributors. + * + * Fetch is injected, so tests (and offline runs) never touch the network. + */ +export const githubAdapter: SourceAdapter = { + id: "github", + description: "A GitHub repository and its contributors become a codebase and people.", + capabilities: { publicData: true, privateData: true, incremental: false, deletions: false, license: true }, + async ingest(input, ctx) { + const base = input.apiBase ?? "https://api.github.com"; + const headers = { + accept: "application/vnd.github+json", + ...(input.token ? { authorization: `Bearer ${input.token}` } : {}) + }; + + const repoResponse = await input.fetch(`${base}/repos/${input.repo}`, { headers }); + if (!repoResponse.ok) throw new Error(`GitHub returned HTTP ${repoResponse.status} for ${input.repo}`); + const repoBody = await repoResponse.text(); + const repo = JSON.parse(repoBody) as { + full_name?: string; + name?: string; + language?: string; + license?: { spdx_id?: string }; + html_url?: string; + }; + + const contributorsResponse = await input.fetch(`${base}/repos/${input.repo}/contributors`, { headers }); + const contributorsBody = contributorsResponse.ok ? await contributorsResponse.text() : "[]"; + const contributors = JSON.parse(contributorsBody) as Array<{ login?: string; contributions?: number }>; + + const source = makeSource( + `${ctx.prefix}:source:github-${slug(input.repo)}`, + "api-response", + repo.html_url ?? `${base}/repos/${input.repo}`, + ctx, + repoBody, + { mediaType: "application/json", license: repo.license?.spdx_id ?? ctx.license ?? "unknown", publisher: "github" } + ); + + const codebaseId = `${ctx.prefix}:code:${slug(repo.name ?? input.repo)}`; + const entities: Entity[] = [ + { + openontology: OPENONTOLOGY_VERSION, + kind: "Entity", + id: codebaseId, + type: "Codebase", + canonicalName: repo.name ?? input.repo, + externalIds: { github: repo.full_name ?? input.repo }, + createdAt: ctx.now, + createdBy: ctx.actor + } + ]; + + const claims: Claim[] = []; + let seq = 0; + const claimBase = { + openontology: OPENONTOLOGY_VERSION, + kind: "Claim" as const, + status: "proposed" as const, + assertedAt: ctx.now, + assertedBy: ctx.actor, + sources: [source.id], + ...(ctx.runId ? { runId: ctx.runId } : {}), + ...(ctx.confidence !== undefined ? { confidence: ctx.confidence } : {}) + }; + + if (repo.language) { + claims.push({ + ...claimBase, + id: `${ctx.prefix}:claim:github-${String(++seq).padStart(4, "0")}`, + subject: codebaseId, + predicate: "language", + object: { value: repo.language } + }); + } + if (repo.license?.spdx_id) { + claims.push({ + ...claimBase, + id: `${ctx.prefix}:claim:github-${String(++seq).padStart(4, "0")}`, + subject: codebaseId, + predicate: "license", + object: { value: repo.license.spdx_id } + }); + } + + for (const contributor of contributors) { + if (!contributor.login) continue; + const personId = `${ctx.prefix}:person:${slug(contributor.login)}`; + entities.push({ + openontology: OPENONTOLOGY_VERSION, + kind: "Entity", + id: personId, + type: "Person", + canonicalName: contributor.login, + externalIds: { github: contributor.login }, + createdAt: ctx.now, + createdBy: ctx.actor + }); + claims.push({ + ...claimBase, + id: `${ctx.prefix}:claim:github-${String(++seq).padStart(4, "0")}`, + subject: personId, + predicate: "contributesTo", + object: { entity: codebaseId } + }); + } + + const warnings: string[] = []; + if (!contributorsResponse.ok) { + warnings.push(`contributors endpoint returned HTTP ${contributorsResponse.status}; only the codebase was mapped`); + } + if (!repo.license?.spdx_id) { + warnings.push("repository declares no SPDX licence; source licence recorded as unknown"); + } + + return { + adapter: "github", + sources: [source], + entities, + claims, + evidence: [], + operations: [ + ...entities.map((entity) => ({ op: "add-entity" as const, value: entity as unknown as Record })), + ...claims.map((claim) => ({ op: "assert-claim" as const, value: claim as unknown as Record })) + ], + warnings, + capabilities: githubAdapter.capabilities + }; + } +}; + +export const ADAPTERS = { + csv: csvAdapter, + json: jsonAdapter, + yaml: yamlAdapter, + ndjson: ndjsonAdapter, + markdown: markdownAdapter, + "http-api": httpApiAdapter, + github: githubAdapter +} as const; + +export type AdapterId = keyof typeof ADAPTERS; + +export function listAdapters(): Array<{ id: string; description: string; capabilities: AdapterCapabilities }> { + return Object.values(ADAPTERS).map((adapter) => ({ + id: adapter.id, + description: adapter.description, + capabilities: adapter.capabilities + })); +} + +/* ── helpers ────────────────────────────────────────────────────────────── */ + +function toRecords(value: unknown): Array> { + if (Array.isArray(value)) return value as Array>; + if (value && typeof value === "object") { + const arrayField = Object.values(value as Record).find((entry) => Array.isArray(entry)); + if (arrayField) return arrayField as Array>; + return [value as Record]; + } + return []; +} + +/** Minimal RFC-4180 CSV reader: quoted fields, escaped quotes, CRLF. */ +export function parseCsv(text: string): Array> { + const rows: string[][] = []; + let row: string[] = []; + let field = ""; + let quoted = false; + + for (let i = 0; i < text.length; i += 1) { + const char = text[i] as string; + + if (quoted) { + if (char === '"') { + if (text[i + 1] === '"') { + field += '"'; + i += 1; + } else { + quoted = false; + } + } else { + field += char; + } + continue; + } + + if (char === '"') { + quoted = true; + } else if (char === ",") { + row.push(field); + field = ""; + } else if (char === "\n") { + row.push(field); + rows.push(row); + row = []; + field = ""; + } else if (char !== "\r") { + field += char; + } + } + + if (field.length > 0 || row.length > 0) { + row.push(field); + rows.push(row); + } + + const [header, ...body] = rows.filter((entry) => entry.some((cell) => cell.trim() !== "")); + if (!header) return []; + + return body.map((cells) => + Object.fromEntries(header.map((name, index) => [name.trim(), (cells[index] ?? "").trim()])) + ); +} diff --git a/packages/openontology/src/index.ts b/packages/openontology/src/index.ts index 4eef506..a0e934f 100644 --- a/packages/openontology/src/index.ts +++ b/packages/openontology/src/index.ts @@ -51,6 +51,15 @@ export { type QueryLimits } from "./query.js"; +export { + createLibsqlStore, + migrate as migrateLibsql, + searchEntities, + MIGRATIONS, + type LibsqlStore, + type LibsqlStoreOptions +} from "./libsql.js"; + export { createMemoryStore, type ClaimFilter, @@ -93,6 +102,30 @@ export { type JsonLdExport } from "./jsonld.js"; +export { + ADAPTERS, + csvAdapter, + githubAdapter, + httpApiAdapter, + jsonAdapter, + listAdapters, + markdownAdapter, + ndjsonAdapter, + parseCsv, + yamlAdapter, + type AdapterCapabilities, + type AdapterId, + type FetchLike, + type IngestContext, + type IngestResult, + type RecordMapping, + type SourceAdapter +} from "./adapters.js"; + +export { exportTurtle, importTurtle, type TurtleExport } from "./rdf.js"; + +export { constraintsToShacl, type ShaclExport } from "./shacl.js"; + export { createEd25519Provider, generateEd25519KeyPair, diff --git a/packages/openontology/src/libsql.test.ts b/packages/openontology/src/libsql.test.ts new file mode 100644 index 0000000..0d92bc2 --- /dev/null +++ b/packages/openontology/src/libsql.test.ts @@ -0,0 +1,184 @@ +import { createClient } from "@libsql/client"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; +import { createOntologyEngine } from "./engine.js"; +import { createLibsqlStore, migrate, searchEntities } from "./libsql.js"; +import { loadPrdFixturePackage } from "./test-helpers.js"; +import { localActor } from "./policy.js"; + +const dirs: string[] = []; +afterAll(() => { + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); +}); + +function dbUrl(): string { + const dir = mkdtempSync(join(tmpdir(), "openontology-libsql-")); + dirs.push(dir); + return `file:${join(dir, "ontology.db")}`; +} + +const NOW = "2026-07-26T00:00:00Z"; + +describe("libSQL adapter", () => { + it("applies migrations once and is idempotent", async () => { + const client = createClient({ url: dbUrl() }); + expect(await migrate(client)).toBeGreaterThan(0); + expect(await migrate(client)).toBe(0); + const tables = await client.execute( + "SELECT name FROM sqlite_master WHERE type IN ('table','index') ORDER BY name" + ); + const names = tables.rows.map((row) => String(row.name)); + expect(names).toEqual(expect.arrayContaining(["claims", "entities", "status_log", "ontology_events"])); + expect(names.filter((n) => n.startsWith("idx_claims")).length).toBeGreaterThanOrEqual(5); + client.close(); + }); + + it("seeds a package and hydrates it back identically", async () => { + const pkg = loadPrdFixturePackage(); + const url = dbUrl(); + + const store = await createLibsqlStore({ client: createClient({ url }), seed: pkg }); + expect(store.listEntities()).toHaveLength(pkg.data.entities.length); + expect(store.listClaims({ status: ["asserted"] }).length).toBeGreaterThan(0); + store.close(); + + // Re-open against the same file: no seed, everything comes from SQL. + const reopened = await createLibsqlStore({ + client: createClient({ url }), + ontology: pkg.manifest.id + }); + expect(reopened.listEntities().map((e) => e.id).sort()).toEqual( + pkg.data.entities.map((e) => e.id).sort() + ); + expect(reopened.getManifest().id).toBe(pkg.manifest.id); + reopened.close(); + }); + + it("persists an applied change set across a reopen", async () => { + const pkg = loadPrdFixturePackage(); + const url = dbUrl(); + + const store = await createLibsqlStore({ client: createClient({ url }), seed: pkg }); + let n = 0; + const engine = createOntologyEngine({ + store, + actor: localActor("curator@example.org"), + clock: () => NOW, + idFactory: (kind) => `${kind}:${String(++n).padStart(4, "0")}` + }); + + const changeSet = engine.createOntologyChangeSet({ + title: "persisted", + operations: [ + { + op: "assert-claim", + value: { + subject: "test:person:alice", + predicate: "worksOn", + object: { entity: "test:project:ledger-indexer" }, + sources: ["test:source:repo"] + } + } + ] + }); + engine.approveOntologyChangeSet(changeSet.id); + const applied = engine.applyOntologyChangeSet(changeSet.id); + + expect(store.pending()).toBeGreaterThan(0); + const flushed = await store.flush(); + expect(flushed.statements).toBeGreaterThan(0); + expect(store.pending()).toBe(0); + store.close(); + + const reopened = await createLibsqlStore({ + client: createClient({ url }), + ontology: pkg.manifest.id + }); + const reread = createOntologyEngine({ store: reopened, actor: localActor() }); + const rows = reread.queryOntology({ + match: [{ subject: "test:person:alice", predicate: "worksOn", object: "?p" }] + }).rows; + + expect(rows).toHaveLength(2); + expect(reopened.getClaim(applied.addedClaims[0]!)).toBeDefined(); + expect(reopened.listChangeSets()).toHaveLength(1); + expect(reopened.listEvents().length).toBeGreaterThan(0); + expect(reopened.revision()).toBe("data-000001"); + reopened.close(); + }); + + it("replays the append-only status log so retractions survive a reopen", async () => { + const pkg = loadPrdFixturePackage(); + const url = dbUrl(); + + const store = await createLibsqlStore({ client: createClient({ url }), seed: pkg }); + const engine = createOntologyEngine({ store, actor: localActor(), clock: () => NOW }); + + const target = engine.queryOntology({ + match: [{ subject: "test:person:carol", predicate: "worksOn", object: "?p" }] + }).rows[0]!.claims[0]!; + + const changeSet = engine.createOntologyChangeSet({ + title: "retract", + operations: [{ op: "retract-claim", target, reason: "left the project" }] + }); + engine.approveOntologyChangeSet(changeSet.id); + engine.applyOntologyChangeSet(changeSet.id); + await store.flush(); + store.close(); + + const reopened = await createLibsqlStore({ + client: createClient({ url }), + ontology: pkg.manifest.id + }); + // The claim row is still there; its effective status came from the log. + expect(reopened.getClaim(target)?.status).toBe("retracted"); + expect(reopened.claimHistory(target).map((h) => h.status)).toEqual(["asserted", "retracted"]); + reopened.close(); + }); + + it("keeps merge redirects resolvable after a reopen", async () => { + const pkg = loadPrdFixturePackage(); + const url = dbUrl(); + + const store = await createLibsqlStore({ client: createClient({ url }), seed: pkg }); + const engine = createOntologyEngine({ store, actor: localActor(), clock: () => NOW }); + const changeSet = engine.createOntologyChangeSet({ + title: "merge", + operations: [{ op: "merge-entity", source: "test:person:carol", target: "test:person:bob" }] + }); + engine.approveOntologyChangeSet(changeSet.id); + engine.applyOntologyChangeSet(changeSet.id); + await store.flush(); + store.close(); + + const reopened = await createLibsqlStore({ + client: createClient({ url }), + ontology: pkg.manifest.id + }); + expect(reopened.getEntity("test:person:carol")?.id).toBe("test:person:bob"); + reopened.close(); + }); + + it("indexes entities for full-text search", async () => { + const pkg = loadPrdFixturePackage(); + const url = dbUrl(); + const client = createClient({ url }); + const store = await createLibsqlStore({ client, seed: pkg }); + + const hits = await searchEntities(client, pkg.manifest.id, "Alice"); + expect(hits.map((hit) => hit.id)).toContain("test:person:alice"); + + const none = await searchEntities(client, pkg.manifest.id, "nonexistentterm"); + expect(none).toHaveLength(0); + store.close(); + }); + + it("refuses to open an unknown ontology with no seed", async () => { + await expect( + createLibsqlStore({ client: createClient({ url: dbUrl() }), ontology: "not-there" }) + ).rejects.toThrow(/not in this database/); + }); +}); diff --git a/packages/openontology/src/libsql.ts b/packages/openontology/src/libsql.ts new file mode 100644 index 0000000..0ef4dc1 --- /dev/null +++ b/packages/openontology/src/libsql.ts @@ -0,0 +1,713 @@ +import type { Client, InArgs } from "@libsql/client"; +import { revisionId } from "./ids.js"; +import { createMemoryStore, type OntologyStore } from "./store.js"; +import type { LoadedPackage } from "./types.js"; + +/** + * SQLite / Turso storage adapter. + * + * The engine's store interface is synchronous by design — a query evaluator + * that awaits per triple pattern is unusable. So this adapter: + * + * 1. hydrates the whole package into the in-memory read model at open, + * 2. serves reads from it synchronously, + * 3. records every mutation as a pending SQL statement, and + * 4. writes them in one transaction when you `flush()`. + * + * Callers that mutate MUST await `flush()` for durability; the REST layer does + * so after every applied change set. Everything persisted is append-only, so a + * crash before flush loses the last change set rather than corrupting history. + * + * At the reference target (~100k claims) hydration is a handful of queries. + * Beyond that, an adapter that pushes evaluation into SQL is the right answer — + * which is exactly why the store is an interface. + */ + +export const MIGRATIONS: Array<{ version: number; name: string; statements: string[] }> = [ + { + version: 1, + name: "initial", + statements: [ + `CREATE TABLE IF NOT EXISTS ontologies ( + id TEXT PRIMARY KEY, + version TEXT NOT NULL, + manifest TEXT NOT NULL, + schema_json TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS entities ( + ontology TEXT NOT NULL, + id TEXT NOT NULL, + type TEXT NOT NULL, + canonical_name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + superseded_by TEXT, + visibility TEXT NOT NULL DEFAULT 'public', + created_at TEXT NOT NULL, + created_by TEXT NOT NULL, + document TEXT NOT NULL, + PRIMARY KEY (ontology, id) + )`, + `CREATE TABLE IF NOT EXISTS aliases ( + ontology TEXT NOT NULL, + entity_id TEXT NOT NULL, + alias TEXT NOT NULL, + PRIMARY KEY (ontology, entity_id, alias) + )`, + `CREATE TABLE IF NOT EXISTS external_ids ( + ontology TEXT NOT NULL, + entity_id TEXT NOT NULL, + namespace TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (ontology, entity_id, namespace) + )`, + `CREATE TABLE IF NOT EXISTS claims ( + ontology TEXT NOT NULL, + id TEXT NOT NULL, + subject TEXT NOT NULL, + predicate TEXT NOT NULL, + object_entity TEXT, + object_value TEXT, + status TEXT NOT NULL, + confidence REAL, + valid_from TEXT, + valid_to TEXT, + observed_at TEXT, + asserted_at TEXT NOT NULL, + asserted_by TEXT NOT NULL, + run_id TEXT, + change_set TEXT, + visibility TEXT NOT NULL DEFAULT 'public', + document TEXT NOT NULL, + PRIMARY KEY (ontology, id) + )`, + `CREATE TABLE IF NOT EXISTS claim_sources ( + ontology TEXT NOT NULL, + claim_id TEXT NOT NULL, + source_id TEXT NOT NULL, + PRIMARY KEY (ontology, claim_id, source_id) + )`, + `CREATE TABLE IF NOT EXISTS sources ( + ontology TEXT NOT NULL, + id TEXT NOT NULL, + source_type TEXT NOT NULL, + uri TEXT NOT NULL, + retrieved_at TEXT NOT NULL, + content_hash TEXT, + license TEXT, + visibility TEXT NOT NULL DEFAULT 'public', + stale INTEGER NOT NULL DEFAULT 0, + document TEXT NOT NULL, + PRIMARY KEY (ontology, id) + )`, + `CREATE TABLE IF NOT EXISTS evidence ( + ontology TEXT NOT NULL, + id TEXT NOT NULL, + source_id TEXT NOT NULL, + document TEXT NOT NULL, + PRIMARY KEY (ontology, id) + )`, + // Append-only status log: claims and entities are never updated in place. + `CREATE TABLE IF NOT EXISTS status_log ( + ontology TEXT NOT NULL, + kind TEXT NOT NULL, + object_id TEXT NOT NULL, + status TEXT NOT NULL, + at TEXT NOT NULL, + by TEXT NOT NULL, + change_set TEXT, + reason TEXT, + seq INTEGER PRIMARY KEY AUTOINCREMENT + )`, + `CREATE TABLE IF NOT EXISTS changesets ( + ontology TEXT NOT NULL, + id TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + created_by TEXT NOT NULL, + base_revision TEXT, + result_revision TEXT, + document TEXT NOT NULL, + PRIMARY KEY (ontology, id) + )`, + `CREATE TABLE IF NOT EXISTS reviews ( + ontology TEXT NOT NULL, + id TEXT NOT NULL, + change_set TEXT NOT NULL, + reviewer TEXT NOT NULL, + state TEXT NOT NULL, + created_at TEXT NOT NULL, + document TEXT NOT NULL, + PRIMARY KEY (ontology, id) + )`, + `CREATE TABLE IF NOT EXISTS approvals ( + ontology TEXT NOT NULL, + id TEXT NOT NULL, + change_set TEXT NOT NULL, + approver TEXT NOT NULL, + created_at TEXT NOT NULL, + document TEXT NOT NULL, + PRIMARY KEY (ontology, id) + )`, + `CREATE TABLE IF NOT EXISTS ontology_events ( + ontology TEXT NOT NULL, + id TEXT NOT NULL, + type TEXT NOT NULL, + at TEXT NOT NULL, + actor TEXT NOT NULL, + change_set TEXT, + subject TEXT, + revision TEXT, + document TEXT NOT NULL, + seq INTEGER PRIMARY KEY AUTOINCREMENT + )`, + // R185: the indexes the query evaluator and lookups actually need. + "CREATE INDEX IF NOT EXISTS idx_entities_type ON entities (ontology, type)", + "CREATE INDEX IF NOT EXISTS idx_entities_status ON entities (ontology, status)", + "CREATE INDEX IF NOT EXISTS idx_entities_name ON entities (ontology, canonical_name)", + "CREATE INDEX IF NOT EXISTS idx_aliases_alias ON aliases (ontology, alias)", + "CREATE INDEX IF NOT EXISTS idx_external_ids ON external_ids (ontology, namespace, value)", + "CREATE INDEX IF NOT EXISTS idx_claims_subject ON claims (ontology, subject)", + "CREATE INDEX IF NOT EXISTS idx_claims_predicate ON claims (ontology, predicate)", + "CREATE INDEX IF NOT EXISTS idx_claims_object ON claims (ontology, object_entity)", + "CREATE INDEX IF NOT EXISTS idx_claims_sp ON claims (ontology, subject, predicate)", + "CREATE INDEX IF NOT EXISTS idx_claims_status ON claims (ontology, status)", + "CREATE INDEX IF NOT EXISTS idx_claims_valid ON claims (ontology, valid_from, valid_to)", + "CREATE INDEX IF NOT EXISTS idx_claims_asserted ON claims (ontology, asserted_at)", + "CREATE INDEX IF NOT EXISTS idx_claim_sources ON claim_sources (ontology, source_id)", + "CREATE INDEX IF NOT EXISTS idx_status_log_object ON status_log (ontology, kind, object_id)", + "CREATE INDEX IF NOT EXISTS idx_events_type ON ontology_events (ontology, type)", + "CREATE INDEX IF NOT EXISTS idx_events_changeset ON ontology_events (ontology, change_set)" + ] + }, + { + version: 2, + name: "full-text-search", + statements: [ + // R186: label, alias, and description search. + `CREATE VIRTUAL TABLE IF NOT EXISTS entity_fts USING fts5( + entity_id UNINDEXED, + ontology UNINDEXED, + canonical_name, + aliases, + description, + tokenize = 'unicode61' + )` + ] + } +]; + +export interface LibsqlStoreOptions { + client: Client; + /** Ontology id. Defaults to the package manifest id. */ + ontology?: string; + /** Seed the database from this package when it holds no rows for the id. */ + seed?: LoadedPackage; +} + +export interface LibsqlStore extends OntologyStore { + /** Persist everything buffered since the last flush, in one transaction. */ + flush(): Promise<{ statements: number }>; + /** Pending statement count — 0 means everything is durable. */ + pending(): number; + close(): void; +} + +export async function migrate(client: Client): Promise { + await client.execute( + "CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL)" + ); + const applied = await client.execute("SELECT version FROM schema_migrations"); + const have = new Set(applied.rows.map((row) => Number(row.version))); + + let count = 0; + for (const migration of MIGRATIONS) { + if (have.has(migration.version)) continue; + for (const statement of migration.statements) { + await client.execute(statement); + } + await client.execute({ + sql: "INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)", + args: [migration.version, migration.name, new Date().toISOString()] + }); + count += 1; + } + return count; +} + +/** Open (and if needed seed) a libSQL-backed store. */ +export async function createLibsqlStore(options: LibsqlStoreOptions): Promise { + const { client, seed } = options; + await migrate(client); + + const ontology = options.ontology ?? seed?.manifest.id; + if (!ontology) throw new Error("createLibsqlStore needs an ontology id or a seed package"); + + const existing = await client.execute({ + sql: "SELECT manifest, schema_json, revision FROM ontologies WHERE id = ?", + args: [ontology] + }); + + if (existing.rows.length === 0) { + if (!seed) throw new Error(`Ontology ${ontology} is not in this database and no seed was given`); + await seedPackage(client, ontology, seed); + } + + const hydrated = await hydrate(client, ontology); + const memory = createMemoryStore(hydrated.pkg); + + // Replay the append-only status log so effective statuses match the database. + for (const transition of hydrated.transitions) { + if (transition.kind === "claim") { + try { + memory.setClaimStatus(transition.transition); + } catch { + // A transition for a claim that is no longer present is not fatal. + } + } else { + memory.setEntityStatus(transition.transition); + } + } + + for (let i = 0; i < hydrated.revision; i += 1) memory.bumpRevision(); + for (const changeSet of hydrated.changeSets) memory.putChangeSet(changeSet); + for (const review of hydrated.reviews) memory.putReview(review); + for (const approval of hydrated.approvals) memory.putApproval(approval); + for (const event of hydrated.events) memory.appendEvent(event); + + const pendingStatements: Array<{ sql: string; args: InArgs }> = []; + const enqueue = (sql: string, args: InArgs) => pendingStatements.push({ sql, args }); + + const store: LibsqlStore = { + ...memory, + + addEntity(entity) { + memory.addEntity(entity); + enqueue( + `INSERT INTO entities (ontology, id, type, canonical_name, status, superseded_by, visibility, created_at, created_by, document) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + ontology, + entity.id, + entity.type, + entity.canonicalName, + entity.status ?? "active", + entity.supersededBy ?? null, + entity.visibility ?? "public", + entity.createdAt, + entity.createdBy, + JSON.stringify(entity) + ] + ); + for (const alias of entity.aliases ?? []) { + enqueue("INSERT OR IGNORE INTO aliases (ontology, entity_id, alias) VALUES (?, ?, ?)", [ + ontology, + entity.id, + alias + ]); + } + for (const [namespace, value] of Object.entries(entity.externalIds ?? {})) { + enqueue( + "INSERT OR REPLACE INTO external_ids (ontology, entity_id, namespace, value) VALUES (?, ?, ?, ?)", + [ontology, entity.id, namespace, value] + ); + } + enqueue( + "INSERT INTO entity_fts (entity_id, ontology, canonical_name, aliases, description) VALUES (?, ?, ?, ?, ?)", + [entity.id, ontology, entity.canonicalName, (entity.aliases ?? []).join(" "), ""] + ); + }, + + updateEntityMetadata(id, patch) { + const updated = memory.updateEntityMetadata(id, patch); + enqueue( + "UPDATE entities SET canonical_name = ?, status = ?, superseded_by = ?, document = ? WHERE ontology = ? AND id = ?", + [ + updated.canonicalName, + updated.status ?? "active", + updated.supersededBy ?? null, + JSON.stringify(updated), + ontology, + updated.id + ] + ); + return updated; + }, + + appendClaim(claim) { + memory.appendClaim(claim); + const objectEntity = "entity" in claim.object ? claim.object.entity : null; + const objectValue = "entity" in claim.object ? null : JSON.stringify(claim.object.value); + enqueue( + `INSERT INTO claims (ontology, id, subject, predicate, object_entity, object_value, status, confidence, + valid_from, valid_to, observed_at, asserted_at, asserted_by, run_id, change_set, visibility, document) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + ontology, + claim.id, + claim.subject, + claim.predicate, + objectEntity, + objectValue, + claim.status, + claim.confidence ?? null, + claim.validTime?.from ?? null, + claim.validTime?.to ?? null, + claim.observedAt ?? null, + claim.assertedAt, + claim.assertedBy, + claim.runId ?? null, + claim.changeSet ?? null, + claim.visibility ?? "public", + JSON.stringify(claim) + ] + ); + for (const source of claim.sources ?? []) { + enqueue( + "INSERT OR IGNORE INTO claim_sources (ontology, claim_id, source_id) VALUES (?, ?, ?)", + [ontology, claim.id, source] + ); + } + }, + + setClaimStatus(transition) { + memory.setClaimStatus(transition); + enqueue( + "INSERT INTO status_log (ontology, kind, object_id, status, at, by, change_set, reason) VALUES (?, 'claim', ?, ?, ?, ?, ?, ?)", + [ + ontology, + transition.objectId, + String(transition.status), + transition.at, + transition.by, + transition.changeSet ?? null, + transition.reason ?? null + ] + ); + }, + + setEntityStatus(transition) { + memory.setEntityStatus(transition); + enqueue( + "INSERT INTO status_log (ontology, kind, object_id, status, at, by, change_set, reason) VALUES (?, 'entity', ?, ?, ?, ?, ?, ?)", + [ + ontology, + transition.objectId, + String(transition.status), + transition.at, + transition.by, + transition.changeSet ?? null, + transition.reason ?? null + ] + ); + }, + + addSource(source) { + memory.addSource(source); + enqueue( + `INSERT OR REPLACE INTO sources (ontology, id, source_type, uri, retrieved_at, content_hash, license, visibility, stale, document) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + ontology, + source.id, + source.sourceType, + source.uri, + source.retrievedAt, + source.contentHash ?? null, + source.license ?? null, + source.visibility ?? "public", + source.stale ? 1 : 0, + JSON.stringify(source) + ] + ); + }, + + addEvidence(record) { + memory.addEvidence(record); + enqueue("INSERT OR REPLACE INTO evidence (ontology, id, source_id, document) VALUES (?, ?, ?, ?)", [ + ontology, + record.id, + record.source, + JSON.stringify(record) + ]); + }, + + putChangeSet(changeSet) { + memory.putChangeSet(changeSet); + enqueue( + `INSERT OR REPLACE INTO changesets (ontology, id, title, status, created_at, created_by, base_revision, result_revision, document) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + ontology, + changeSet.id, + changeSet.title, + changeSet.status, + changeSet.createdAt, + changeSet.createdBy, + changeSet.baseRevision ?? null, + changeSet.resultRevision ?? null, + JSON.stringify(changeSet) + ] + ); + }, + + putReview(review) { + memory.putReview(review); + enqueue( + `INSERT OR REPLACE INTO reviews (ontology, id, change_set, reviewer, state, created_at, document) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ontology, review.id, review.changeSet, review.reviewer, review.state, review.createdAt, JSON.stringify(review)] + ); + }, + + putApproval(approval) { + memory.putApproval(approval); + enqueue( + `INSERT OR REPLACE INTO approvals (ontology, id, change_set, approver, created_at, document) + VALUES (?, ?, ?, ?, ?, ?)`, + [ontology, approval.id, approval.changeSet, approval.approver, approval.createdAt, JSON.stringify(approval)] + ); + }, + + appendEvent(event) { + memory.appendEvent(event); + enqueue( + `INSERT INTO ontology_events (ontology, id, type, at, actor, change_set, subject, revision, document) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + ontology, + event.id, + event.type, + event.at, + event.actor, + event.changeSet ?? null, + event.subject ?? null, + event.revision ?? null, + JSON.stringify(event) + ] + ); + }, + + bumpRevision() { + const revision = memory.bumpRevision(); + enqueue("UPDATE ontologies SET revision = revision + 1, updated_at = ? WHERE id = ?", [ + new Date().toISOString(), + ontology + ]); + return revision; + }, + + pending: () => pendingStatements.length, + + async flush() { + if (pendingStatements.length === 0) return { statements: 0 }; + const batch = pendingStatements.splice(0, pendingStatements.length); + await client.batch( + batch.map((statement) => ({ sql: statement.sql, args: statement.args })), + "write" + ); + return { statements: batch.length }; + }, + + close() { + client.close(); + } + }; + + return store; +} + +async function seedPackage(client: Client, ontology: string, pkg: LoadedPackage): Promise { + const statements: Array<{ sql: string; args: InArgs }> = [ + { + sql: "INSERT INTO ontologies (id, version, manifest, schema_json, revision, updated_at) VALUES (?, ?, ?, ?, 0, ?)", + args: [ + ontology, + pkg.manifest.version, + JSON.stringify(pkg.manifest), + JSON.stringify(pkg.schema), + new Date().toISOString() + ] + } + ]; + + for (const entity of pkg.data.entities) { + statements.push({ + sql: `INSERT INTO entities (ontology, id, type, canonical_name, status, superseded_by, visibility, created_at, created_by, document) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + ontology, + entity.id, + entity.type, + entity.canonicalName, + entity.status ?? "active", + entity.supersededBy ?? null, + entity.visibility ?? "public", + entity.createdAt, + entity.createdBy, + JSON.stringify(entity) + ] + }); + statements.push({ + sql: "INSERT INTO entity_fts (entity_id, ontology, canonical_name, aliases, description) VALUES (?, ?, ?, ?, ?)", + args: [entity.id, ontology, entity.canonicalName, (entity.aliases ?? []).join(" "), ""] + }); + for (const alias of entity.aliases ?? []) { + statements.push({ + sql: "INSERT OR IGNORE INTO aliases (ontology, entity_id, alias) VALUES (?, ?, ?)", + args: [ontology, entity.id, alias] + }); + } + for (const [namespace, value] of Object.entries(entity.externalIds ?? {})) { + statements.push({ + sql: "INSERT OR REPLACE INTO external_ids (ontology, entity_id, namespace, value) VALUES (?, ?, ?, ?)", + args: [ontology, entity.id, namespace, value] + }); + } + } + + for (const claim of pkg.data.claims) { + statements.push({ + sql: `INSERT INTO claims (ontology, id, subject, predicate, object_entity, object_value, status, confidence, + valid_from, valid_to, observed_at, asserted_at, asserted_by, run_id, change_set, visibility, document) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + ontology, + claim.id, + claim.subject, + claim.predicate, + "entity" in claim.object ? claim.object.entity : null, + "entity" in claim.object ? null : JSON.stringify(claim.object.value), + claim.status, + claim.confidence ?? null, + claim.validTime?.from ?? null, + claim.validTime?.to ?? null, + claim.observedAt ?? null, + claim.assertedAt, + claim.assertedBy, + claim.runId ?? null, + claim.changeSet ?? null, + claim.visibility ?? "public", + JSON.stringify(claim) + ] + }); + for (const source of claim.sources ?? []) { + statements.push({ + sql: "INSERT OR IGNORE INTO claim_sources (ontology, claim_id, source_id) VALUES (?, ?, ?)", + args: [ontology, claim.id, source] + }); + } + } + + for (const source of pkg.data.sources) { + statements.push({ + sql: `INSERT INTO sources (ontology, id, source_type, uri, retrieved_at, content_hash, license, visibility, stale, document) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + ontology, + source.id, + source.sourceType, + source.uri, + source.retrievedAt, + source.contentHash ?? null, + source.license ?? null, + source.visibility ?? "public", + source.stale ? 1 : 0, + JSON.stringify(source) + ] + }); + } + + for (const record of pkg.data.evidence) { + statements.push({ + sql: "INSERT INTO evidence (ontology, id, source_id, document) VALUES (?, ?, ?, ?)", + args: [ontology, record.id, record.source, JSON.stringify(record)] + }); + } + + // Batched so a partial seed cannot leave a half-populated ontology behind. + for (let i = 0; i < statements.length; i += 200) { + await client.batch(statements.slice(i, i + 200), "write"); + } +} + +async function hydrate(client: Client, ontology: string) { + const [meta, entities, claims, sources, evidence, log, changeSets, reviews, approvals, events] = + await Promise.all([ + client.execute({ sql: "SELECT manifest, schema_json, revision FROM ontologies WHERE id = ?", args: [ontology] }), + client.execute({ sql: "SELECT document FROM entities WHERE ontology = ?", args: [ontology] }), + client.execute({ sql: "SELECT document FROM claims WHERE ontology = ?", args: [ontology] }), + client.execute({ sql: "SELECT document FROM sources WHERE ontology = ?", args: [ontology] }), + client.execute({ sql: "SELECT document FROM evidence WHERE ontology = ?", args: [ontology] }), + client.execute({ + sql: "SELECT kind, object_id, status, at, by, change_set, reason FROM status_log WHERE ontology = ? ORDER BY seq", + args: [ontology] + }), + client.execute({ sql: "SELECT document FROM changesets WHERE ontology = ?", args: [ontology] }), + client.execute({ sql: "SELECT document FROM reviews WHERE ontology = ?", args: [ontology] }), + client.execute({ sql: "SELECT document FROM approvals WHERE ontology = ?", args: [ontology] }), + client.execute({ sql: "SELECT document FROM ontology_events WHERE ontology = ? ORDER BY seq", args: [ontology] }) + ]); + + const row = meta.rows[0]; + if (!row) throw new Error(`Ontology ${ontology} disappeared while hydrating`); + + const docs = (result: { rows: Array> }): T[] => + result.rows.map((r) => JSON.parse(String(r.document)) as T); + + const pkg: LoadedPackage = { + manifest: JSON.parse(String(row.manifest)) as LoadedPackage["manifest"], + schema: JSON.parse(String(row.schema_json)) as LoadedPackage["schema"], + data: { + entities: docs(entities), + claims: docs(claims), + sources: docs(sources), + evidence: docs(evidence) + }, + files: [] + }; + + return { + pkg, + revision: Number(row.revision ?? 0), + transitions: log.rows.map((r) => ({ + kind: String(r.kind) as "claim" | "entity", + transition: { + objectId: String(r.object_id), + status: String(r.status) as never, + at: String(r.at), + by: String(r.by), + changeSet: r.change_set ? String(r.change_set) : undefined, + reason: r.reason ? String(r.reason) : undefined + } + })), + changeSets: docs(changeSets) as never[], + reviews: docs(reviews) as never[], + approvals: docs(approvals) as never[], + events: docs(events) as never[] + }; +} + +/** Full-text entity search backed by FTS5 (R186). */ +export async function searchEntities( + client: Client, + ontology: string, + query: string, + limit = 20 +): Promise> { + const result = await client.execute({ + sql: `SELECT entity_id, canonical_name, rank FROM entity_fts + WHERE ontology = ? AND entity_fts MATCH ? + ORDER BY rank LIMIT ?`, + args: [ontology, query, limit] + }); + return result.rows.map((row) => ({ + id: String(row.entity_id), + canonicalName: String(row.canonical_name), + rank: Number(row.rank ?? 0) + })); +} + +export { revisionId }; diff --git a/packages/openontology/src/rdf.test.ts b/packages/openontology/src/rdf.test.ts new file mode 100644 index 0000000..52667b6 --- /dev/null +++ b/packages/openontology/src/rdf.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { exportTurtle, importTurtle } from "./rdf.js"; +import { constraintsToShacl } from "./shacl.js"; +import { packagePrefix } from "./jsonld.js"; +import { loadPrdFixturePackage } from "./test-helpers.js"; + +const pkg = loadPrdFixturePackage(); + +describe("RDF/Turtle", () => { + it("declares the prefixes it uses", () => { + const { turtle } = exportTurtle(pkg); + for (const prefix of ["oo:", "prov:", "rdf:", "rdfs:", "xsd:"]) { + expect(turtle).toContain(`@prefix ${prefix}`); + } + }); + + it("reifies claims so provenance survives the crossing", () => { + const { turtle } = exportTurtle(pkg); + expect(turtle).toContain("a oo:Claim"); + expect(turtle).toContain("rdf:subject"); + expect(turtle).toContain("prov:wasDerivedFrom"); + expect(turtle).toContain("prov:wasAttributedTo"); + }); + + it("also emits the plain triple for asserted relationships", () => { + const { turtle } = exportTurtle(pkg); + const worksOn = turtle.split("\n").filter((line) => line.includes("worksOn")); + // Reified (rdf:predicate) plus at least one direct triple. + expect(worksOn.some((line) => line.includes("rdf:predicate"))).toBe(true); + expect(worksOn.some((line) => !line.includes("rdf:predicate"))).toBe(true); + }); + + it("round-trips entities and claims through Turtle", () => { + const { turtle } = exportTurtle(pkg); + const back = importTurtle(turtle, { ...pkg.manifest, prefix: packagePrefix(pkg) }); + + expect(back.entities.map((e) => e.id).sort()).toEqual(pkg.data.entities.map((e) => e.id).sort()); + expect(back.claims.map((c) => c.id).sort()).toEqual(pkg.data.claims.map((c) => c.id).sort()); + expect(back.unsupported).toEqual([]); + + const original = pkg.data.claims[0]!; + const restored = back.claims.find((c) => c.id === original.id)!; + expect(restored.subject).toBe(original.subject); + expect(restored.predicate).toBe(original.predicate); + expect(restored.object).toEqual(original.object); + expect(restored.sources).toEqual(original.sources); + expect(restored.confidence).toBe(original.confidence); + expect(restored.validTime?.from).toBe(original.validTime?.from); + }); + + it("preserves a scalar property claim's value and type", () => { + const { turtle } = exportTurtle(pkg); + const back = importTurtle(turtle, { ...pkg.manifest, prefix: packagePrefix(pkg) }); + const homepage = back.claims.find((c) => c.predicate === "homepage")!; + expect(homepage.object).toEqual({ value: "https://example.org/zk-prover" }); + }); + + it("reports fields the profile cannot carry", () => { + const copy = structuredClone(pkg); + copy.data.claims[0]!.tags = ["zk"]; + copy.data.claims[0]!.license = "CC-BY-4.0"; + const { lossy } = exportTurtle(copy); + const entry = lossy.find((l) => l.objectId === copy.data.claims[0]!.id); + expect(entry?.fields).toEqual(expect.arrayContaining(["tags", "license"])); + }); + + it("counts what crossed the boundary", () => { + const { counts } = exportTurtle(pkg); + expect(counts.entities).toBe(pkg.data.entities.length); + expect(counts.claims).toBe(pkg.data.claims.length); + expect(counts.triples).toBeGreaterThan(counts.claims); + }); +}); + +describe("SHACL", () => { + it("maps a required-predicate constraint to a node shape", () => { + const { turtle, mapped } = constraintsToShacl(pkg); + expect(mapped.map((m) => m.constraint)).toContain("project-has-homepage"); + expect(turtle).toContain("sh:NodeShape"); + expect(turtle).toContain("sh:targetClass ns:Project"); + expect(turtle).toContain("sh:minCount 1"); + }); + + it("carries the constraint severity across", () => { + const { turtle } = constraintsToShacl(pkg); + expect(turtle).toContain("sh:Warning"); + }); + + it("reports uniqueness and query constraints as unmappable rather than faking them", () => { + const copy = structuredClone(pkg); + copy.schema.constraints.push( + { + openontology: "0.1", + kind: "Constraint", + id: "unique-homepage", + description: "Homepages are unique.", + rule: { type: "unique", predicate: "homepage" } + }, + { + openontology: "0.1", + kind: "Constraint", + id: "needs-review", + description: "No claims awaiting review.", + rule: { type: "query", query: "contributors" } + } + ); + + const { unmapped, turtle } = constraintsToShacl(copy); + expect(unmapped.map((u) => u.constraint)).toEqual(["unique-homepage", "needs-review"]); + expect(unmapped[0]!.reason).toMatch(/SPARQLConstraint/); + // The gap is stated in the output itself, not just in a return value. + expect(turtle).toContain("# unmapped: unique-homepage"); + }); +}); diff --git a/packages/openontology/src/rdf.ts b/packages/openontology/src/rdf.ts new file mode 100644 index 0000000..3da1972 --- /dev/null +++ b/packages/openontology/src/rdf.ts @@ -0,0 +1,355 @@ +import { toIri } from "./ids.js"; +import { packagePrefix, OO, PROV } from "./jsonld.js"; +import { OPENONTOLOGY_VERSION } from "./types.js"; +import type { BuiltPackage, Claim, Entity, LoadedPackage } from "./types.js"; + +/** + * RDF/Turtle export and import for the losslessly mappable core subset. + * + * Claims are reified — each is its own resource carrying subject, predicate, + * object, status, time, confidence, and provenance — because a bare triple + * cannot say "asserted by this agent, from this commit, valid since April." + * Asserted relationship claims additionally emit the plain triple, so a + * consumer that only wants the current graph gets one. + */ + +const PREFIXES: Array<[string, string]> = [ + ["oo", OO], + ["prov", PROV], + ["rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#"], + ["rdfs", "http://www.w3.org/2000/01/rdf-schema#"], + ["xsd", "http://www.w3.org/2001/XMLSchema#"] +]; + +/** Fields this profile carries. Everything else is reported as lossy. */ +const LOSSLESS_CLAIM_FIELDS = new Set([ + "openontology", + "kind", + "id", + "ontology", + "subject", + "predicate", + "object", + "status", + "confidence", + "validTime", + "observedAt", + "assertedAt", + "assertedBy", + "runId", + "sources", + "evidence", + "supersedes", + "disputes" +]); + +const LOSSLESS_ENTITY_FIELDS = new Set([ + "openontology", + "kind", + "id", + "type", + "canonicalName", + "aliases", + "externalIds", + "status", + "createdAt", + "createdBy", + "supersededBy" +]); + +export interface TurtleExport { + turtle: string; + lossy: Array<{ objectId: string; fields: string[] }>; + /** Counts, so a caller can report what actually crossed the boundary. */ + counts: { entities: number; claims: number; sources: number; triples: number }; +} + +export function exportTurtle(pkg: BuiltPackage | LoadedPackage): TurtleExport { + const manifest = pkg.manifest; + const prefix = packagePrefix(pkg); + const ns = manifest.namespace.endsWith("/") ? manifest.namespace : `${manifest.namespace}/`; + const iri = (id: string) => `<${toIri(id, { defaultNamespace: manifest.namespace })}>`; + const vocab = (term: string) => `<${ns}${encodeURIComponent(term)}>`; + + const lossy: TurtleExport["lossy"] = []; + const lines: string[] = []; + let triples = 0; + + const emit = (subject: string, pairs: Array<[string, string]>) => { + if (pairs.length === 0) return; + lines.push(`${subject}`); + pairs.forEach(([predicate, object], index) => { + triples += 1; + lines.push(` ${predicate} ${object}${index === pairs.length - 1 ? " ." : " ;"}`); + }); + lines.push(""); + }; + + for (const [name, uri] of PREFIXES) lines.push(`@prefix ${name}: <${uri}> .`); + lines.push(`@prefix ns: <${ns}> .`); + lines.push(`@prefix pkg: <${ns}> .`); + lines.push(""); + lines.push(`# LogicSRC OpenOntology ${OPENONTOLOGY_VERSION} — ${manifest.id}@${manifest.version}`); + lines.push(`# compact id prefix: ${prefix}`); + lines.push(""); + + for (const entity of pkg.data.entities) { + const pairs: Array<[string, string]> = [ + ["a", vocab(entity.type)], + ["rdfs:label", literal(entity.canonicalName)], + ["oo:status", literal(entity.status ?? "active")], + ["prov:generatedAtTime", typed(entity.createdAt, "xsd:dateTime")], + ["prov:wasAttributedTo", literal(entity.createdBy)] + ]; + for (const alias of entity.aliases ?? []) pairs.push(["oo:alias", literal(alias)]); + for (const [namespace, value] of Object.entries(entity.externalIds ?? {})) { + pairs.push(["oo:externalId", literal(`${namespace}:${value}`)]); + } + if (entity.supersededBy) pairs.push(["oo:supersededBy", iri(entity.supersededBy)]); + + emit(iri(entity.id), pairs); + + const extra = extraFields(entity as unknown as Record, LOSSLESS_ENTITY_FIELDS); + if (extra.length) lossy.push({ objectId: entity.id, fields: extra }); + } + + for (const claim of pkg.data.claims) { + const objectTerm = + "entity" in claim.object ? iri(claim.object.entity) : valueTerm(claim.object); + + const pairs: Array<[string, string]> = [ + ["a", "oo:Claim"], + ["rdf:subject", iri(claim.subject)], + ["rdf:predicate", vocab(claim.predicate)], + ["rdf:object", objectTerm], + ["oo:status", literal(claim.status)], + ["prov:generatedAtTime", typed(claim.assertedAt, "xsd:dateTime")], + ["prov:wasAttributedTo", literal(claim.assertedBy)] + ]; + + if (claim.confidence !== undefined) pairs.push(["oo:confidence", typed(String(claim.confidence), "xsd:double")]); + if (claim.validTime?.from) pairs.push(["oo:validFrom", typed(claim.validTime.from, "xsd:dateTime")]); + if (claim.validTime?.to) pairs.push(["oo:validTo", typed(claim.validTime.to, "xsd:dateTime")]); + if (claim.observedAt) pairs.push(["oo:observedAt", typed(claim.observedAt, "xsd:dateTime")]); + if (claim.runId) pairs.push(["prov:wasGeneratedBy", literal(claim.runId)]); + for (const source of claim.sources ?? []) pairs.push(["prov:wasDerivedFrom", iri(source)]); + for (const record of claim.evidence ?? []) pairs.push(["oo:evidence", iri(record)]); + if (claim.supersedes) pairs.push(["oo:supersedes", iri(claim.supersedes)]); + if (claim.disputes) pairs.push(["oo:disputes", iri(claim.disputes)]); + + emit(iri(claim.id), pairs); + + // The plain triple, for consumers that only want the accepted graph. + if (claim.status === "asserted" && "entity" in claim.object) { + emit(iri(claim.subject), [[vocab(claim.predicate), iri(claim.object.entity)]]); + } + + const extra = extraFields(claim as unknown as Record, LOSSLESS_CLAIM_FIELDS); + if (extra.length) lossy.push({ objectId: claim.id, fields: extra }); + } + + for (const source of pkg.data.sources) { + emit(iri(source.id), [ + ["a", "prov:Entity"], + ["oo:sourceType", literal(source.sourceType)], + ["oo:uri", literal(source.uri)], + ["prov:generatedAtTime", typed(source.retrievedAt, "xsd:dateTime")], + ...(source.license ? ([["oo:license", literal(source.license)]] as Array<[string, string]>) : []), + ...(source.contentHash ? ([["oo:contentHash", literal(source.contentHash)]] as Array<[string, string]>) : []) + ]); + } + + return { + turtle: `${lines.join("\n").trimEnd()}\n`, + lossy, + counts: { + entities: pkg.data.entities.length, + claims: pkg.data.claims.length, + sources: pkg.data.sources.length, + triples + } + }; +} + +/** + * Import the profile `exportTurtle` produces. + * + * This is deliberately a parser for that profile, not a general Turtle parser: + * it reads the reified claim shape and the entity shape, and ignores plain + * triples (which are redundant with the claims). Anything it cannot interpret + * is reported rather than silently dropped. + */ +export function importTurtle( + turtle: string, + manifest: { id: string; namespace: string; prefix?: string } +): { entities: Entity[]; claims: Claim[]; unsupported: string[] } { + const base = manifest.namespace.endsWith("/") ? manifest.namespace : `${manifest.namespace}/`; + const prefix = manifest.prefix ?? manifest.id; + + const compact = (value: string): string => { + const trimmed = value.replace(/^<|>$/g, ""); + if (!trimmed.startsWith(base)) return trimmed; + return [prefix, ...trimmed.slice(base.length).split("/").map(decodeURIComponent)].join(":"); + }; + const term = (value: string): string => { + const trimmed = value.replace(/^<|>$/g, ""); + if (!trimmed.startsWith(base)) return trimmed; + return decodeURIComponent(trimmed.slice(base.length)); + }; + + const entities: Entity[] = []; + const claims: Claim[] = []; + const unsupported: string[] = []; + + for (const block of splitBlocks(turtle)) { + const pairs = block.pairs; + const type = pairs.find(([p]) => p === "a" || p === "rdf:type")?.[1]; + + if (type === "oo:Claim") { + const objectTerm = pairs.find(([p]) => p === "rdf:object")?.[1] ?? ""; + const claim: Claim = { + openontology: OPENONTOLOGY_VERSION, + kind: "Claim", + id: compact(block.subject), + subject: compact(pairs.find(([p]) => p === "rdf:subject")?.[1] ?? ""), + predicate: term(pairs.find(([p]) => p === "rdf:predicate")?.[1] ?? ""), + object: objectTerm.startsWith("<") + ? { entity: compact(objectTerm) } + : { value: parseLiteral(objectTerm) }, + status: (unquote(pairs.find(([p]) => p === "oo:status")?.[1] ?? '"asserted"') as Claim["status"]) ?? "asserted", + assertedAt: unquote(pairs.find(([p]) => p === "prov:generatedAtTime")?.[1] ?? '""'), + assertedBy: unquote(pairs.find(([p]) => p === "prov:wasAttributedTo")?.[1] ?? '""') + }; + + const confidence = pairs.find(([p]) => p === "oo:confidence")?.[1]; + if (confidence) claim.confidence = Number(unquote(confidence)); + const from = pairs.find(([p]) => p === "oo:validFrom")?.[1]; + const to = pairs.find(([p]) => p === "oo:validTo")?.[1]; + if (from || to) { + claim.validTime = { + ...(from ? { from: unquote(from) } : {}), + ...(to ? { to: unquote(to) } : {}) + }; + } + const observed = pairs.find(([p]) => p === "oo:observedAt")?.[1]; + if (observed) claim.observedAt = unquote(observed); + const run = pairs.find(([p]) => p === "prov:wasGeneratedBy")?.[1]; + if (run) claim.runId = unquote(run); + + const sources = pairs.filter(([p]) => p === "prov:wasDerivedFrom").map(([, o]) => compact(o)); + if (sources.length) claim.sources = sources; + const evidence = pairs.filter(([p]) => p === "oo:evidence").map(([, o]) => compact(o)); + if (evidence.length) claim.evidence = evidence; + const supersedes = pairs.find(([p]) => p === "oo:supersedes")?.[1]; + if (supersedes) claim.supersedes = compact(supersedes); + const disputes = pairs.find(([p]) => p === "oo:disputes")?.[1]; + if (disputes) claim.disputes = compact(disputes); + + claims.push(claim); + continue; + } + + if (type === "prov:Entity" || !type) continue; + + if (!type.startsWith("<")) { + unsupported.push(`${block.subject} has unrecognized type ${type}`); + continue; + } + + const entity: Entity = { + openontology: OPENONTOLOGY_VERSION, + kind: "Entity", + id: compact(block.subject), + type: term(type), + canonicalName: unquote(pairs.find(([p]) => p === "rdfs:label")?.[1] ?? '""'), + createdAt: unquote(pairs.find(([p]) => p === "prov:generatedAtTime")?.[1] ?? '""'), + createdBy: unquote(pairs.find(([p]) => p === "prov:wasAttributedTo")?.[1] ?? '""') + }; + + const aliases = pairs.filter(([p]) => p === "oo:alias").map(([, o]) => unquote(o)); + if (aliases.length) entity.aliases = aliases; + + const externalIds = pairs.filter(([p]) => p === "oo:externalId").map(([, o]) => unquote(o)); + if (externalIds.length) { + entity.externalIds = Object.fromEntries( + externalIds.map((pair) => { + const at = pair.indexOf(":"); + return [pair.slice(0, at), pair.slice(at + 1)]; + }) + ); + } + + const status = pairs.find(([p]) => p === "oo:status")?.[1]; + if (status && unquote(status) !== "active") entity.status = unquote(status) as Entity["status"]; + const supersededBy = pairs.find(([p]) => p === "oo:supersededBy")?.[1]; + if (supersededBy) entity.supersededBy = compact(supersededBy); + + entities.push(entity); + } + + return { entities, claims, unsupported }; +} + +interface Block { + subject: string; + pairs: Array<[string, string]>; +} + +/** Split the profile's `subject\n predicate object ;\n … .` blocks. */ +function splitBlocks(turtle: string): Block[] { + const blocks: Block[] = []; + let current: Block | null = null; + + for (const raw of turtle.split("\n")) { + const line = raw.trim(); + if (!line || line.startsWith("#") || line.startsWith("@prefix")) continue; + + if (!raw.startsWith(" ")) { + if (current) blocks.push(current); + current = { subject: line.replace(/\s*[;.]$/, ""), pairs: [] }; + continue; + } + if (!current) continue; + + const body = line.replace(/\s*[;.]$/, ""); + const space = body.indexOf(" "); + if (space < 0) continue; + current.pairs.push([body.slice(0, space), body.slice(space + 1).trim()]); + } + + if (current) blocks.push(current); + // Plain triples re-state an asserted claim, so drop those single-pair blocks. + return blocks.filter((block) => block.pairs.some(([p]) => p === "a" || p === "rdf:type")); +} + +function literal(value: string): string { + return JSON.stringify(String(value)); +} + +function typed(value: string, datatype: string): string { + return `${JSON.stringify(value)}^^${datatype}`; +} + +function valueTerm(object: { value: unknown; language?: string }): string { + if (object.language) return `${JSON.stringify(String(object.value))}@${object.language}`; + if (typeof object.value === "number") return `${JSON.stringify(String(object.value))}^^xsd:double`; + if (typeof object.value === "boolean") return `${JSON.stringify(String(object.value))}^^xsd:boolean`; + return literal(String(object.value)); +} + +function unquote(term: string): string { + const match = /^"((?:[^"\\]|\\.)*)"/.exec(term.trim()); + if (!match) return term.trim(); + return JSON.parse(`"${match[1]}"`) as string; +} + +function parseLiteral(term: string): unknown { + const text = unquote(term); + if (term.includes("^^xsd:double") || term.includes("^^xsd:integer")) return Number(text); + if (term.includes("^^xsd:boolean")) return text === "true"; + return text; +} + +function extraFields(object: Record, lossless: Set): string[] { + return Object.keys(object).filter((key) => !lossless.has(key) && object[key] !== undefined); +} diff --git a/packages/openontology/src/shacl.ts b/packages/openontology/src/shacl.ts new file mode 100644 index 0000000..49cbe7e --- /dev/null +++ b/packages/openontology/src/shacl.ts @@ -0,0 +1,209 @@ +import { packagePrefix, OO } from "./jsonld.js"; +import type { BuiltPackage, Constraint, LoadedPackage } from "./types.js"; + +/** + * SHACL mapping for the constraint kinds whose semantics genuinely match. + * + * Four of the seven map cleanly onto node/property shapes. `unique` and the + * query-based checks do not — SHACL has no portable "this value appears once + * across the graph" without SPARQL constraints, and an OpenOntology saved + * query is not a SPARQL query. Those are reported as unmapped rather than + * approximated, because a shape that silently means something else is worse + * than no shape at all. + */ + +export interface ShaclExport { + turtle: string; + mapped: Array<{ constraint: string; shape: string }>; + unmapped: Array<{ constraint: string; rule: string; reason: string }>; +} + +export function constraintsToShacl(pkg: BuiltPackage | LoadedPackage): ShaclExport { + const manifest = pkg.manifest; + const ns = manifest.namespace.endsWith("/") ? manifest.namespace : `${manifest.namespace}/`; + const prefix = packagePrefix(pkg); + + const mapped: ShaclExport["mapped"] = []; + const unmapped: ShaclExport["unmapped"] = []; + const body: string[] = []; + + const shapeName = (constraint: Constraint) => + `ns:${toPascal(constraint.id)}Shape`; + + for (const constraint of pkg.schema.constraints) { + const severity = shaclSeverity(constraint.severity ?? "error"); + const shape = shapeName(constraint); + const rule = constraint.rule; + + switch (rule.type) { + case "required-predicate": { + body.push( + `${shape}`, + ` a sh:NodeShape ;`, + ` sh:targetClass ns:${rule.entityType} ;`, + ` rdfs:comment ${JSON.stringify(constraint.description)} ;`, + ` sh:property [`, + ` sh:path ns:${rule.predicate} ;`, + ` sh:minCount 1 ;`, + ` sh:severity ${severity} ;`, + ` sh:message ${JSON.stringify(constraint.description)} ;`, + ` ] .`, + "" + ); + mapped.push({ constraint: constraint.id, shape }); + break; + } + + case "cardinality": { + const counts = [ + rule.min !== undefined ? ` sh:minCount ${rule.min} ;` : null, + rule.max !== undefined ? ` sh:maxCount ${rule.max} ;` : null + ].filter(Boolean) as string[]; + body.push( + `${shape}`, + ` a sh:NodeShape ;`, + rule.entityType ? ` sh:targetClass ns:${rule.entityType} ;` : ` sh:targetSubjectsOf ns:${rule.predicate} ;`, + ` rdfs:comment ${JSON.stringify(constraint.description)} ;`, + ` sh:property [`, + ` sh:path ns:${rule.predicate} ;`, + ...counts, + ` sh:severity ${severity} ;`, + ` sh:message ${JSON.stringify(constraint.description)} ;`, + ` ] .`, + "" + ); + mapped.push({ constraint: constraint.id, shape }); + break; + } + + case "allowed-values": { + body.push( + `${shape}`, + ` a sh:NodeShape ;`, + ` sh:targetSubjectsOf ns:${rule.predicate} ;`, + ` rdfs:comment ${JSON.stringify(constraint.description)} ;`, + ` sh:property [`, + ` sh:path ns:${rule.predicate} ;`, + ` sh:in (${rule.values.map((value) => JSON.stringify(String(value))).join(" ")}) ;`, + ` sh:severity ${severity} ;`, + ` sh:message ${JSON.stringify(constraint.description)} ;`, + ` ] .`, + "" + ); + mapped.push({ constraint: constraint.id, shape }); + break; + } + + case "domain-range": { + const lines = [`${shape}`, ` a sh:NodeShape ;`]; + lines.push(` sh:targetSubjectsOf ns:${rule.predicate} ;`); + lines.push(` rdfs:comment ${JSON.stringify(constraint.description)} ;`); + if (rule.from?.length) { + lines.push( + ` sh:or (${rule.from.map((type) => `[ sh:class ns:${type} ]`).join(" ")}) ;` + ); + } + if (rule.to?.length) { + lines.push( + ` sh:property [`, + ` sh:path ns:${rule.predicate} ;`, + ` sh:or (${rule.to.map((type) => `[ sh:class ns:${type} ]`).join(" ")}) ;`, + ` sh:severity ${severity} ;`, + ` ] ;` + ); + } + lines.push(` sh:severity ${severity} .`, ""); + body.push(...lines); + mapped.push({ constraint: constraint.id, shape }); + break; + } + + case "temporal-bounds": { + const props: string[] = []; + if (rule.notBefore) props.push(` sh:minInclusive ${JSON.stringify(rule.notBefore)} ;`); + if (rule.notAfter) props.push(` sh:maxInclusive ${JSON.stringify(rule.notAfter)} ;`); + if (rule.requireValidFrom) props.push(` sh:minCount 1 ;`); + if (props.length === 0) { + unmapped.push({ + constraint: constraint.id, + rule: rule.type, + reason: "temporal-bounds with no bounds has nothing to express" + }); + break; + } + body.push( + `${shape}`, + ` a sh:NodeShape ;`, + ` sh:targetObjectsOf ns:${rule.predicate} ;`, + ` rdfs:comment ${JSON.stringify(constraint.description)} ;`, + ` sh:property [`, + ` sh:path oo:validFrom ;`, + ...props, + ` sh:severity ${severity} ;`, + ` ] .`, + "" + ); + mapped.push({ constraint: constraint.id, shape }); + break; + } + + case "unique": + unmapped.push({ + constraint: constraint.id, + rule: rule.type, + reason: + "graph-wide uniqueness has no portable SHACL Core equivalent; it needs a sh:SPARQLConstraint" + }); + break; + + case "query": + unmapped.push({ + constraint: constraint.id, + rule: rule.type, + reason: "an OpenOntology saved query is a triple-pattern AST, not SPARQL" + }); + break; + + default: + unmapped.push({ constraint: (constraint as Constraint).id, rule: "unknown", reason: "unrecognized rule type" }); + } + } + + const header = [ + "@prefix sh: .", + "@prefix rdfs: .", + "@prefix xsd: .", + `@prefix oo: <${OO}> .`, + `@prefix ns: <${ns}> .`, + "", + `# SHACL shapes generated from ${manifest.id}@${manifest.version} (compact prefix: ${prefix})`, + `# ${mapped.length} constraint(s) mapped, ${unmapped.length} not mappable to SHACL Core.`, + ...unmapped.map((entry) => `# unmapped: ${entry.constraint} (${entry.rule}) — ${entry.reason}`), + "" + ]; + + return { + turtle: `${[...header, ...body].join("\n").trimEnd()}\n`, + mapped, + unmapped + }; +} + +function shaclSeverity(severity: string): string { + switch (severity) { + case "warning": + return "sh:Warning"; + case "info": + case "policy": + return "sh:Info"; + default: + return "sh:Violation"; + } +} + +function toPascal(id: string): string { + return id + .split(/[-_\s]+/) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); +} diff --git a/packages/openontology/src/test-helpers.ts b/packages/openontology/src/test-helpers.ts new file mode 100644 index 0000000..3ce1f76 --- /dev/null +++ b/packages/openontology/src/test-helpers.ts @@ -0,0 +1,16 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadOntologyPackage } from "./package.js"; +import { initOntologyPackage } from "./scaffold.js"; +import type { LoadedPackage } from "./types.js"; + +/** + * A scaffolded package with a pinned timestamp, for tests that need real data + * without depending on the Ethereum example (which is removable by design). + */ +export function loadPrdFixturePackage(id = "test-ecosystem"): LoadedPackage { + const dir = mkdtempSync(join(tmpdir(), "openontology-fixture-")); + initOntologyPackage(dir, { id, now: "2026-07-26T00:00:00Z" }); + return loadOntologyPackage(dir); +} diff --git a/packages/tui/package.json b/packages/tui/package.json index e017116..b7c9678 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -9,8 +9,9 @@ "build": "tsc -p tsconfig.json" }, "dependencies": { - "@logicsrc/plugin-core": "file:../plugin-core", + "@logicsrc/openontology": "file:../openontology", "@logicsrc/plugin-coinpay": "file:../../plugins/coinpay", + "@logicsrc/plugin-core": "file:../plugin-core", "@logicsrc/plugin-email-accounts": "file:../../plugins/email-accounts", "@logicsrc/plugin-social-accounts": "file:../../plugins/social-accounts", "@logicsrc/plugin-ugig": "file:../../plugins/ugig" diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 9a89965..e18301a 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -3,6 +3,7 @@ import { coinPayPlugin } from "@logicsrc/plugin-coinpay"; import { emailAccountsPlugin } from "@logicsrc/plugin-email-accounts"; import { socialAccountsPlugin } from "@logicsrc/plugin-social-accounts"; import { uGigPlugin } from "@logicsrc/plugin-ugig"; +export { renderOntologyTui, renderOntologyKeyHelp, PANEL_KEYS, type OntologyPanel, type OntologyTuiOptions } from "./openontology.js"; export { ArcadeRegistry, createDefaultArcadeRegistry, renderArcadeList, renderArcadeSnapshot, runArcadeSession } from "./arcade/index.js"; export type { ArcadeEvent, GameAction, GameContext, GameControl, KeyEvent, TaskEvent, TaskSnapshot, TerminalFrame, WaitingGame } from "./arcade/index.js"; diff --git a/packages/tui/src/openontology.test.ts b/packages/tui/src/openontology.test.ts new file mode 100644 index 0000000..1e5896a --- /dev/null +++ b/packages/tui/src/openontology.test.ts @@ -0,0 +1,94 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; +import { + createOntologyEngine, + initOntologyPackage, + loadOntologyPackage, + localActor +} from "@logicsrc/openontology"; +import { renderOntologyKeyHelp, renderOntologyTui, PANEL_KEYS } from "./openontology.js"; + +const dirs: string[] = []; +afterAll(() => { + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); +}); + +function engine() { + const dir = mkdtempSync(join(tmpdir(), "tui-ontology-")); + dirs.push(dir); + initOntologyPackage(dir, { id: "test-ecosystem", now: "2026-07-26T00:00:00Z" }); + return createOntologyEngine({ + package: loadOntologyPackage(dir), + actor: localActor("curator"), + clock: () => "2026-07-26T00:00:00Z" + }); +} + +describe("ontology TUI", () => { + it("renders a bordered frame with the key bar and status line", () => { + const output = renderOntologyTui(engine()); + const lines = output.split("\n"); + expect(lines[0]!.startsWith("┌")).toBe(true); + expect(lines.at(-1)!.startsWith("└")).toBe(true); + expect(output).toContain("e entities"); + expect(output).toContain("q quit"); + expect(output).toContain("asserted claims"); + }); + + it("never exceeds the requested width, and stays usable when narrow", () => { + for (const width of [60, 78, 120]) { + const lines = renderOntologyTui(engine(), { width }).split("\n"); + const widths = new Set(lines.map((line) => [...line].length)); + expect(widths.size).toBe(1); + expect([...widths][0]).toBe(Math.max(width, 60)); + } + }); + + it("shows every panel", () => { + const e = engine(); + expect(renderOntologyTui(e, { panel: "types" })).toContain("Person"); + expect(renderOntologyTui(e, { panel: "entities" })).toContain("Alice Reyes"); + expect(renderOntologyTui(e, { panel: "claims" })).toContain("worksOn"); + expect(renderOntologyTui(e, { panel: "sources" })).toContain("web-page"); + expect(renderOntologyTui(e, { panel: "queries" })).toContain("contributors"); + expect(renderOntologyTui(e, { panel: "changesets" })).toContain("no change sets"); + expect(renderOntologyTui(e, { panel: "violations" })).toContain("no errors"); + expect(renderOntologyTui(e, { panel: "audit" })).toContain("no events"); + // Rendering is read-only: repainting must not append to the audit log. + expect(renderOntologyTui(e, { panel: "audit" })).toContain("no events"); + }); + + it("distinguishes claim status by glyph and word, not colour", () => { + const claims = renderOntologyTui(engine(), { panel: "claims", rows: 20 }); + expect(claims).toMatch(/✓ asserted/); + // No ANSI escapes: the panel must survive a monochrome terminal. + expect(claims.includes("[")).toBe(false); + }); + + it("surfaces proposed change sets and disputes in the status bar", () => { + const e = engine(); + e.createOntologyChangeSet({ + title: "pending", + operations: [ + { + op: "assert-claim", + value: { + subject: "test:person:alice", + predicate: "worksOn", + object: { entity: "test:project:docs-portal" }, + sources: ["test:source:repo"] + } + } + ] + }); + expect(renderOntologyTui(e)).toContain("1 proposed"); + expect(renderOntologyTui(e, { panel: "changesets" })).toContain("pending"); + }); + + it("lists the keys it binds", () => { + expect(renderOntologyKeyHelp()).toContain("v: validate"); + expect(PANEL_KEYS.map((entry) => entry.key)).toContain("q"); + }); +}); diff --git a/packages/tui/src/openontology.ts b/packages/tui/src/openontology.ts new file mode 100644 index 0000000..1e7f65b --- /dev/null +++ b/packages/tui/src/openontology.ts @@ -0,0 +1,282 @@ +import { validateOntologyPackage, type OntologyEngine, type ValidationReport } from "@logicsrc/openontology"; + +/** + * Keyboard-first OpenOntology panels. + * + * Rendered as plain strings so they work over SSH, in tmux, in a narrow + * terminal, and in tests. Graph diagrams are optional in the standard; lists, + * trees, and paths are what this shows. + */ + +export type OntologyPanel = "types" | "entities" | "claims" | "sources" | "queries" | "changesets" | "violations" | "audit"; + +export const PANEL_KEYS: Array<{ key: string; panel: OntologyPanel | "quit" | "search"; label: string }> = [ + { key: "/", panel: "search", label: "search" }, + { key: "t", panel: "types", label: "types" }, + { key: "e", panel: "entities", label: "entities" }, + { key: "c", panel: "claims", label: "claims" }, + { key: "s", panel: "sources", label: "sources" }, + { key: "r", panel: "queries", label: "queries" }, + { key: "g", panel: "changesets", label: "change sets" }, + { key: "v", panel: "violations", label: "validate" }, + { key: "a", panel: "audit", label: "audit" }, + { key: "q", panel: "quit", label: "quit" } +]; + +export interface OntologyTuiOptions { + panel?: OntologyPanel; + /** Terminal width. Clamped to a usable minimum. */ + width?: number; + /** Rows of detail to show. */ + rows?: number; + selected?: string; +} + +const MIN_WIDTH = 60; + +export function renderOntologyTui(engine: OntologyEngine, options: OntologyTuiOptions = {}): string { + const width = Math.max(options.width ?? 78, MIN_WIDTH); + const rows = options.rows ?? 8; + const panel = options.panel ?? "entities"; + const manifest = engine.getOntologyManifest(); + const schema = engine.getOntologySchema(); + + const lines: string[] = []; + const inner = width - 2; + + const rule = (left: string, right: string, fill = "─") => `${left}${fill.repeat(inner)}${right}`; + const row = (text: string) => `│${clip(` ${text}`, inner)}│`; + const heading = (text: string) => `├${clip(`─ ${text} `, inner, "─")}┤`; + + lines.push(rule("┌", "┐")); + lines.push(row(`OpenOntology: ${manifest.id}@${manifest.version} rev ${engine.store.revision()}`)); + // The key bar wraps rather than truncating: a binding you cannot see is a + // binding you do not have. + for (const line of wrap(PANEL_KEYS.map((entry) => `${entry.key} ${entry.label}`), inner - 1)) { + lines.push(row(line)); + } + + lines.push(heading(panelTitle(panel))); + for (const line of panelBody(engine, panel, { rows, selected: options.selected, width: inner })) { + lines.push(row(line)); + } + + // Status bar: what needs a human, always visible. + const changeSets = engine.store.listChangeSets(); + const proposed = changeSets.filter((entry) => entry.status === "proposed").length; + const conflicted = changeSets.filter((entry) => entry.status === "conflicted").length; + const report = validate(engine); + const disputed = engine.store.listClaims({ status: ["disputed"] }).length; + + lines.push(heading("status")); + lines.push( + row( + `${proposed} proposed ${disputed} disputed ${conflicted} conflicts ` + + `${report.counts.error} errors ${report.counts.warning} warnings` + ) + ); + lines.push(row(`${schema.entityTypes.length} types ${engine.store.listEntities().length} entities ${engine.store.listClaims({ status: ["asserted"] }).length} asserted claims`)); + lines.push(rule("└", "┘")); + + return lines.join("\n"); +} + +/** + * Validate without emitting an event. + * + * `engine.validateOntologyPackage()` records a package.validated event, which + * is right for a CLI run and wrong for a panel that repaints on every keypress: + * rendering a read-only view must not write to the audit log. + */ +function validate(engine: OntologyEngine): ValidationReport { + return validateOntologyPackage({ + manifest: engine.getOntologyManifest(), + schema: engine.getOntologySchema(), + data: { + entities: engine.store.listEntities(), + claims: engine.store.listClaims({ + status: ["asserted", "proposed", "disputed", "retracted", "superseded", "derived"] + }), + sources: engine.store.listSources(), + evidence: engine.store.listEvidence() + }, + files: [] + }); +} + +function panelTitle(panel: OntologyPanel): string { + switch (panel) { + case "types": + return "Types"; + case "entities": + return "Entities"; + case "claims": + return "Claims"; + case "sources": + return "Sources"; + case "queries": + return "Saved queries"; + case "changesets": + return "Change sets"; + case "violations": + return "Validation"; + default: + return "Audit"; + } +} + +function panelBody( + engine: OntologyEngine, + panel: OntologyPanel, + view: { rows: number; selected?: string; width: number } +): string[] { + const { rows } = view; + + switch (panel) { + case "types": { + const counts = new Map(); + for (const entity of engine.store.listEntities()) { + counts.set(entity.type, (counts.get(entity.type) ?? 0) + 1); + } + return engine + .getOntologySchema() + .entityTypes.slice(0, rows) + .map((type) => `${pad(type.id, 20)} ${String(counts.get(type.id) ?? 0).padStart(5)} ${type.label}`); + } + + case "entities": { + const entities = engine.store.listEntities({ limit: rows }); + return entities.map( + (entity) => + `${statusMark(entity.status ?? "active")} ${pad(entity.canonicalName, 26)} ${pad(entity.type, 14)} ${entity.id}` + ); + } + + case "claims": { + const claims = engine.store.listClaims({ + status: ["asserted", "proposed", "disputed", "retracted", "superseded", "derived"], + limit: rows + }); + return claims.map((claim) => { + const object = "entity" in claim.object ? claim.object.entity : JSON.stringify(claim.object.value); + const confidence = claim.confidence === undefined ? " — " : claim.confidence.toFixed(2); + const from = claim.validTime?.from?.slice(0, 10) ?? "—"; + // Status is never conveyed by colour alone: a glyph and the word. + return `${statusMark(claim.status)} ${pad(claim.status, 10)} ${pad(shorten(claim.subject), 22)} ${pad(claim.predicate, 14)} ${pad(shorten(object), 22)} ${confidence} ${from} src:${claim.sources?.length ?? 0}`; + }); + } + + case "sources": { + return engine.store + .listSources() + .slice(0, rows) + .map( + (source) => + `${source.stale ? "!" : "·"} ${pad(source.sourceType, 14)} ${pad(source.license ?? "unknown", 12)} ${source.uri}` + ); + } + + case "queries": { + return engine + .getOntologySchema() + .queries.slice(0, rows) + .map((query) => `${pad(query.id, 30)} ${query.description}`); + } + + case "changesets": { + const changeSets = engine.store.listChangeSets().slice(0, rows); + if (changeSets.length === 0) return ["(no change sets in this session)"]; + return changeSets.map((changeSet) => { + const approvals = engine.store.listApprovals(changeSet.id).length; + const required = changeSet.requiredApprovals ?? 0; + return `${statusMark(changeSet.status)} ${pad(changeSet.status, 10)} ${pad(changeSet.title, 34)} ops:${String(changeSet.operations.length).padStart(3)} appr:${approvals}/${required}`; + }); + } + + case "violations": { + const report = validate(engine); + const findings = report.findings.filter((finding) => finding.severity !== "info").slice(0, rows); + if (findings.length === 0) return ["✓ no errors, warnings, or policy findings"]; + return findings.map( + (finding) => `${severityMark(finding.severity)} ${pad(finding.code, 24)} ${finding.message}` + ); + } + + default: { + const events = engine.listEvents({ limit: rows }); + if (events.length === 0) return ["(no events in this session)"]; + return events.map( + (event) => `${event.at.slice(0, 19)} ${pad(event.type, 20)} ${pad(event.actor, 20)} ${event.subject ?? ""}` + ); + } + } +} + +/** Glyphs, not colour, so the status survives a monochrome terminal. */ +function statusMark(status: string): string { + switch (status) { + case "asserted": + case "active": + case "applied": + return "✓"; + case "proposed": + return "?"; + case "disputed": + case "conflicted": + return "!"; + case "retracted": + case "rejected": + return "×"; + case "superseded": + case "merged": + return "→"; + case "derived": + return "ƒ"; + case "archived": + return "▪"; + default: + return "·"; + } +} + +function severityMark(severity: string): string { + return severity === "error" ? "✗" : severity === "warning" ? "!" : "·"; +} + +/** Pack items onto as many lines as the width needs. */ +function wrap(items: string[], width: number): string[] { + const lines: string[] = []; + let current = ""; + for (const item of items) { + const candidate = current ? `${current} ${item}` : item; + if (candidate.length > width && current) { + lines.push(current); + current = item; + } else { + current = candidate; + } + } + if (current) lines.push(current); + return lines; +} + +function pad(value: string, width: number): string { + const text = String(value ?? ""); + return text.length >= width ? `${text.slice(0, Math.max(width - 1, 1))}…` : text.padEnd(width); +} + +function clip(value: string, width: number, fill = " "): string { + const text = value.length > width ? `${value.slice(0, width - 1)}…` : value; + return text.padEnd(width, fill); +} + +/** Compact ids read better in a narrow column without their prefix. */ +function shorten(id: string): string { + const parts = String(id).split(":"); + return parts.length > 2 ? parts.slice(1).join(":") : String(id); +} + +/** One-line help for the panel keys, for a footer or `--help`. */ +export function renderOntologyKeyHelp(): string { + return PANEL_KEYS.map((entry) => `${entry.key}: ${entry.label}`).join(" "); +}