logicsrc/packages/openontology/src/policy.ts
Anthony Ettinger 58c942c67f
Some checks failed
CI / build (push) Has been cancelled
test / test (push) Has been cancelled
feat(openontology): implement OpenOntology Phase 0 + local engine and CLI (#99)
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>
2026-07-26 02:10:13 -07:00

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