logicsrc/apps/logicsrc-web/src/app/api/ontologies/openapi/route.ts
Anthony Ettinger da5f6f8381
Some checks are pending
CI / build (push) Waiting to run
test / test (push) Waiting to run
feat(openontology): Phase 2 + Phase 3 — storage, REST/SSE, MCP, RDF/SHACL, adapters, TUI, explorer (#101)
Everything the two shipped PRD phases deferred, minus what is called out below.

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

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

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

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

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

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

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

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

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

Refs: prd/0001-add-logicsrc-openontology-spec.md

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 05:10:33 -07:00

371 lines
13 KiB
TypeScript

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" }
});
}