mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-02 20:57:03 +00:00
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>
209 lines
7.1 KiB
TypeScript
209 lines
7.1 KiB
TypeScript
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: <http://www.w3.org/ns/shacl#> .",
|
|
"@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .",
|
|
"@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .",
|
|
`@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("");
|
|
}
|