mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-03 05:07:10 +00:00
Implements OpenPRD 0001 through Phase 0 (specification, schemas, example, docs surface) and Phase 1 (local engine, CLI, conformance tests). Schemas (17 contracts, JSON Schema Draft 2020-12, additionalProperties:false) manifest, namespace, entity-type, property, relationship-type, constraint, query, action, entity, claim, source, evidence, changeset, review, approval, event, package — registered in @logicsrc/validators and exported from @logicsrc/schemas under https://logicsrc.com/schemas/openontology/. @logicsrc/openontology - canonical JSON + sha256 package digests; YAML, JSON, NDJSON, and inline authoring all compile to the same bytes, so digests are authoring-agnostic - id profile: compact / IRI / urn with one canonicalization rule, prefix bound by a Namespace object so IRIs reverse unambiguously - validation: schema, graph (domain/range, datatypes, dangling refs), provenance (source-or-firstParty, agent runId, derivation inputs), policy (excerpt limits, licensing, visibility, staleness) and declared constraints; four severities, stable codes, text/json/yaml/markdown - portable triple-pattern query AST: multi-hop, 14 operators, asOf and recordedAsOf, per-status filtering, distinct/order/limit, explanation mode, and enforced depth/binding/row limits - append-only store: claims are immutable; dispute/retract/supersede append status transitions and the effective status is the latest one - change sets: 9 operations, atomic pre-flight, conflict detection on stale base revisions, semantic diff with duplicate-identity warnings and affected-query deltas, per-operation reviewer decisions - policy: agents propose but can never apply — the denial keys on actor type, so every scope plus high confidence plus --yolo still cannot apply; merges need approval, bulk retractions need two, undeclared action side effects are denied - JSON-LD 1.1 export/import with PROV-O aliases and lossy-field reporting - pluggable signature envelope with a jws-ed25519 reference profile and a fail-closed trust policy CLI: logicsrc ontology init|validate|lint|build|inspect, entity, claim, query, changeset, import, export, audit. Reads take --format, writes default to a proposal, exit codes are stable for CI. Example: examples/openontology/ethereum-ecosystem — 12 entity types, 17 relationship types, 63 entities, 169 claims, 25 sources, 31 evidence records, 5 saved queries, every claim lifecycle state, and a pending merge proposal. All data is fictional; the directory is removable without affecting any core test. Docs: docs/openontology{,-governance,-interoperability}.md, a real /openontology route, homepage + nav + sitemap entries, and a root README section. Verification: 112 new tests; full monorepo build and every workspace test pass; conformance bundle (18 valid + 13 invalid fixtures) runs against the published schemas alone; Node.js 25 and Bun 1.3 produce byte-identical digests, revisions, event trails, and query results. Not included (later PRD phases): MCP resources, REST/SSE, Turso adapter, TUI and PWA surfaces, RDF/SHACL mappings, source adapters, governed actions. Refs: prd/0001-add-logicsrc-openontology-spec.md Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
251 lines
8.3 KiB
TypeScript
251 lines
8.3 KiB
TypeScript
import type { ActorType, ChangeSet } from "./types.js";
|
|
|
|
export const SCOPES = [
|
|
"ontology:read",
|
|
"ontology:schema:read",
|
|
"ontology:query",
|
|
"ontology:source:read",
|
|
"ontology:claim:propose",
|
|
"ontology:claim:write",
|
|
"ontology:changeset:review",
|
|
"ontology:changeset:approve",
|
|
"ontology:action:execute",
|
|
"ontology:publish",
|
|
"ontology:admin"
|
|
] as const;
|
|
|
|
export type Scope = (typeof SCOPES)[number];
|
|
|
|
export interface Actor {
|
|
id: string;
|
|
type: ActorType;
|
|
scopes: Scope[];
|
|
/**
|
|
* Unattended/--yolo execution. Recorded for audit and explicitly NOT a way
|
|
* to skip a required approval (R107) — it only affects prompting.
|
|
*/
|
|
unattended?: boolean;
|
|
client?: string;
|
|
}
|
|
|
|
export type Operation =
|
|
| { kind: "read" }
|
|
| { kind: "query" }
|
|
| { kind: "propose"; changeSet?: ChangeSet }
|
|
| { kind: "review" }
|
|
| { kind: "approve" }
|
|
| { kind: "apply"; changeSet: ChangeSet }
|
|
| { kind: "publish" }
|
|
| { kind: "execute-action"; approvalMode: "none" | "policy" | "always"; declaredSideEffects: boolean };
|
|
|
|
export interface PolicyDecision {
|
|
decision: "allow" | "deny" | "require-approval";
|
|
rule: string;
|
|
reason: string;
|
|
/** Approvals that must exist before an apply/execute may proceed. */
|
|
requiredApprovals: number;
|
|
missingScopes: Scope[];
|
|
}
|
|
|
|
export interface PolicyOptions {
|
|
/** A change set with at least this many retractions counts as bulk. */
|
|
bulkRetractionThreshold?: number;
|
|
/** Approvals required for an entity merge. */
|
|
mergeApprovals?: number;
|
|
/** Approvals required for a bulk retraction. */
|
|
bulkRetractionApprovals?: number;
|
|
/** Allow agents to apply directly. Off by default and strongly discouraged. */
|
|
allowAgentApply?: boolean;
|
|
}
|
|
|
|
const DEFAULTS: Required<PolicyOptions> = {
|
|
bulkRetractionThreshold: 2,
|
|
mergeApprovals: 1,
|
|
bulkRetractionApprovals: 2,
|
|
allowAgentApply: false
|
|
};
|
|
|
|
function need(actor: Actor, scopes: Scope[]): Scope[] {
|
|
if (actor.scopes.includes("ontology:admin")) return [];
|
|
return scopes.filter((scope) => !actor.scopes.includes(scope));
|
|
}
|
|
|
|
/**
|
|
* The default policy from the PRD, expressed as code:
|
|
*
|
|
* agent query → allowed with ontology:query
|
|
* agent proposal → allowed with ontology:claim:propose
|
|
* agent direct apply → DENIED regardless of scopes or confidence
|
|
* human apply → requires ontology:claim:write
|
|
* entity merge → one curator approval
|
|
* bulk retraction → two approvals
|
|
* breaking migration → maintainer approval
|
|
* undeclared action → denied
|
|
*/
|
|
export function evaluatePolicy(
|
|
operation: Operation,
|
|
actor: Actor,
|
|
options: PolicyOptions = {}
|
|
): PolicyDecision {
|
|
const opts = { ...DEFAULTS, ...options };
|
|
const allow = (rule: string, reason: string): PolicyDecision => ({
|
|
decision: "allow",
|
|
rule,
|
|
reason,
|
|
requiredApprovals: 0,
|
|
missingScopes: []
|
|
});
|
|
const deny = (rule: string, reason: string, missingScopes: Scope[] = []): PolicyDecision => ({
|
|
decision: "deny",
|
|
rule,
|
|
reason,
|
|
requiredApprovals: 0,
|
|
missingScopes
|
|
});
|
|
|
|
switch (operation.kind) {
|
|
case "read": {
|
|
const missing = need(actor, ["ontology:read"]);
|
|
return missing.length
|
|
? deny("read.scope", "Reading requires ontology:read", missing)
|
|
: allow("read.scope", "Actor holds ontology:read");
|
|
}
|
|
|
|
case "query": {
|
|
const missing = need(actor, ["ontology:query"]);
|
|
return missing.length
|
|
? deny("query.scope", "Querying requires ontology:query", missing)
|
|
: allow("query.scope", "Actor holds ontology:query");
|
|
}
|
|
|
|
case "propose": {
|
|
const missing = need(actor, ["ontology:claim:propose"]);
|
|
return missing.length
|
|
? deny("propose.scope", "Proposing requires ontology:claim:propose", missing)
|
|
: allow("propose.scope", "Actor holds ontology:claim:propose");
|
|
}
|
|
|
|
case "review": {
|
|
const missing = need(actor, ["ontology:changeset:review"]);
|
|
return missing.length
|
|
? deny("review.scope", "Reviewing requires ontology:changeset:review", missing)
|
|
: allow("review.scope", "Actor holds ontology:changeset:review");
|
|
}
|
|
|
|
case "approve": {
|
|
const missing = need(actor, ["ontology:changeset:approve"]);
|
|
return missing.length
|
|
? deny("approve.scope", "Approving requires ontology:changeset:approve", missing)
|
|
: allow("approve.scope", "Actor holds ontology:changeset:approve");
|
|
}
|
|
|
|
case "publish": {
|
|
const missing = need(actor, ["ontology:publish"]);
|
|
if (missing.length) return deny("publish.scope", "Publishing requires ontology:publish", missing);
|
|
return {
|
|
decision: "require-approval",
|
|
rule: "publish.maintainer-approval",
|
|
reason: "Public package publish requires maintainer approval and a passing conformance run",
|
|
requiredApprovals: 1,
|
|
missingScopes: []
|
|
};
|
|
}
|
|
|
|
case "execute-action": {
|
|
if (!operation.declaredSideEffects) {
|
|
return deny(
|
|
"action.undeclared-side-effects",
|
|
"Action execution is denied when side effects are undeclared"
|
|
);
|
|
}
|
|
const missing = need(actor, ["ontology:action:execute"]);
|
|
if (missing.length) {
|
|
return deny("action.scope", "Executing an action requires ontology:action:execute", missing);
|
|
}
|
|
if (operation.approvalMode === "always") {
|
|
return {
|
|
decision: "require-approval",
|
|
rule: "action.approval-always",
|
|
reason: "This action declares approval.mode: always",
|
|
requiredApprovals: 1,
|
|
missingScopes: []
|
|
};
|
|
}
|
|
return allow("action.scope", "Actor holds ontology:action:execute");
|
|
}
|
|
|
|
case "apply": {
|
|
const changeSet = operation.changeSet;
|
|
|
|
// R105/R107: neither model confidence nor unattended mode is a permission.
|
|
if (actor.type === "agent" && !opts.allowAgentApply) {
|
|
return deny(
|
|
"apply.agent-denied",
|
|
"Agents may propose but never apply directly; a human or service actor must apply"
|
|
);
|
|
}
|
|
|
|
const missing = need(actor, ["ontology:claim:write"]);
|
|
if (missing.length) {
|
|
return deny("apply.scope", "Applying requires ontology:claim:write", missing);
|
|
}
|
|
|
|
const merges = changeSet.operations.filter((op) => op.op === "merge-entity").length;
|
|
const retractions = changeSet.operations.filter((op) => op.op === "retract-claim").length;
|
|
const breaking = changeSet.operations.some(
|
|
(op) => op.op === "schema-migration" && op.breaking === true
|
|
);
|
|
|
|
let requiredApprovals = changeSet.requiredApprovals ?? 0;
|
|
let rule = "apply.scope";
|
|
let reason = "Actor holds ontology:claim:write";
|
|
|
|
if (merges > 0 && requiredApprovals < opts.mergeApprovals) {
|
|
requiredApprovals = opts.mergeApprovals;
|
|
rule = "apply.merge-approval";
|
|
reason = `Entity merge requires ${opts.mergeApprovals} curator approval(s)`;
|
|
}
|
|
if (retractions >= opts.bulkRetractionThreshold && requiredApprovals < opts.bulkRetractionApprovals) {
|
|
requiredApprovals = opts.bulkRetractionApprovals;
|
|
rule = "apply.bulk-retraction";
|
|
reason = `Bulk retraction (${retractions} claims) requires ${opts.bulkRetractionApprovals} approvals`;
|
|
}
|
|
if (breaking) {
|
|
requiredApprovals = Math.max(requiredApprovals, 1);
|
|
rule = "apply.breaking-migration";
|
|
reason = "Breaking schema migration requires maintainer approval and a major version bump";
|
|
}
|
|
|
|
if (requiredApprovals > 0) {
|
|
return { decision: "require-approval", rule, reason, requiredApprovals, missingScopes: [] };
|
|
}
|
|
return allow(rule, reason);
|
|
}
|
|
|
|
default:
|
|
return deny("unknown-operation", "No policy rule matched this operation");
|
|
}
|
|
}
|
|
|
|
/** Convenience actor used by the CLI for local, offline, single-user work. */
|
|
export function localActor(id = "local", type: ActorType = "human"): Actor {
|
|
return { id, type, scopes: [...SCOPES] };
|
|
}
|
|
|
|
export function readOnlyActor(id: string, type: ActorType = "agent"): Actor {
|
|
return { id, type, scopes: ["ontology:read", "ontology:schema:read", "ontology:query", "ontology:source:read"] };
|
|
}
|
|
|
|
export function proposerActor(id: string, type: ActorType = "agent"): Actor {
|
|
return {
|
|
id,
|
|
type,
|
|
scopes: [
|
|
"ontology:read",
|
|
"ontology:schema:read",
|
|
"ontology:query",
|
|
"ontology:source:read",
|
|
"ontology:claim:propose"
|
|
]
|
|
};
|
|
}
|