From 2872d088c82608e6cce48222d3778456e240efe3 Mon Sep 17 00:00:00 2001 From: Viktor Pelle Date: Fri, 20 Mar 2026 16:33:26 +0100 Subject: [PATCH 1/3] add paranet-scoped CCL policies, evaluation publishing, and owner-only approval CCL (Corroboration & Consensus Language) is a deterministic DSL for expressing how a paranet decides whether shared DKG facts are sufficient to support, reject, or promote a claim. We need it so agents and nodes can evaluate the same approved policy over the same snapshot, produce the same result, and turn paranet governance into something replayable, auditable, and domain-specific instead of relying on ad hoc reasoning. --- ccl_v0_1/LANGUAGE_SPEC.md | 282 ++++++++++ ccl_v0_1/README.md | 126 +++++ ccl_v0_1/SURFACE_SYNTAX.md | 141 +++++ ccl_v0_1/evaluator/reference_evaluator.js | 292 ++++++++++ ccl_v0_1/evaluator/reference_evaluator.py | 198 +++++++ ccl_v0_1/examples/context_corroboration.ccl | 34 ++ ccl_v0_1/examples/owner_assertion.ccl | 10 + ccl_v0_1/grammar.ebnf | 43 ++ ccl_v0_1/package-lock.json | 31 ++ ccl_v0_1/package.json | 11 + ccl_v0_1/policies/context_corroboration.yaml | 56 ++ ccl_v0_1/policies/owner_assertion.yaml | 19 + ccl_v0_1/tests/TEST_RESULTS.md | 12 + ccl_v0_1/tests/cases/01_owner_valid.yaml | 18 + ccl_v0_1/tests/cases/02_owner_invalid.yaml | 16 + .../03_context_minimal_corroboration.yaml | 27 + .../cases/04_context_missing_vendor.yaml | 26 + .../cases/05_context_workspace_excluded.yaml | 26 + ccl_v0_1/tests/cases/06_context_disputed.yaml | 33 ++ .../cases/07_context_epoch_mismatch.yaml | 29 + .../tests/cases/08_context_quorum_accept.yaml | 31 ++ ccl_v0_1/tests/run_all_tests.js | 37 ++ ccl_v0_1/tests/run_all_tests.py | 41 ++ packages/adapter-openclaw/README.md | 9 +- packages/adapter-openclaw/skills/ccl/SKILL.md | 173 ++++++ packages/agent/package.json | 8 + packages/agent/src/ccl-evaluation-publish.ts | 74 +++ packages/agent/src/ccl-evaluator.ts | 211 ++++++++ packages/agent/src/ccl-policy.ts | 136 +++++ packages/agent/src/dkg-agent.ts | 512 +++++++++++++++++- packages/agent/src/index.ts | 22 + packages/agent/test/agent.test.ts | 180 ++++++ packages/cli/package.json | 2 + packages/cli/src/api-client.ts | 87 +++ packages/cli/src/cli.ts | 231 +++++++- packages/cli/src/daemon.ts | 81 +++ packages/cli/test/api-client.test.ts | 57 ++ packages/core/src/genesis.ts | 27 + packages/core/test/genesis.test.ts | 16 + 39 files changed, 3359 insertions(+), 6 deletions(-) create mode 100644 ccl_v0_1/LANGUAGE_SPEC.md create mode 100644 ccl_v0_1/README.md create mode 100644 ccl_v0_1/SURFACE_SYNTAX.md create mode 100644 ccl_v0_1/evaluator/reference_evaluator.js create mode 100644 ccl_v0_1/evaluator/reference_evaluator.py create mode 100644 ccl_v0_1/examples/context_corroboration.ccl create mode 100644 ccl_v0_1/examples/owner_assertion.ccl create mode 100644 ccl_v0_1/grammar.ebnf create mode 100644 ccl_v0_1/package-lock.json create mode 100644 ccl_v0_1/package.json create mode 100644 ccl_v0_1/policies/context_corroboration.yaml create mode 100644 ccl_v0_1/policies/owner_assertion.yaml create mode 100644 ccl_v0_1/tests/TEST_RESULTS.md create mode 100644 ccl_v0_1/tests/cases/01_owner_valid.yaml create mode 100644 ccl_v0_1/tests/cases/02_owner_invalid.yaml create mode 100644 ccl_v0_1/tests/cases/03_context_minimal_corroboration.yaml create mode 100644 ccl_v0_1/tests/cases/04_context_missing_vendor.yaml create mode 100644 ccl_v0_1/tests/cases/05_context_workspace_excluded.yaml create mode 100644 ccl_v0_1/tests/cases/06_context_disputed.yaml create mode 100644 ccl_v0_1/tests/cases/07_context_epoch_mismatch.yaml create mode 100644 ccl_v0_1/tests/cases/08_context_quorum_accept.yaml create mode 100644 ccl_v0_1/tests/run_all_tests.js create mode 100644 ccl_v0_1/tests/run_all_tests.py create mode 100644 packages/adapter-openclaw/skills/ccl/SKILL.md create mode 100644 packages/agent/src/ccl-evaluation-publish.ts create mode 100644 packages/agent/src/ccl-evaluator.ts create mode 100644 packages/agent/src/ccl-policy.ts diff --git a/ccl_v0_1/LANGUAGE_SPEC.md b/ccl_v0_1/LANGUAGE_SPEC.md new file mode 100644 index 000000000..fd9e6bd29 --- /dev/null +++ b/ccl_v0_1/LANGUAGE_SPEC.md @@ -0,0 +1,282 @@ +# CCL v0.1 Language Spec + +## 1. Purpose + +CCL is a deterministic language for evaluating **corroboration and consensus-adjacent conditions** over a fixed DKG snapshot. + +It exists to solve a narrow problem: + +- agents may publish claims, evidence links, contradictions, ownership declarations, quorum facts, epochs, and authority classes +- nodes need a replayable way to adjudicate whether some condition is satisfied +- the result must be identical on all honest nodes given the same inputs + +CCL does **not** discover truth. +CCL does **not** mutate authoritative state directly. +CCL does **not** replace `PUBLISH`. + +--- + +## 2. Evaluation context + +Every CCL evaluation is scoped by a declared context: + +- `paranet` +- `scope_ual` +- `view` +- `snapshot_id` +- `policy_name` +- `policy_version` + +The evaluation boundary is closed. Only facts present in the declared snapshot/view are visible to the evaluator. + +--- + +## 3. Core data model + +Facts are normalized atoms: + +```text +predicate(arg1, arg2, ..., argN) +``` + +Examples: + +```text +claim(c1) +supports(e1, c1) +authority_class(e1, vendor) +evidence_view(e1, accepted) +owner_of(p1, 0xalice) +signed_by(p1, 0xalice) +quorum_reached(incident_review, 3, 4) +claim_epoch(c1, 7) +quorum_epoch(incident_review, 7) +contradicts(c2, c1) +accepted_status(c2, accepted) +``` + +In the canonical test package, facts are serialized as YAML tuples: + +```yaml +- [supports, e1, c1] +- [authority_class, e1, vendor] +``` + +--- + +## 4. Outputs + +CCL produces two output classes: + +### 4.1 Derived predicates +Facts computed by rule evaluation. + +Examples: + +```text +corroborated(c1) +disputed(c2) +promotable(c1) +owner_asserted(p1) +``` + +### 4.2 Decisions +Named outputs intended to become **inputs to later publish flows**. + +Examples: + +```text +propose_accept(c1) +propose_reject(c2) +``` + +A CCL decision is **not authoritative state by itself**. + +--- + +## 5. Determinism requirements + +CCL v0.1 must remain safe on a distributed trustless network. + +Therefore: + +- no external API calls +- no hidden model calls +- no floating-point arithmetic +- no access to local wall clock +- no randomization +- no recursion +- no dynamic code loading +- no dependence on local DB iteration order + +All facts, rule order, and output serialization must be canonicalized by the evaluator. + +--- + +## 6. Language restrictions + +CCL v0.1 is intentionally small. + +### Allowed constructs +- positive atoms +- existential checks +- negated existential checks +- distinct counts with integer thresholds +- conjunction (`all`) +- references to previously derived predicates + +### Disallowed constructs +- recursion +- unstratified negation +- unrestricted arithmetic +- user-defined functions +- fuzzy similarity +- regex / substring matching in the trustless core +- implicit type coercion + +--- + +## 7. Canonical policy model + +The reference evaluator uses a canonical YAML representation. + +### Rule shape + +```yaml +- name: corroborated + params: [C] + all: + - atom: {pred: claim, args: ["$C"]} + - count_distinct: + vars: [E] + where: + - atom: {pred: supports, args: ["$E", "$C"]} + - atom: {pred: evidence_view, args: ["$E", "accepted"]} + - atom: {pred: independent, args: ["$E"]} + op: ">=" + value: 2 +``` + +### Decision shape + +```yaml +- name: propose_accept + params: [C] + all: + - atom: {pred: promotable, args: ["$C"]} +``` + +--- + +## 8. Semantics + +### 8.1 Atom +An `atom` joins against either: +- base facts from the snapshot +- already derived predicates + +Variables begin with `$`. + +Example: + +```yaml +atom: {pred: supports, args: ["$E", "$C"]} +``` + +### 8.2 Exists +`exists` succeeds if the nested `where` block has at least one satisfying assignment. + +### 8.3 Not exists +`not_exists` succeeds if the nested `where` block has zero satisfying assignments. + +### 8.4 Count distinct +`count_distinct` evaluates a nested `where` block, projects the named variables, counts distinct tuples, and applies an integer comparison. + +Example: + +```yaml +count_distinct: + vars: [E] + where: ... + op: ">=" + value: 2 +``` + +### 8.5 Rule evaluation +Rules are evaluated until fixpoint over: +- base facts +- newly derived predicates + +Since recursion is forbidden in v0.1, fixpoint convergence is bounded and straightforward. + +### 8.6 Decision evaluation +Decisions are evaluated after rule derivation fixpoint. + +--- + +## 9. Alignment with DKG v9 axioms + +### A1. Paranet-scoped +CCL evaluation is explicitly paranet-scoped. + +### A2. Authority-aware +Authority is represented as facts and checked by policy rules. + +### A3. Typed transitions +CCL itself does not mutate state. Decisions can feed later typed transitions. + +### A4. Canonical publish +CCL output is advisory until introduced via normal `PUBLISH`. + +### A5. Workspace vs authoritative +View must be declared. Policies can intentionally count only `accepted` evidence and ignore `workspace`. + +### A6. Declared state views +The snapshot/view boundary is first-class. + +### A7. Explicit movement across layers +Promotion readiness can be derived, but actual promotion still requires explicit publish. + +### A8. Deterministic conflict resolution +Accepted contradictions, epochs, and quorum facts are all explicit inputs to deterministic rules. + +--- + +## 10. Recommended canonical serialization fields + +A future canonical policy envelope should include: + +- `policy_name` +- `policy_version` +- `paranet` +- `scope_ual` +- `view` +- `snapshot_id` +- `rule_hash` +- `fact_set_hash` +- `evaluator_version` + +--- + +## 11. Non-goals + +CCL v0.1 is not intended to: +- replace graph query languages +- replace application workflow engines +- represent arbitrary human legal logic +- do probabilistic reasoning +- do semantic entity resolution directly + +Those can exist upstream as proposal-generating layers. + +--- + +## 12. Suggested future extensions + +- stratified disjunction +- typed enums / schemas +- signed policy envelopes +- canonical CBOR policy encoding +- proof trace compression +- explicit cost limits per rule +- static policy validator / linter diff --git a/ccl_v0_1/README.md b/ccl_v0_1/README.md new file mode 100644 index 000000000..e9ddab8d6 --- /dev/null +++ b/ccl_v0_1/README.md @@ -0,0 +1,126 @@ +# CCL v0.1 — Corroboration & Consensus Language + +CCL is a **small deterministic adjudication language** for evaluating corroboration, contradiction, and promotion conditions over a fixed DKG snapshot. + +It is designed to align with the DKG v9 axioms: + +- paranet-scoped evaluation +- authority-aware facts +- typed transitions outside the language +- canonical publish as the only ingress into authoritative shared state +- deterministic conflict handling +- explicit view / snapshot boundaries + +## What CCL is for + +CCL is for evaluating questions like: + +- does claim `C` have enough independent support? +- is claim `C` disputed by an accepted contradiction? +- is claim `C` promotable under the current quorum / epoch? +- is an owner-scoped assertion signed by the correct authority? + +CCL is **not** a general reasoning engine and **not** an LLM-facing tool language. + +## Trustless-network constraints + +CCL v0.1 is intentionally restricted: + +- no recursion +- no external I/O +- no model calls +- no floating-point math +- no wall-clock access +- no hidden heuristics +- only explicit published facts in a declared snapshot/view +- decisions are **proposals**, not state changes + +## Package layout + +- `LANGUAGE_SPEC.md` — language design and semantics +- `SURFACE_SYNTAX.md` — human-friendly DSL shape +- `grammar.ebnf` — small EBNF for the surface syntax +- `policies/` — canonical YAML policies used by the reference evaluator +- `examples/` — surface-language examples +- `evaluator/reference_evaluator.py` — tiny deterministic evaluator for the canonical YAML format +- `evaluator/reference_evaluator.js` — JavaScript port of the reference evaluator +- `tests/cases/` — test cases with expected derived facts and decisions +- `tests/run_all_tests.py` — executes the bundled test cases +- `tests/run_all_tests.js` — executes the bundled test cases with Node.js + +## Canonical evaluation model + +The reference evaluator consumes a canonical YAML policy format. This is deliberate: + +- human authors may write surface CCL +- nodes should evaluate a normalized canonical form +- canonical form is easier to serialize, audit, hash, and replay + +## Running the tests + +```bash +python tests/run_all_tests.py +``` + +Or with Node.js from the `ccl_v0_1` directory: + +```bash +pnpm test +``` + +From the project root, or: + +```bash +python evaluator/reference_evaluator.py policies/context_corroboration.yaml tests/cases/03_context_minimal_corroboration.yaml +``` + +JavaScript evaluator: + +```bash +node evaluator/reference_evaluator.js policies/context_corroboration.yaml tests/cases/03_context_minimal_corroboration.yaml --check +``` + +## Output model + +CCL produces two kinds of outputs: + +1. **Derived predicates** + - e.g. `corroborated(c1)`, `promotable(c1)`, `owner_asserted(p1)` + +2. **Decisions** + - e.g. `propose_accept(c1)`, `propose_reject(c2)` + +A decision is still **non-authoritative** until a normal DKG `PUBLISH` introduces it as a typed transition into shared state. + +## Included policies + +### 1. `owner_assertion` +Simple owner-scope adjudication: +- a claim is owner-asserted if the signer matches the declared owner + +### 2. `context_corroboration` +Context-governed corroboration + promotion: +- at least two independent accepted supports +- at least one accepted vendor-class support +- no accepted contradiction +- matching claim epoch and quorum epoch +- quorum reached at 3-of-4 + +## Included test coverage + +- single-owner valid signature +- single-owner invalid signature +- minimal corroboration +- missing vendor support +- workspace-only evidence excluded from accepted view +- accepted contradiction blocks promotion +- epoch mismatch blocks promotion +- successful quorum-based promotion + +## Suggested next steps + +- add a canonical CBOR serialization +- add Merkleizable rule / policy hashing +- add rule-set version negotiation +- add policy signatures / authority binding +- add bounded provenance traces as first-class evaluation outputs diff --git a/ccl_v0_1/SURFACE_SYNTAX.md b/ccl_v0_1/SURFACE_SYNTAX.md new file mode 100644 index 000000000..d27ba6449 --- /dev/null +++ b/ccl_v0_1/SURFACE_SYNTAX.md @@ -0,0 +1,141 @@ +# CCL v0.1 Surface Syntax + +The package uses canonical YAML for machine evaluation. + +This document defines a **human-oriented surface syntax** that can compile into that canonical form. + +The surface syntax is intentionally small. + +--- + +## 1. Example + +```ccl +policy context_corroboration v0.1.0 + +rule corroborated(C): + claim(C) + count_distinct E where + supports(E, C) + evidence_view(E, accepted) + independent(E) + >= 2 + exists E where + supports(E, C) + evidence_view(E, accepted) + authority_class(E, vendor) + not exists C2 where + contradicts(C2, C) + accepted_status(C2, accepted) + +rule disputed(C): + claim(C) + exists C2 where + contradicts(C2, C) + accepted_status(C2, accepted) + +rule promotable(C): + corroborated(C) + claim_epoch(C, E) + quorum_epoch(incident_review, E) + quorum_reached(incident_review, 3, 4) + +decision propose_accept(C): + promotable(C) + +decision propose_reject(C): + disputed(C) +``` + +--- + +## 2. Design notes + +### Variables +- Uppercase identifiers are variables: `C`, `E` +- Lowercase identifiers are predicate names or constants: `claim`, `accepted`, `vendor` + +### Rule heads +A rule head defines a derived predicate: + +```ccl +rule corroborated(C): +``` + +### Decisions +A decision head defines a named output that may later feed a normal publish flow: + +```ccl +decision propose_accept(C): +``` + +### Condition blocks +The rule body is conjunction-only in v0.1. +Every listed condition must hold. + +### Exists +```ccl +exists E where + supports(E, C) + authority_class(E, vendor) +``` + +### Not exists +```ccl +not exists C2 where + contradicts(C2, C) + accepted_status(C2, accepted) +``` + +### Count distinct +```ccl +count_distinct E where + supports(E, C) + independent(E) +>= 2 +``` + +--- + +## 3. Compilation target + +A surface rule compiles into canonical YAML. + +Example: + +```ccl +rule owner_asserted(C): + claim(C) + exists A where + owner_of(C, A) + signed_by(C, A) +``` + +Compiles conceptually to: + +```yaml +- name: owner_asserted + params: [C] + all: + - atom: {pred: claim, args: ["$C"]} + - exists: + vars: [A] + where: + - atom: {pred: owner_of, args: ["$C", "$A"]} + - atom: {pred: signed_by, args: ["$C", "$A"]} +``` + +--- + +## 4. Why canonical YAML exists + +A trustless network needs: + +- stable hashing +- stable ordering +- simple validation +- easy replay +- low parser ambiguity + +So the surface syntax is ergonomics. +The canonical form is what nodes should actually hash, store, sign, and evaluate. diff --git a/ccl_v0_1/evaluator/reference_evaluator.js b/ccl_v0_1/evaluator/reference_evaluator.js new file mode 100644 index 000000000..911a578fe --- /dev/null +++ b/ccl_v0_1/evaluator/reference_evaluator.js @@ -0,0 +1,292 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); +const path = require('node:path'); +const yaml = require('js-yaml'); + +function isVar(value) { + return typeof value === 'string' && value.startsWith('$'); +} + +function normalizeValue(value) { + return value; +} + +function loadYaml(filePath) { + return yaml.load(fs.readFileSync(filePath, 'utf8')); +} + +function tupleKey(tuple) { + return JSON.stringify(tuple); +} + +function normalizeVarName(value) { + const str = String(value); + return str.startsWith('$') ? str : `$${str}`; +} + +class Evaluator { + constructor(policy, facts) { + this.policy = policy; + this.relations = new Map(); + + for (const factRow of facts) { + const fact = [...factRow]; + const pred = fact[0]; + const args = fact.slice(1).map(normalizeValue); + this._getRelation(pred).set(tupleKey(args), args); + } + } + + run() { + this._deriveFixpoint(); + const decisions = this._evaluateDecisions(); + const derived = {}; + + for (const rule of this.policy.rules ?? []) { + derived[rule.name] = this._sortedTuples(this._getRelation(rule.name)); + } + + return { derived, decisions }; + } + + _deriveFixpoint() { + const rules = this.policy.rules ?? []; + const maxRounds = 64; + + for (let round = 0; round < maxRounds; round += 1) { + let changed = false; + + for (const rule of rules) { + const newTuples = this._evaluateRule(rule); + const relation = this._getRelation(rule.name); + const before = relation.size; + + for (const tuple of newTuples) { + relation.set(tupleKey(tuple), tuple); + } + + if (relation.size !== before) { + changed = true; + } + } + + if (!changed) { + return; + } + } + + throw new Error('fixpoint did not converge'); + } + + _evaluateRule(rule) { + const params = (rule.params ?? []).map(normalizeVarName); + const tuples = new Map(); + + for (const binding of this._evaluateConditions(rule.all ?? [], [{}])) { + const head = params.map((param) => binding[param]); + tuples.set(tupleKey(head), head); + } + + return tuples.values(); + } + + _evaluateDecisions() { + const results = {}; + + for (const decision of this.policy.decisions ?? []) { + const params = (decision.params ?? []).map(normalizeVarName); + const tuples = new Map(); + + for (const binding of this._evaluateConditions(decision.all ?? [], [{}])) { + const head = params.map((param) => binding[param]); + tuples.set(tupleKey(head), head); + } + + results[decision.name] = [...tuples.values()].sort(compareTuples); + } + + return results; + } + + _evaluateConditions(conditions, bindings) { + let current = bindings; + + for (const cond of conditions) { + const nextBindings = []; + for (const binding of current) { + nextBindings.push(...this._evaluateCondition(cond, binding)); + } + current = nextBindings; + if (current.length === 0) { + break; + } + } + + return current; + } + + _evaluateCondition(cond, binding) { + if (cond.atom) { + return this._matchAtom(cond.atom.pred, cond.atom.args ?? [], binding); + } + + if (cond.exists) { + const matches = this._evaluateConditions(cond.exists.where ?? [], [{ ...binding }]); + return matches.length > 0 ? [binding] : []; + } + + if (cond.not_exists) { + const matches = this._evaluateConditions(cond.not_exists.where ?? [], [{ ...binding }]); + return matches.length === 0 ? [binding] : []; + } + + if (cond.count_distinct) { + const spec = cond.count_distinct; + const matches = this._evaluateConditions(spec.where ?? [], [{ ...binding }]); + const vars = (spec.vars ?? []).map(normalizeVarName); + const projection = new Set(matches.map((match) => tupleKey(vars.map((name) => match[name])))); + return compareInts(projection.size, spec.op, Number(spec.value)) ? [binding] : []; + } + + throw new Error(`Unsupported condition: ${JSON.stringify(cond)}`); + } + + _matchAtom(pred, args, binding) { + const out = []; + const tuples = this._sortedTuples(this._getRelation(pred)); + + for (const tuple of tuples) { + if (tuple.length !== args.length) { + continue; + } + + const candidate = { ...binding }; + let ok = true; + + for (let i = 0; i < args.length; i += 1) { + const term = args[i]; + const value = tuple[i]; + + if (isVar(term)) { + if (Object.hasOwn(candidate, term)) { + if (candidate[term] !== value) { + ok = false; + break; + } + } else { + candidate[term] = value; + } + } else if (normalizeValue(term) !== value) { + ok = false; + break; + } + } + + if (ok) { + out.push(candidate); + } + } + + return out; + } + + _getRelation(pred) { + if (!this.relations.has(pred)) { + this.relations.set(pred, new Map()); + } + return this.relations.get(pred); + } + + _sortedTuples(relation) { + return [...relation.values()].map((tuple) => [...tuple]).sort(compareTuples); + } +} + +function compareInts(left, op, right) { + switch (op) { + case '>=': + return left >= right; + case '>': + return left > right; + case '==': + return left === right; + case '<=': + return left <= right; + case '<': + return left < right; + default: + throw new Error(`Unsupported comparison operator: ${op}`); + } +} + +function compareTuples(left, right) { + return tupleKey(left).localeCompare(tupleKey(right)); +} + +function runCase(policyPath, casePath) { + const policy = loadYaml(policyPath); + const testCase = loadYaml(casePath); + const evaluator = new Evaluator(policy, testCase.facts); + return evaluator.run(); +} + +function compareExpected(result, expected) { + const normalizedResult = JSON.parse(JSON.stringify(result)); + const normalizedExpected = JSON.parse(JSON.stringify(expected)); + return { + ok: JSON.stringify(normalizedResult) === JSON.stringify(normalizedExpected), + detail: { + result: normalizedResult, + expected: normalizedExpected, + }, + }; +} + +function resolvePolicyPath(casePath, policyArg) { + return path.isAbsolute(policyArg) ? policyArg : path.resolve(path.dirname(casePath), '..', '..', 'policies', policyArg); +} + +function main(argv = process.argv.slice(2)) { + const args = [...argv]; + const checkIndex = args.indexOf('--check'); + const check = checkIndex !== -1; + + if (check) { + args.splice(checkIndex, 1); + } + + if (args.length !== 2) { + console.error('Usage: node evaluator/reference_evaluator.js [--check]'); + process.exitCode = 1; + return; + } + + const [policyArg, caseArg] = args; + const casePath = path.resolve(caseArg); + const policyPath = path.resolve(policyArg); + const result = runCase(policyPath, casePath); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + + if (check) { + const testCase = loadYaml(casePath); + const comparison = compareExpected(result, testCase.expected); + if (!comparison.ok) { + process.stdout.write('\nEXPECTED MISMATCH\n'); + process.stdout.write(`${JSON.stringify(comparison.detail, null, 2)}\n`); + process.exitCode = 1; + } + } +} + +module.exports = { + Evaluator, + compareExpected, + loadYaml, + resolvePolicyPath, + runCase, +}; + +if (require.main === module) { + main(); +} diff --git a/ccl_v0_1/evaluator/reference_evaluator.py b/ccl_v0_1/evaluator/reference_evaluator.py new file mode 100644 index 000000000..a83600ee9 --- /dev/null +++ b/ccl_v0_1/evaluator/reference_evaluator.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +from collections import defaultdict +from pathlib import Path +from typing import Any, Dict, Iterable, List, Tuple + +import yaml + + +Binding = Dict[str, Any] +RelationStore = Dict[str, set[Tuple[Any, ...]]] + + +def is_var(value: Any) -> bool: + return isinstance(value, str) and value.startswith("$") + + +def normalize_value(value: Any) -> Any: + return value + + +def load_yaml(path: Path) -> Any: + with path.open("r", encoding="utf-8") as f: + return yaml.safe_load(f) + + +class Evaluator: + def __init__(self, policy: dict, facts: Iterable[Iterable[Any]]) -> None: + self.policy = policy + self.relations: RelationStore = defaultdict(set) + for fact in facts: + fact = list(fact) + pred = fact[0] + args = tuple(normalize_value(x) for x in fact[1:]) + self.relations[pred].add(args) + + def run(self) -> dict[str, list[list[Any]]]: + self._derive_fixpoint() + decisions = self._evaluate_decisions() + derived = { + rule["name"]: sorted([list(t) for t in self.relations.get(rule["name"], set())]) + for rule in self.policy.get("rules", []) + } + return { + "derived": derived, + "decisions": decisions, + } + + def _derive_fixpoint(self) -> None: + rules = self.policy.get("rules", []) + max_rounds = 64 + for _ in range(max_rounds): + changed = False + for rule in rules: + new_tuples = self._evaluate_rule(rule) + rel = self.relations[rule["name"]] + before = len(rel) + rel.update(new_tuples) + if len(rel) != before: + changed = True + if not changed: + return + raise RuntimeError("fixpoint did not converge") + + def _evaluate_rule(self, rule: dict) -> set[Tuple[Any, ...]]: + params = [f"${p}" for p in rule.get("params", [])] + tuples = set() + for binding in self._evaluate_conditions(rule.get("all", []), [{}]): + head = tuple(binding[p] for p in params) + tuples.add(head) + return tuples + + def _evaluate_decisions(self) -> dict[str, list[list[Any]]]: + results: dict[str, list[list[Any]]] = {} + for decision in self.policy.get("decisions", []): + params = [f"${p}" for p in decision.get("params", [])] + tuples = [] + seen = set() + for binding in self._evaluate_conditions(decision.get("all", []), [{}]): + head = tuple(binding[p] for p in params) + if head not in seen: + seen.add(head) + tuples.append(list(head)) + results[decision["name"]] = sorted(tuples) + return results + + def _evaluate_conditions(self, conditions: List[dict], bindings: List[Binding]) -> List[Binding]: + current = bindings + for cond in conditions: + next_bindings: List[Binding] = [] + for binding in current: + next_bindings.extend(self._evaluate_condition(cond, binding)) + current = next_bindings + if not current: + break + return current + + def _evaluate_condition(self, cond: dict, binding: Binding) -> List[Binding]: + if "atom" in cond: + atom = cond["atom"] + return self._match_atom(atom["pred"], atom.get("args", []), binding) + if "exists" in cond: + spec = cond["exists"] + matches = self._evaluate_conditions(spec.get("where", []), [dict(binding)]) + return [binding] if matches else [] + if "not_exists" in cond: + spec = cond["not_exists"] + matches = self._evaluate_conditions(spec.get("where", []), [dict(binding)]) + return [binding] if not matches else [] + if "count_distinct" in cond: + spec = cond["count_distinct"] + matches = self._evaluate_conditions(spec.get("where", []), [dict(binding)]) + vars_ = [f"${v}" if not str(v).startswith("$") else str(v) for v in spec.get("vars", [])] + projection = {tuple(m[v] for v in vars_) for m in matches} + if self._compare(len(projection), spec["op"], int(spec["value"])): + return [binding] + return [] + raise ValueError(f"Unsupported condition: {cond}") + + def _compare(self, left: int, op: str, right: int) -> bool: + if op == ">=": + return left >= right + if op == ">": + return left > right + if op == "==": + return left == right + if op == "<=": + return left <= right + if op == "<": + return left < right + raise ValueError(f"Unsupported comparison operator: {op}") + + def _match_atom(self, pred: str, args: List[Any], binding: Binding) -> List[Binding]: + out: List[Binding] = [] + tuples = sorted(self.relations.get(pred, set())) + for tup in tuples: + if len(tup) != len(args): + continue + candidate = dict(binding) + ok = True + for term, value in zip(args, tup): + if is_var(term): + if term in candidate: + if candidate[term] != value: + ok = False + break + else: + candidate[term] = value + else: + if normalize_value(term) != value: + ok = False + break + if ok: + out.append(candidate) + return out + + +def run_case(policy_path: Path, case_path: Path) -> dict: + policy = load_yaml(policy_path) + case = load_yaml(case_path) + evaluator = Evaluator(policy, case["facts"]) + result = evaluator.run() + return result + + +def compare_expected(result: dict, expected: dict) -> tuple[bool, dict]: + normalized_result = json.loads(json.dumps(result, sort_keys=True)) + normalized_expected = json.loads(json.dumps(expected, sort_keys=True)) + return normalized_result == normalized_expected, { + "result": normalized_result, + "expected": normalized_expected, + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run the CCL reference evaluator.") + parser.add_argument("policy", type=Path, help="Path to canonical policy YAML.") + parser.add_argument("case", type=Path, help="Path to test case YAML.") + parser.add_argument("--check", action="store_true", help="Compare output to case.expected and set exit code.") + args = parser.parse_args() + + result = run_case(args.policy, args.case) + print(json.dumps(result, indent=2, sort_keys=True)) + + if args.check: + case = load_yaml(args.case) + ok, detail = compare_expected(result, case["expected"]) + if not ok: + print("\\nEXPECTED MISMATCH") + print(json.dumps(detail, indent=2, sort_keys=True)) + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/ccl_v0_1/examples/context_corroboration.ccl b/ccl_v0_1/examples/context_corroboration.ccl new file mode 100644 index 000000000..6d6f7164d --- /dev/null +++ b/ccl_v0_1/examples/context_corroboration.ccl @@ -0,0 +1,34 @@ +policy context_corroboration v0.1.0 + +rule corroborated(C): + claim(C) + count_distinct E where + supports(E, C) + evidence_view(E, accepted) + independent(E) + >= 2 + exists E where + supports(E, C) + evidence_view(E, accepted) + authority_class(E, vendor) + not exists C2 where + contradicts(C2, C) + accepted_status(C2, accepted) + +rule disputed(C): + claim(C) + exists C2 where + contradicts(C2, C) + accepted_status(C2, accepted) + +rule promotable(C): + corroborated(C) + claim_epoch(C, E) + quorum_epoch(incident_review, E) + quorum_reached(incident_review, 3, 4) + +decision propose_accept(C): + promotable(C) + +decision propose_reject(C): + disputed(C) diff --git a/ccl_v0_1/examples/owner_assertion.ccl b/ccl_v0_1/examples/owner_assertion.ccl new file mode 100644 index 000000000..d04fbbe07 --- /dev/null +++ b/ccl_v0_1/examples/owner_assertion.ccl @@ -0,0 +1,10 @@ +policy owner_assertion v0.1.0 + +rule owner_asserted(C): + claim(C) + exists A where + owner_of(C, A) + signed_by(C, A) + +decision propose_accept(C): + owner_asserted(C) diff --git a/ccl_v0_1/grammar.ebnf b/ccl_v0_1/grammar.ebnf new file mode 100644 index 000000000..c1caadffc --- /dev/null +++ b/ccl_v0_1/grammar.ebnf @@ -0,0 +1,43 @@ +(* CCL v0.1 surface grammar *) + +policy = "policy", ws, ident, ws, version, nl, { rule | decision } ; + +rule = "rule", ws, head, ":", nl, body ; +decision = "decision", ws, head, ":", nl, body ; + +head = ident, "(", [ ident, { ",", ws, ident } ], ")" ; +body = { indented_condition } ; + +indented_condition + = indent, condition, nl ; + +condition = atom + | exists_block + | not_exists_block + | count_distinct_block + ; + +atom = ident, "(", [ arg, { ",", ws, arg } ], ")" ; +arg = ident | string | integer ; + +exists_block = "exists", ws, ident_list, ws, "where", nl, subbody ; +not_exists_block + = "not", ws, "exists", ws, ident_list, ws, "where", nl, subbody ; + +count_distinct_block + = "count_distinct", ws, ident_list, ws, "where", nl, subbody, + indent, compare_op, ws, integer ; + +subbody = { indent, condition, nl } ; + +ident_list = ident, { ",", ws, ident } ; +compare_op = ">=" | ">" | "==" | "<=" | "<" ; + +ident = letter, { letter | digit | "_" | "-" } ; +version = "v", digit, { digit | "." } ; +integer = digit, { digit } ; +string = '"', { ? any char except '"' ? }, '"' ; + +ws = { " " | "\t" } ; +nl = "\n" ; +indent = " ", { " " } ; diff --git a/ccl_v0_1/package-lock.json b/ccl_v0_1/package-lock.json new file mode 100644 index 000000000..cf1fbfe77 --- /dev/null +++ b/ccl_v0_1/package-lock.json @@ -0,0 +1,31 @@ +{ + "name": "ccl-v0_1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ccl-v0_1", + "dependencies": { + "js-yaml": "^4.1.1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + } + } +} diff --git a/ccl_v0_1/package.json b/ccl_v0_1/package.json new file mode 100644 index 000000000..87133859b --- /dev/null +++ b/ccl_v0_1/package.json @@ -0,0 +1,11 @@ +{ + "name": "ccl-v0_1", + "private": true, + "type": "commonjs", + "scripts": { + "test": "node tests/run_all_tests.js" + }, + "dependencies": { + "js-yaml": "^4.1.1" + } +} diff --git a/ccl_v0_1/policies/context_corroboration.yaml b/ccl_v0_1/policies/context_corroboration.yaml new file mode 100644 index 000000000..7c5476a08 --- /dev/null +++ b/ccl_v0_1/policies/context_corroboration.yaml @@ -0,0 +1,56 @@ +policy: context_corroboration +version: 0.1.0 +kind: canonical_policy +description: Deterministic corroboration and promotion for context-governed claims. +rules: + - name: corroborated + params: [C] + all: + - atom: {pred: claim, args: ["$C"]} + - count_distinct: + vars: [E] + where: + - atom: {pred: supports, args: ["$E", "$C"]} + - atom: {pred: evidence_view, args: ["$E", "accepted"]} + - atom: {pred: independent, args: ["$E"]} + op: ">=" + value: 2 + - exists: + vars: [E] + where: + - atom: {pred: supports, args: ["$E", "$C"]} + - atom: {pred: evidence_view, args: ["$E", "accepted"]} + - atom: {pred: authority_class, args: ["$E", "vendor"]} + - not_exists: + vars: [C2] + where: + - atom: {pred: contradicts, args: ["$C2", "$C"]} + - atom: {pred: accepted_status, args: ["$C2", "accepted"]} + - name: disputed + params: [C] + all: + - atom: {pred: claim, args: ["$C"]} + - exists: + vars: [C2] + where: + - atom: {pred: contradicts, args: ["$C2", "$C"]} + - atom: {pred: accepted_status, args: ["$C2", "accepted"]} + - name: promotable + params: [C] + all: + - atom: {pred: corroborated, args: ["$C"]} + - exists: + vars: [E] + where: + - atom: {pred: claim_epoch, args: ["$C", "$E"]} + - atom: {pred: quorum_epoch, args: ["incident_review", "$E"]} + - atom: {pred: quorum_reached, args: ["incident_review", 3, 4]} +decisions: + - name: propose_accept + params: [C] + all: + - atom: {pred: promotable, args: ["$C"]} + - name: propose_reject + params: [C] + all: + - atom: {pred: disputed, args: ["$C"]} diff --git a/ccl_v0_1/policies/owner_assertion.yaml b/ccl_v0_1/policies/owner_assertion.yaml new file mode 100644 index 000000000..6c3fbdc07 --- /dev/null +++ b/ccl_v0_1/policies/owner_assertion.yaml @@ -0,0 +1,19 @@ +policy: owner_assertion +version: 0.1.0 +kind: canonical_policy +description: Deterministic owner-scope assertion adjudication. +rules: + - name: owner_asserted + params: [C] + all: + - atom: {pred: claim, args: ["$C"]} + - exists: + vars: [A] + where: + - atom: {pred: owner_of, args: ["$C", "$A"]} + - atom: {pred: signed_by, args: ["$C", "$A"]} +decisions: + - name: propose_accept + params: [C] + all: + - atom: {pred: owner_asserted, args: ["$C"]} diff --git a/ccl_v0_1/tests/TEST_RESULTS.md b/ccl_v0_1/tests/TEST_RESULTS.md new file mode 100644 index 000000000..0e4ed375e --- /dev/null +++ b/ccl_v0_1/tests/TEST_RESULTS.md @@ -0,0 +1,12 @@ +# Test Results + +- 01_owner_valid.yaml: **PASS** +- 02_owner_invalid.yaml: **PASS** +- 03_context_minimal_corroboration.yaml: **PASS** +- 04_context_missing_vendor.yaml: **PASS** +- 05_context_workspace_excluded.yaml: **PASS** +- 06_context_disputed.yaml: **PASS** +- 07_context_epoch_mismatch.yaml: **PASS** +- 08_context_quorum_accept.yaml: **PASS** + +Passed: 8, Failed: 0 diff --git a/ccl_v0_1/tests/cases/01_owner_valid.yaml b/ccl_v0_1/tests/cases/01_owner_valid.yaml new file mode 100644 index 000000000..10ba87145 --- /dev/null +++ b/ccl_v0_1/tests/cases/01_owner_valid.yaml @@ -0,0 +1,18 @@ +name: owner_valid_signature +policy: owner_assertion.yaml +context: + paranet: example-paranet + scope_ual: ual:dkg:example-paranet:auth:pk:0xalice:profile + view: accepted + snapshot_id: snap-owner-01 +facts: + - [claim, p1] + - [owner_of, p1, 0xalice] + - [signed_by, p1, 0xalice] +expected: + derived: + owner_asserted: + - [p1] + decisions: + propose_accept: + - [p1] diff --git a/ccl_v0_1/tests/cases/02_owner_invalid.yaml b/ccl_v0_1/tests/cases/02_owner_invalid.yaml new file mode 100644 index 000000000..8c404b692 --- /dev/null +++ b/ccl_v0_1/tests/cases/02_owner_invalid.yaml @@ -0,0 +1,16 @@ +name: owner_invalid_signature +policy: owner_assertion.yaml +context: + paranet: example-paranet + scope_ual: ual:dkg:example-paranet:auth:pk:0xalice:profile + view: accepted + snapshot_id: snap-owner-02 +facts: + - [claim, p1] + - [owner_of, p1, 0xalice] + - [signed_by, p1, 0xbob] +expected: + derived: + owner_asserted: [] + decisions: + propose_accept: [] diff --git a/ccl_v0_1/tests/cases/03_context_minimal_corroboration.yaml b/ccl_v0_1/tests/cases/03_context_minimal_corroboration.yaml new file mode 100644 index 000000000..7d51d5cc2 --- /dev/null +++ b/ccl_v0_1/tests/cases/03_context_minimal_corroboration.yaml @@ -0,0 +1,27 @@ +name: context_minimal_corroboration_without_quorum +policy: context_corroboration.yaml +context: + paranet: ops-paranet + scope_ual: ual:dkg:ops-paranet:auth:context:incident-123:claim-c1 + view: accepted + snapshot_id: snap-context-03 +facts: + - [claim, c1] + - [supports, e1, c1] + - [supports, e2, c1] + - [evidence_view, e1, accepted] + - [evidence_view, e2, accepted] + - [independent, e1] + - [independent, e2] + - [authority_class, e1, vendor] + - [authority_class, e2, operator] + - [claim_epoch, c1, 7] +expected: + derived: + corroborated: + - [c1] + disputed: [] + promotable: [] + decisions: + propose_accept: [] + propose_reject: [] diff --git a/ccl_v0_1/tests/cases/04_context_missing_vendor.yaml b/ccl_v0_1/tests/cases/04_context_missing_vendor.yaml new file mode 100644 index 000000000..71cf72e9a --- /dev/null +++ b/ccl_v0_1/tests/cases/04_context_missing_vendor.yaml @@ -0,0 +1,26 @@ +name: context_missing_vendor_support +policy: context_corroboration.yaml +context: + paranet: ops-paranet + scope_ual: ual:dkg:ops-paranet:auth:context:incident-124:claim-c1 + view: accepted + snapshot_id: snap-context-04 +facts: + - [claim, c1] + - [supports, e1, c1] + - [supports, e2, c1] + - [evidence_view, e1, accepted] + - [evidence_view, e2, accepted] + - [independent, e1] + - [independent, e2] + - [authority_class, e1, operator] + - [authority_class, e2, customer] + - [claim_epoch, c1, 7] +expected: + derived: + corroborated: [] + disputed: [] + promotable: [] + decisions: + propose_accept: [] + propose_reject: [] diff --git a/ccl_v0_1/tests/cases/05_context_workspace_excluded.yaml b/ccl_v0_1/tests/cases/05_context_workspace_excluded.yaml new file mode 100644 index 000000000..e7f559056 --- /dev/null +++ b/ccl_v0_1/tests/cases/05_context_workspace_excluded.yaml @@ -0,0 +1,26 @@ +name: context_workspace_evidence_excluded_from_accepted_view +policy: context_corroboration.yaml +context: + paranet: ops-paranet + scope_ual: ual:dkg:ops-paranet:auth:context:incident-125:claim-c1 + view: accepted + snapshot_id: snap-context-05 +facts: + - [claim, c1] + - [supports, e1, c1] + - [supports, e2, c1] + - [evidence_view, e1, accepted] + - [evidence_view, e2, workspace] + - [independent, e1] + - [independent, e2] + - [authority_class, e1, vendor] + - [authority_class, e2, operator] + - [claim_epoch, c1, 7] +expected: + derived: + corroborated: [] + disputed: [] + promotable: [] + decisions: + propose_accept: [] + propose_reject: [] diff --git a/ccl_v0_1/tests/cases/06_context_disputed.yaml b/ccl_v0_1/tests/cases/06_context_disputed.yaml new file mode 100644 index 000000000..5f94cfb8e --- /dev/null +++ b/ccl_v0_1/tests/cases/06_context_disputed.yaml @@ -0,0 +1,33 @@ +name: context_accepted_contradiction_blocks_promotion +policy: context_corroboration.yaml +context: + paranet: ops-paranet + scope_ual: ual:dkg:ops-paranet:auth:context:incident-126:claim-c1 + view: accepted + snapshot_id: snap-context-06 +facts: + - [claim, c1] + - [claim, c2] + - [supports, e1, c1] + - [supports, e2, c1] + - [evidence_view, e1, accepted] + - [evidence_view, e2, accepted] + - [independent, e1] + - [independent, e2] + - [authority_class, e1, vendor] + - [authority_class, e2, operator] + - [claim_epoch, c1, 7] + - [quorum_epoch, incident_review, 7] + - [quorum_reached, incident_review, 3, 4] + - [contradicts, c2, c1] + - [accepted_status, c2, accepted] +expected: + derived: + corroborated: [] + disputed: + - [c1] + promotable: [] + decisions: + propose_accept: [] + propose_reject: + - [c1] diff --git a/ccl_v0_1/tests/cases/07_context_epoch_mismatch.yaml b/ccl_v0_1/tests/cases/07_context_epoch_mismatch.yaml new file mode 100644 index 000000000..ad2fc0a27 --- /dev/null +++ b/ccl_v0_1/tests/cases/07_context_epoch_mismatch.yaml @@ -0,0 +1,29 @@ +name: context_epoch_mismatch_blocks_promotion +policy: context_corroboration.yaml +context: + paranet: ops-paranet + scope_ual: ual:dkg:ops-paranet:auth:context:incident-127:claim-c1 + view: accepted + snapshot_id: snap-context-07 +facts: + - [claim, c1] + - [supports, e1, c1] + - [supports, e2, c1] + - [evidence_view, e1, accepted] + - [evidence_view, e2, accepted] + - [independent, e1] + - [independent, e2] + - [authority_class, e1, vendor] + - [authority_class, e2, operator] + - [claim_epoch, c1, 8] + - [quorum_epoch, incident_review, 7] + - [quorum_reached, incident_review, 3, 4] +expected: + derived: + corroborated: + - [c1] + disputed: [] + promotable: [] + decisions: + propose_accept: [] + propose_reject: [] diff --git a/ccl_v0_1/tests/cases/08_context_quorum_accept.yaml b/ccl_v0_1/tests/cases/08_context_quorum_accept.yaml new file mode 100644 index 000000000..858304623 --- /dev/null +++ b/ccl_v0_1/tests/cases/08_context_quorum_accept.yaml @@ -0,0 +1,31 @@ +name: context_quorum_accept +policy: context_corroboration.yaml +context: + paranet: ops-paranet + scope_ual: ual:dkg:ops-paranet:auth:context:incident-128:claim-c1 + view: accepted + snapshot_id: snap-context-08 +facts: + - [claim, c1] + - [supports, e1, c1] + - [supports, e2, c1] + - [evidence_view, e1, accepted] + - [evidence_view, e2, accepted] + - [independent, e1] + - [independent, e2] + - [authority_class, e1, vendor] + - [authority_class, e2, operator] + - [claim_epoch, c1, 7] + - [quorum_epoch, incident_review, 7] + - [quorum_reached, incident_review, 3, 4] +expected: + derived: + corroborated: + - [c1] + disputed: [] + promotable: + - [c1] + decisions: + propose_accept: + - [c1] + propose_reject: [] diff --git a/ccl_v0_1/tests/run_all_tests.js b/ccl_v0_1/tests/run_all_tests.js new file mode 100644 index 000000000..24f034861 --- /dev/null +++ b/ccl_v0_1/tests/run_all_tests.js @@ -0,0 +1,37 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); +const path = require('node:path'); +const { compareExpected, loadYaml, resolvePolicyPath, runCase } = require('../evaluator/reference_evaluator.js'); + +const casesDir = path.resolve(__dirname, 'cases'); + +function main() { + const caseFiles = fs.readdirSync(casesDir) + .filter((file) => file.endsWith('.yaml')) + .sort(); + + let passed = 0; + + for (const file of caseFiles) { + const casePath = path.join(casesDir, file); + const testCase = loadYaml(casePath); + const policyPath = resolvePolicyPath(casePath, testCase.policy); + const result = runCase(policyPath, casePath); + const comparison = compareExpected(result, testCase.expected); + + if (!comparison.ok) { + console.error(`FAIL ${testCase.name}`); + console.error(JSON.stringify(comparison.detail, null, 2)); + process.exitCode = 1; + return; + } + + passed += 1; + console.log(`PASS ${testCase.name}`); + } + + console.log(`\n${passed}/${caseFiles.length} cases passed`); +} + +main(); diff --git a/ccl_v0_1/tests/run_all_tests.py b/ccl_v0_1/tests/run_all_tests.py new file mode 100644 index 000000000..7fa71d065 --- /dev/null +++ b/ccl_v0_1/tests/run_all_tests.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "evaluator")) + +from reference_evaluator import compare_expected, load_yaml, run_case # type: ignore + + +def main() -> None: + cases_dir = ROOT / "tests" / "cases" + passed = 0 + failed = 0 + lines = ["# Test Results", ""] + for case_path in sorted(cases_dir.glob("*.yaml")): + case = load_yaml(case_path) + policy_path = ROOT / "policies" / case["policy"] + result = run_case(policy_path, case_path) + ok, _detail = compare_expected(result, case["expected"]) + status = "PASS" if ok else "FAIL" + lines.append(f"- {case_path.name}: **{status}**") + if ok: + passed += 1 + else: + failed += 1 + print(f"{case_path.name}: FAIL") + print("Result:", result) + print("Expected:", case["expected"]) + summary = f"Passed: {passed}, Failed: {failed}" + lines.extend(["", summary, ""]) + print(summary) + (ROOT / "tests" / "TEST_RESULTS.md").write_text("\n".join(lines), encoding="utf-8") + if failed: + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/packages/adapter-openclaw/README.md b/packages/adapter-openclaw/README.md index 66237afec..037c835b7 100644 --- a/packages/adapter-openclaw/README.md +++ b/packages/adapter-openclaw/README.md @@ -311,12 +311,19 @@ Create or update `WORKSPACE_DIR/config.json` with a `"dkg-node"` block: ### 6. Copy the skill files into the workspace +The package ships three skill files that teach the agent how to use DKG tools, CCL, and game tools: + ```bash -mkdir -p WORKSPACE_DIR/skills/dkg-node WORKSPACE_DIR/skills/origin-trail-game +mkdir -p WORKSPACE_DIR/skills/dkg-node WORKSPACE_DIR/skills/ccl WORKSPACE_DIR/skills/origin-trail-game cp ~/dkg-v9/packages/adapter-openclaw/skills/dkg-node/SKILL.md WORKSPACE_DIR/skills/dkg-node/SKILL.md +cp ~/dkg-v9/packages/adapter-openclaw/skills/ccl/SKILL.md WORKSPACE_DIR/skills/ccl/SKILL.md cp ~/dkg-v9/packages/adapter-openclaw/skills/origin-trail-game/SKILL.md WORKSPACE_DIR/skills/origin-trail-game/SKILL.md ``` +- `dkg-node/SKILL.md` — teaches memory, publishing, querying, and agent discovery tools +- `ccl/SKILL.md` — teaches deterministic adjudication over DKG facts with the CCL evaluator +- `origin-trail-game/SKILL.md` — teaches game mechanics, actions, strategy, and autopilot usage + ### 7. Restart the OpenClaw gateway Restart the OpenClaw gateway so it reloads the plugin and workspace config. diff --git a/packages/adapter-openclaw/skills/ccl/SKILL.md b/packages/adapter-openclaw/skills/ccl/SKILL.md new file mode 100644 index 000000000..ba144ec61 --- /dev/null +++ b/packages/adapter-openclaw/skills/ccl/SKILL.md @@ -0,0 +1,173 @@ +--- +name: ccl +description: Use the Corroboration & Consensus Language to evaluate deterministic agreement policies over DKG facts and snapshots. +--- + +# CCL Skill + +Use **CCL (Corroboration & Consensus Language)** when agents need a deterministic, replayable way to decide whether published facts satisfy an agreement policy. + +CCL is not for ordinary chat. It is for questions like: + +1. does a claim have enough independent support? +2. is a claim blocked by an accepted contradiction? +3. has a quorum been reached for promotion? +4. is an owner-scoped assertion signed by the correct authority? + +## When To Use CCL + +- Use CCL after facts are available in the DKG or in a prepared case file. +- Use it when multiple agents or nodes must reach the same result from the same inputs. +- Use it for narrow policy checks, not open-ended reasoning. + +## When Not To Use CCL + +- Do not use CCL for free-form conversation or negotiation. +- Do not use CCL as a replacement for SPARQL queries. +- Do not use CCL for fuzzy judgments, semantic similarity, or LLM-driven reasoning. + +## Mental Model + +- `dkg_send_message` / `dkg_invoke_skill` = agent communication +- `dkg_publish` = shared facts enter the DKG +- `dkg_query` = inspect the published facts +- CCL = evaluate whether those facts satisfy a deterministic policy +- later `PUBLISH` = make the resulting proposal authoritative + +## Recommended Workflow + +1. Gather or publish the relevant facts. +2. Make sure the evaluation scope is clear: paranet, view, snapshot, and policy version. +3. Query the DKG to verify the input facts. +4. Run the CCL evaluator on the policy and fact set. +5. Treat the result as advisory until it is introduced through the normal DKG publish flow. + +## Evaluator Commands + +Run a single case: + +```bash +node ccl_v0_1/evaluator/reference_evaluator.js \ + ccl_v0_1/policies/context_corroboration.yaml \ + ccl_v0_1/tests/cases/08_context_quorum_accept.yaml \ + --check +``` + +Run all bundled cases: + +```bash +cd ccl_v0_1 && npm test +``` + +## Usage Examples + +### Example 1: Propose And Approve A Policy For A Paranet + +Publish a policy proposal: + +```bash +dkg ccl policy publish ops-paranet \ + --name incident-review \ + --version 0.1.0 \ + --file ccl_v0_1/policies/context_corroboration.yaml +``` + +Approve it as the paranet owner: + +```bash +dkg ccl policy approve ops-paranet did:dkg:policy:... +``` + +Resolve the active approved policy: + +```bash +dkg ccl policy resolve ops-paranet --name incident-review --include-body +``` + +### Example 2: Evaluate A Case Against The Approved Policy + +Run evaluation without publishing the result: + +```bash +dkg ccl eval ops-paranet \ + --name incident-review \ + --case ccl_v0_1/tests/cases/08_context_quorum_accept.yaml +``` + +Use a stricter per-context override if one exists: + +```bash +dkg ccl eval ops-paranet \ + --name incident-review \ + --context-type incident_review \ + --case ccl_v0_1/tests/cases/08_context_quorum_accept.yaml +``` + +### Example 3: Publish The Evaluation Result Back Into The Paranet + +When the evaluation should become a published adjudication record: + +```bash +dkg ccl eval ops-paranet \ + --name incident-review \ + --context-type incident_review \ + --case ccl_v0_1/tests/cases/08_context_quorum_accept.yaml \ + --publish-result +``` + +This publishes: + +- a `CCLEvaluation` node with the policy, fact-set hash, snapshot metadata, and scope +- linked `CCLResultEntry` nodes for derived predicates and decisions +- linked `CCLResultArg` nodes so each tuple element is queryable in RDF + +### Example 4: Query Published Results + +List all published CCL evaluation records for a paranet: + +```bash +dkg ccl results ops-paranet +``` + +Filter to only acceptance decisions for a given snapshot: + +```bash +dkg ccl results ops-paranet \ + --snapshot-id snap-42 \ + --result-kind decision \ + --result-name propose_accept +``` + +### Example 5: Agent Workflow Pattern + +Use this sequence when multiple agents need to agree on a claim: + +1. agents exchange messages or skill calls to request evidence +2. agents publish claim, support, contradiction, ownership, or quorum facts into the DKG +3. the paranet resolves the active approved CCL policy +4. the evaluator runs on a fixed snapshot and returns deterministic outputs +5. the result is optionally published and then used by the normal DKG workflow + +In short: + +- messages coordinate +- DKG stores facts +- CCL evaluates policy +- publish/finalization makes the outcome authoritative + +## Output Model + +CCL returns: + +- `derived` predicates such as `corroborated(c1)` or `promotable(c1)` +- `decisions` such as `propose_accept(c1)` or `propose_reject(c1)` + +These outputs do not change authoritative DKG state by themselves. + +## Guidance + +- Keep policies small and deterministic. +- Version policies explicitly. +- Evaluate only against a declared snapshot or case input. +- Prefer publishing the supporting facts first, then evaluating. +- If agents disagree, check the facts, the snapshot boundary, and the policy version before anything else. diff --git a/packages/agent/package.json b/packages/agent/package.json index 65cfbb0db..bf8fc73a6 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -21,10 +21,18 @@ "@origintrail-official/dkg-publisher": "workspace:*", "@origintrail-official/dkg-query": "workspace:*", "@origintrail-official/dkg-storage": "workspace:*", + "@libp2p/peer-id": "^6.0.4", + "@multiformats/multiaddr": "*", + "@noble/ciphers": "^2.1.1", + "@noble/curves": "^2", + "@noble/ed25519": "^3", + "@noble/hashes": "^2", "ethers": "^6", + "js-yaml": "^4.1.1", "jsonld": "^8.3.3" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@vitest/coverage-v8": "^4.0.18", "vitest": "^4.0.18" }, diff --git a/packages/agent/src/ccl-evaluation-publish.ts b/packages/agent/src/ccl-evaluation-publish.ts new file mode 100644 index 000000000..016dd30f7 --- /dev/null +++ b/packages/agent/src/ccl-evaluation-publish.ts @@ -0,0 +1,74 @@ +import { DKG_ONTOLOGY, sparqlString } from '@origintrail-official/dkg-core'; +import type { Quad } from '@origintrail-official/dkg-storage'; +import type { CclEvaluationResult } from './ccl-evaluator.js'; + +export interface PublishCclEvaluationInput { + paranetId: string; + policyUri: string; + factSetHash: string; + result: CclEvaluationResult; + evaluatedAt: string; + view?: string; + snapshotId?: string; + scopeUal?: string; + contextType?: string; +} + +export function buildCclEvaluationQuads(input: PublishCclEvaluationInput, graph: string): { + evaluationUri: string; + quads: Quad[]; +} { + const suffix = `${Date.now()}-${input.factSetHash.slice(-12)}`; + const evaluationUri = `did:dkg:ccl-eval:${encodeSegment(input.paranetId)}:${suffix}`; + const graphUri = String(graph); + const quads: Quad[] = [ + { subject: evaluationUri, predicate: DKG_ONTOLOGY.RDF_TYPE, object: String(DKG_ONTOLOGY.DKG_CCL_EVALUATION), graph: graphUri }, + { subject: evaluationUri, predicate: DKG_ONTOLOGY.DKG_EVALUATED_POLICY, object: String(input.policyUri), graph: graphUri }, + { subject: evaluationUri, predicate: DKG_ONTOLOGY.DKG_FACT_SET_HASH, object: sparqlString(input.factSetHash), graph: graphUri }, + { subject: evaluationUri, predicate: DKG_ONTOLOGY.DKG_CREATED_AT, object: sparqlString(input.evaluatedAt), graph: graphUri }, + ]; + + if (input.view) quads.push({ subject: evaluationUri, predicate: DKG_ONTOLOGY.DKG_VIEW, object: sparqlString(input.view), graph: graphUri }); + if (input.snapshotId) quads.push({ subject: evaluationUri, predicate: DKG_ONTOLOGY.DKG_SNAPSHOT_ID, object: sparqlString(input.snapshotId), graph: graphUri }); + if (input.scopeUal) quads.push({ subject: evaluationUri, predicate: DKG_ONTOLOGY.DKG_SCOPE_UAL, object: sparqlString(input.scopeUal), graph: graphUri }); + if (input.contextType) quads.push({ subject: evaluationUri, predicate: DKG_ONTOLOGY.DKG_POLICY_CONTEXT_TYPE, object: sparqlString(input.contextType), graph: graphUri }); + + appendEntries(quads, evaluationUri, 'derived', input.result.derived, graphUri); + appendEntries(quads, evaluationUri, 'decision', input.result.decisions, graphUri); + + return { evaluationUri, quads }; +} + +function appendEntries( + quads: Quad[], + evaluationUri: string, + kind: 'derived' | 'decision', + entries: Record, + graph: string, +): void { + for (const [name, tuples] of Object.entries(entries)) { + tuples.forEach((tuple, index) => { + const entryUri = `${evaluationUri}/result/${encodeSegment(kind)}/${encodeSegment(name)}/${index}`; + quads.push( + { subject: entryUri, predicate: DKG_ONTOLOGY.RDF_TYPE, object: DKG_ONTOLOGY.DKG_CCL_RESULT_ENTRY, graph }, + { subject: evaluationUri, predicate: DKG_ONTOLOGY.DKG_HAS_RESULT, object: entryUri, graph }, + { subject: entryUri, predicate: DKG_ONTOLOGY.DKG_RESULT_KIND, object: sparqlString(kind), graph }, + { subject: entryUri, predicate: DKG_ONTOLOGY.DKG_RESULT_NAME, object: sparqlString(name), graph }, + ); + + tuple.forEach((value, argIndex) => { + const argUri = `${entryUri}/arg/${argIndex}`; + quads.push( + { subject: argUri, predicate: DKG_ONTOLOGY.RDF_TYPE, object: DKG_ONTOLOGY.DKG_CCL_RESULT_ARG, graph }, + { subject: entryUri, predicate: DKG_ONTOLOGY.DKG_HAS_RESULT_ARG, object: argUri, graph }, + { subject: argUri, predicate: DKG_ONTOLOGY.DKG_RESULT_ARG_INDEX, object: sparqlString(String(argIndex)), graph }, + { subject: argUri, predicate: DKG_ONTOLOGY.DKG_RESULT_ARG_VALUE, object: sparqlString(JSON.stringify(value)), graph }, + ); + }); + }); + } +} + +function encodeSegment(value: string): string { + return encodeURIComponent(value).replace(/%/g, '_'); +} diff --git a/packages/agent/src/ccl-evaluator.ts b/packages/agent/src/ccl-evaluator.ts new file mode 100644 index 000000000..800d6b43b --- /dev/null +++ b/packages/agent/src/ccl-evaluator.ts @@ -0,0 +1,211 @@ +import { createHash } from 'node:crypto'; +import yaml from 'js-yaml'; + +export type CclFactTuple = [string, ...unknown[]]; + +export interface CclCanonicalPolicy { + policy?: string; + version?: string; + rules?: Array<{ + name: string; + params?: string[]; + all?: CclCondition[]; + }>; + decisions?: Array<{ + name: string; + params?: string[]; + all?: CclCondition[]; + }>; +} + +export type CclCondition = + | { atom: { pred: string; args?: unknown[] } } + | { exists: { where?: CclCondition[] } } + | { not_exists: { where?: CclCondition[] } } + | { count_distinct: { vars?: string[]; where?: CclCondition[]; op: string; value: number } }; + +export interface CclEvaluationResult { + derived: Record; + decisions: Record; +} + +type Binding = Record; + +function isVar(value: unknown): value is string { + return typeof value === 'string' && value.startsWith('$'); +} + +function normalizeVarName(value: string): string { + return value.startsWith('$') ? value : `$${value}`; +} + +function tupleKey(tuple: unknown[]): string { + return JSON.stringify(tuple); +} + +function compareTuples(left: unknown[], right: unknown[]): number { + return tupleKey(left).localeCompare(tupleKey(right)); +} + +export function parseCclPolicy(content: string): CclCanonicalPolicy { + const parsed = yaml.load(content); + if (!parsed || typeof parsed !== 'object') { + throw new Error('CCL policy must be a YAML object'); + } + return parsed as CclCanonicalPolicy; +} + +export function hashCclFacts(facts: CclFactTuple[]): string { + const normalized = facts.map(tuple => [...tuple]).sort(compareTuples); + return `sha256:${createHash('sha256').update(JSON.stringify(normalized)).digest('hex')}`; +} + +export class CclEvaluator { + private readonly policy: CclCanonicalPolicy; + private readonly relations = new Map>(); + + constructor(policy: CclCanonicalPolicy, facts: CclFactTuple[]) { + this.policy = policy; + for (const row of facts) { + const [pred, ...args] = row; + this.getRelation(pred).set(tupleKey(args), args); + } + } + + run(): CclEvaluationResult { + this.deriveFixpoint(); + const decisions = this.evaluateDecisions(); + const derived: Record = {}; + for (const rule of this.policy.rules ?? []) { + derived[rule.name] = this.sortedTuples(this.getRelation(rule.name)); + } + return { derived, decisions }; + } + + private deriveFixpoint(): void { + const rules = this.policy.rules ?? []; + for (let round = 0; round < 64; round += 1) { + let changed = false; + for (const rule of rules) { + const relation = this.getRelation(rule.name); + const before = relation.size; + for (const tuple of this.evaluateRule(rule)) { + relation.set(tupleKey(tuple), tuple); + } + if (relation.size !== before) changed = true; + } + if (!changed) return; + } + throw new Error('CCL fixpoint did not converge'); + } + + private evaluateRule(rule: NonNullable[number]): unknown[][] { + const params = (rule.params ?? []).map(normalizeVarName); + const tuples = new Map(); + for (const binding of this.evaluateConditions(rule.all ?? [], [{}])) { + const head = params.map(param => binding[param]); + tuples.set(tupleKey(head), head); + } + return [...tuples.values()].sort(compareTuples); + } + + private evaluateDecisions(): Record { + const decisions: Record = {}; + for (const decision of this.policy.decisions ?? []) { + const params = (decision.params ?? []).map(normalizeVarName); + const tuples = new Map(); + for (const binding of this.evaluateConditions(decision.all ?? [], [{}])) { + const head = params.map(param => binding[param]); + tuples.set(tupleKey(head), head); + } + decisions[decision.name] = [...tuples.values()].sort(compareTuples); + } + return decisions; + } + + private evaluateConditions(conditions: CclCondition[], bindings: Binding[]): Binding[] { + let current = bindings; + for (const condition of conditions) { + const next: Binding[] = []; + for (const binding of current) { + next.push(...this.evaluateCondition(condition, binding)); + } + current = next; + if (current.length === 0) break; + } + return current; + } + + private evaluateCondition(condition: CclCondition, binding: Binding): Binding[] { + if ('atom' in condition) { + return this.matchAtom(condition.atom.pred, condition.atom.args ?? [], binding); + } + if ('exists' in condition) { + const matches = this.evaluateConditions(condition.exists.where ?? [], [{ ...binding }]); + return matches.length > 0 ? [binding] : []; + } + if ('not_exists' in condition) { + const matches = this.evaluateConditions(condition.not_exists.where ?? [], [{ ...binding }]); + return matches.length === 0 ? [binding] : []; + } + if ('count_distinct' in condition) { + const vars = (condition.count_distinct.vars ?? []).map(normalizeVarName); + const matches = this.evaluateConditions(condition.count_distinct.where ?? [], [{ ...binding }]); + const projection = new Set(matches.map(match => tupleKey(vars.map(variable => match[variable])))); + return compareInts(projection.size, condition.count_distinct.op, Number(condition.count_distinct.value)) ? [binding] : []; + } + throw new Error(`Unsupported CCL condition: ${JSON.stringify(condition)}`); + } + + private matchAtom(pred: string, args: unknown[], binding: Binding): Binding[] { + const out: Binding[] = []; + for (const tuple of this.sortedTuples(this.getRelation(pred))) { + if (tuple.length !== args.length) continue; + const candidate: Binding = { ...binding }; + let ok = true; + for (let i = 0; i < args.length; i += 1) { + const term = args[i]; + const value = tuple[i]; + if (isVar(term)) { + if (Object.hasOwn(candidate, term)) { + if (candidate[term] !== value) { + ok = false; + break; + } + } else { + candidate[term] = value; + } + } else if (term !== value) { + ok = false; + break; + } + } + if (ok) out.push(candidate); + } + return out; + } + + private getRelation(pred: string): Map { + let relation = this.relations.get(pred); + if (!relation) { + relation = new Map(); + this.relations.set(pred, relation); + } + return relation; + } + + private sortedTuples(relation: Map): unknown[][] { + return [...relation.values()].map(tuple => [...tuple]).sort(compareTuples); + } +} + +function compareInts(left: number, op: string, right: number): boolean { + switch (op) { + case '>=': return left >= right; + case '>': return left > right; + case '==': return left === right; + case '<=': return left <= right; + case '<': return left < right; + default: throw new Error(`Unsupported CCL comparison operator: ${op}`); + } +} diff --git a/packages/agent/src/ccl-policy.ts b/packages/agent/src/ccl-policy.ts new file mode 100644 index 000000000..4a204bc6d --- /dev/null +++ b/packages/agent/src/ccl-policy.ts @@ -0,0 +1,136 @@ +import { createHash } from 'node:crypto'; +import { DKG_ONTOLOGY, sparqlString } from '@origintrail-official/dkg-core'; +import type { Quad } from '@origintrail-official/dkg-storage'; + +export interface PublishCclPolicyInput { + paranetId: string; + name: string; + version: string; + content: string; + description?: string; + contextType?: string; + language?: string; + format?: string; +} + +export interface CclPolicyRecord { + policyUri: string; + paranetId: string; + name: string; + version: string; + hash: string; + language: string; + format: string; + status: string; + creator?: string; + createdAt?: string; + approvedBy?: string; + approvedAt?: string; + description?: string; + contextType?: string; + body?: string; + isActiveDefault: boolean; + activeContexts: string[]; +} + +export interface PolicyApprovalBinding { + bindingUri: string; + policyUri: string; + paranetId: string; + name: string; + contextType?: string; + approvedAt: string; +} + +export function hashCclPolicy(content: string): string { + return `sha256:${createHash('sha256').update(content).digest('hex')}`; +} + +export function policyUriFor(paranetId: string, hash: string): string { + return `did:dkg:policy:${encodeSegment(paranetId)}:${hash.replace(/[^a-zA-Z0-9]/g, '-')}`; +} + +export function policyBindingUriFor(paranetId: string, name: string, contextType?: string): string { + const suffix = contextType ? `${encodeSegment(name)}:${encodeSegment(contextType)}` : `${encodeSegment(name)}:default`; + return `did:dkg:policy-binding:${encodeSegment(paranetId)}:${suffix}:${Date.now()}`; +} + +export function buildCclPolicyQuads(input: PublishCclPolicyInput, creator: string, graph: string, createdAt: string): { + policyUri: string; + hash: string; + quads: Quad[]; +} { + const hash = hashCclPolicy(input.content); + const policyUri = policyUriFor(input.paranetId, hash); + const paranetUri = `did:dkg:paranet:${input.paranetId}`; + const quads: Quad[] = [ + { subject: policyUri, predicate: DKG_ONTOLOGY.RDF_TYPE, object: DKG_ONTOLOGY.DKG_CCL_POLICY, graph }, + { subject: policyUri, predicate: DKG_ONTOLOGY.DKG_POLICY_APPLIES_TO_PARANET, object: paranetUri, graph }, + { subject: policyUri, predicate: DKG_ONTOLOGY.SCHEMA_NAME, object: sparqlString(input.name), graph }, + { subject: policyUri, predicate: DKG_ONTOLOGY.DKG_POLICY_VERSION, object: sparqlString(input.version), graph }, + { subject: policyUri, predicate: DKG_ONTOLOGY.DKG_POLICY_LANGUAGE, object: sparqlString(input.language ?? 'ccl/v0.1'), graph }, + { subject: policyUri, predicate: DKG_ONTOLOGY.DKG_POLICY_FORMAT, object: sparqlString(input.format ?? 'canonical-yaml'), graph }, + { subject: policyUri, predicate: DKG_ONTOLOGY.DKG_POLICY_HASH, object: sparqlString(hash), graph }, + { subject: policyUri, predicate: DKG_ONTOLOGY.DKG_POLICY_BODY, object: sparqlString(input.content), graph }, + { subject: policyUri, predicate: DKG_ONTOLOGY.DKG_POLICY_STATUS, object: sparqlString('proposed'), graph }, + { subject: policyUri, predicate: DKG_ONTOLOGY.DKG_CREATOR, object: creator, graph }, + { subject: policyUri, predicate: DKG_ONTOLOGY.DKG_CREATED_AT, object: sparqlString(createdAt), graph }, + ]; + + if (input.description) { + quads.push({ + subject: policyUri, + predicate: DKG_ONTOLOGY.SCHEMA_DESCRIPTION, + object: sparqlString(input.description), + graph, + }); + } + + if (input.contextType) { + quads.push({ + subject: policyUri, + predicate: DKG_ONTOLOGY.DKG_POLICY_CONTEXT_TYPE, + object: sparqlString(input.contextType), + graph, + }); + } + + return { policyUri, hash, quads }; +} + +export function buildPolicyApprovalQuads(opts: { + paranetId: string; + policyUri: string; + policyName: string; + creator: string; + graph: string; + approvedAt: string; + contextType?: string; +}): { bindingUri: string; quads: Quad[] } { + const bindingUri = policyBindingUriFor(opts.paranetId, opts.policyName, opts.contextType); + const paranetUri = `did:dkg:paranet:${opts.paranetId}`; + const quads: Quad[] = [ + { subject: bindingUri, predicate: DKG_ONTOLOGY.RDF_TYPE, object: DKG_ONTOLOGY.DKG_POLICY_BINDING, graph: opts.graph }, + { subject: bindingUri, predicate: DKG_ONTOLOGY.DKG_POLICY_APPLIES_TO_PARANET, object: paranetUri, graph: opts.graph }, + { subject: bindingUri, predicate: DKG_ONTOLOGY.SCHEMA_NAME, object: sparqlString(opts.policyName), graph: opts.graph }, + { subject: bindingUri, predicate: DKG_ONTOLOGY.DKG_ACTIVE_POLICY, object: opts.policyUri, graph: opts.graph }, + { subject: bindingUri, predicate: DKG_ONTOLOGY.DKG_APPROVED_BY, object: opts.creator, graph: opts.graph }, + { subject: bindingUri, predicate: DKG_ONTOLOGY.DKG_APPROVED_AT, object: sparqlString(opts.approvedAt), graph: opts.graph }, + { subject: bindingUri, predicate: DKG_ONTOLOGY.DKG_CREATED_AT, object: sparqlString(opts.approvedAt), graph: opts.graph }, + ]; + + if (opts.contextType) { + quads.push({ + subject: bindingUri, + predicate: DKG_ONTOLOGY.DKG_POLICY_CONTEXT_TYPE, + object: sparqlString(opts.contextType), + graph: opts.graph, + }); + } + + return { bindingUri, quads }; +} + +function encodeSegment(value: string): string { + return encodeURIComponent(value).replace(/%/g, '_'); +} diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 8edd864fb..9b304eab4 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -7,7 +7,7 @@ import { encodeKAUpdateRequest, encodeFinalizationMessage, type FinalizationMessageMsg, getGenesisQuads, computeNetworkId, SYSTEM_PARANETS, DKG_ONTOLOGY, - Logger, createOperationContext, withRetry, + Logger, createOperationContext, withRetry, sparqlString, type DKGNodeConfig, type OperationContext, } from '@origintrail-official/dkg-core'; import { GraphManager, createTripleStore, type TripleStore, type TripleStoreConfig, type Quad } from '@origintrail-official/dkg-storage'; @@ -32,6 +32,28 @@ import { AGENT_REGISTRY_PARANET, type AgentProfileConfig } from './profile.js'; import { GossipPublishHandler } from './gossip-publish-handler.js'; import { FinalizationHandler } from './finalization-handler.js'; import { multiaddr } from '@multiformats/multiaddr'; +import { buildCclPolicyQuads, buildPolicyApprovalQuads, type CclPolicyRecord, type PolicyApprovalBinding } from './ccl-policy.js'; +import { CclEvaluator, hashCclFacts, parseCclPolicy, type CclEvaluationResult, type CclFactTuple } from './ccl-evaluator.js'; +import { buildCclEvaluationQuads } from './ccl-evaluation-publish.js'; + +export interface CclPublishedResultEntry { + entryUri: string; + kind: 'derived' | 'decision'; + name: string; + tuple: unknown[]; +} + +export interface CclPublishedEvaluationRecord { + evaluationUri: string; + policyUri: string; + factSetHash: string; + createdAt?: string; + view?: string; + snapshotId?: string; + scopeUal?: string; + contextType?: string; + results: CclPublishedResultEntry[]; +} interface PublishOpts { onPhase?: PhaseCallback; @@ -1918,6 +1940,387 @@ export class DKGAgent { } } + async publishCclPolicy(opts: { + paranetId: string; + name: string; + version: string; + content: string; + description?: string; + contextType?: string; + language?: string; + format?: string; + }): Promise<{ policyUri: string; hash: string; status: 'proposed' }> { + const ctx = createOperationContext('system'); + if (!(await this.paranetExists(opts.paranetId))) { + throw new Error(`Paranet "${opts.paranetId}" does not exist. Create it first with createParanet().`); + } + + const ontologyGraph = paranetDataGraphUri(SYSTEM_PARANETS.ONTOLOGY); + const now = new Date().toISOString(); + const { policyUri, hash, quads } = buildCclPolicyQuads(opts, `did:dkg:agent:${this.peerId}`, ontologyGraph, now); + await this.store.insert(quads); + await this.publishOntologyQuads(policyUri, quads); + this.log.info(ctx, `Published CCL policy ${opts.name}@${opts.version} for paranet "${opts.paranetId}"`); + return { policyUri, hash, status: 'proposed' }; + } + + async approveCclPolicy(opts: { + paranetId: string; + policyUri: string; + contextType?: string; + }): Promise<{ policyUri: string; bindingUri: string; contextType?: string; approvedAt: string }> { + const ctx = createOperationContext('system'); + await this.assertParanetOwner(opts.paranetId); + const record = await this.getCclPolicyByUri(opts.policyUri); + if (!record) throw new Error(`CCL policy not found: ${opts.policyUri}`); + if (record.paranetId !== opts.paranetId) { + throw new Error(`CCL policy ${opts.policyUri} belongs to paranet "${record.paranetId}", not "${opts.paranetId}"`); + } + if (record.contextType && opts.contextType && record.contextType !== opts.contextType) { + throw new Error(`CCL policy contextType mismatch: policy=${record.contextType}, requested=${opts.contextType}`); + } + + const ontologyGraph = paranetDataGraphUri(SYSTEM_PARANETS.ONTOLOGY); + const approvedAt = new Date().toISOString(); + const effectiveContextType = opts.contextType ?? record.contextType; + const { bindingUri, quads } = buildPolicyApprovalQuads({ + paranetId: opts.paranetId, + policyUri: opts.policyUri, + policyName: record.name, + creator: `did:dkg:agent:${this.peerId}`, + graph: ontologyGraph, + approvedAt, + contextType: effectiveContextType, + }); + + quads.push( + { subject: opts.policyUri, predicate: DKG_ONTOLOGY.DKG_POLICY_STATUS, object: sparqlString('approved'), graph: ontologyGraph }, + { subject: opts.policyUri, predicate: DKG_ONTOLOGY.DKG_APPROVED_BY, object: `did:dkg:agent:${this.peerId}`, graph: ontologyGraph }, + { subject: opts.policyUri, predicate: DKG_ONTOLOGY.DKG_APPROVED_AT, object: sparqlString(approvedAt), graph: ontologyGraph }, + ); + + await this.store.insert(quads); + await this.publishOntologyQuads(bindingUri, quads); + this.log.info(ctx, `Approved CCL policy ${record.name}@${record.version} for paranet "${opts.paranetId}"${effectiveContextType ? ` (context ${effectiveContextType})` : ''}`); + return { policyUri: opts.policyUri, bindingUri, contextType: effectiveContextType, approvedAt }; + } + + async listCclPolicies(opts: { + paranetId?: string; + name?: string; + contextType?: string; + status?: string; + includeBody?: boolean; + } = {}): Promise { + const ontologyGraph = paranetDataGraphUri(SYSTEM_PARANETS.ONTOLOGY); + const filters: string[] = []; + if (opts.paranetId) filters.push(`?paranet = `); + if (opts.name) filters.push(`?name = ${sparqlString(opts.name)}`); + if (opts.contextType) filters.push(`?contextType = ${sparqlString(opts.contextType)}`); + if (opts.status) filters.push(`?status = ${sparqlString(opts.status)}`); + const filterBlock = filters.length > 0 ? `FILTER(${filters.join(' && ')})` : ''; + const bodyClause = opts.includeBody ? `OPTIONAL { ?policy <${DKG_ONTOLOGY.DKG_POLICY_BODY}> ?body }` : ''; + + const result = await this.store.query(` + SELECT ?policy ?paranet ?name ?version ?hash ?language ?format ?status ?creator ?created ?approvedBy ?approvedAt ?desc ?contextType ${opts.includeBody ? '?body' : ''} WHERE { + GRAPH <${ontologyGraph}> { + ?policy <${DKG_ONTOLOGY.RDF_TYPE}> <${DKG_ONTOLOGY.DKG_CCL_POLICY}> ; + <${DKG_ONTOLOGY.DKG_POLICY_APPLIES_TO_PARANET}> ?paranet ; + <${DKG_ONTOLOGY.SCHEMA_NAME}> ?name ; + <${DKG_ONTOLOGY.DKG_POLICY_VERSION}> ?version ; + <${DKG_ONTOLOGY.DKG_POLICY_HASH}> ?hash ; + <${DKG_ONTOLOGY.DKG_POLICY_LANGUAGE}> ?language ; + <${DKG_ONTOLOGY.DKG_POLICY_FORMAT}> ?format ; + <${DKG_ONTOLOGY.DKG_POLICY_STATUS}> ?status . + OPTIONAL { ?policy <${DKG_ONTOLOGY.DKG_CREATOR}> ?creator } + OPTIONAL { ?policy <${DKG_ONTOLOGY.DKG_CREATED_AT}> ?created } + OPTIONAL { ?policy <${DKG_ONTOLOGY.DKG_APPROVED_BY}> ?approvedBy } + OPTIONAL { ?policy <${DKG_ONTOLOGY.DKG_APPROVED_AT}> ?approvedAt } + OPTIONAL { ?policy <${DKG_ONTOLOGY.SCHEMA_DESCRIPTION}> ?desc } + OPTIONAL { ?policy <${DKG_ONTOLOGY.DKG_POLICY_CONTEXT_TYPE}> ?contextType } + ${bodyClause} + ${filterBlock} + } + } + ORDER BY ?name ?version + `); + + const bindings = await this.listCclPolicyBindings({ paranetId: opts.paranetId, name: opts.name }); + const latestByScope = new Map(); + for (const binding of bindings) { + const key = `${binding.paranetId}|${binding.name}|${binding.contextType ?? ''}`; + const current = latestByScope.get(key); + if (!current || binding.approvedAt > current.approvedAt) { + latestByScope.set(key, binding); + } + } + + const records = new Map(); + if (result.type === 'bindings') { + for (const row of result.bindings as Record[]) { + const paranetUri = row['paranet']; + const paranetId = paranetUri.startsWith('did:dkg:paranet:') ? paranetUri.slice('did:dkg:paranet:'.length) : paranetUri; + const name = stripLiteral(row['name']); + const defaultActive = latestByScope.get(`${paranetId}|${name}|`); + const activeContexts = Array.from(latestByScope.values()) + .filter(binding => binding.paranetId === paranetId && binding.name === name && binding.contextType && binding.policyUri === row['policy']) + .map(binding => binding.contextType as string) + .sort(); + const nextRecord: CclPolicyRecord = { + policyUri: row['policy'], + paranetId, + name, + version: stripLiteral(row['version']), + hash: stripLiteral(row['hash']), + language: stripLiteral(row['language']), + format: stripLiteral(row['format']), + status: stripLiteral(row['status']), + creator: row['creator'], + createdAt: row['created'] ? stripLiteral(row['created']) : undefined, + approvedBy: row['approvedBy'], + approvedAt: row['approvedAt'] ? stripLiteral(row['approvedAt']) : undefined, + description: row['desc'] ? stripLiteral(row['desc']) : undefined, + contextType: row['contextType'] ? stripLiteral(row['contextType']) : undefined, + body: row['body'] ? stripLiteral(row['body']) : undefined, + isActiveDefault: defaultActive?.policyUri === row['policy'], + activeContexts, + }; + + const current = records.get(row['policy']); + if (!current || (current.status !== 'approved' && nextRecord.status === 'approved')) { + records.set(row['policy'], nextRecord); + } + } + } + + return Array.from(records.values()).sort((a, b) => `${a.name}@${a.version}`.localeCompare(`${b.name}@${b.version}`)); + } + + async resolveCclPolicy(opts: { + paranetId: string; + name: string; + contextType?: string; + includeBody?: boolean; + }): Promise { + const bindings = await this.listCclPolicyBindings({ paranetId: opts.paranetId, name: opts.name }); + const matching = bindings + .filter(binding => binding.contextType === opts.contextType) + .sort((a, b) => b.approvedAt.localeCompare(a.approvedAt)); + const fallback = bindings + .filter(binding => binding.contextType == null) + .sort((a, b) => b.approvedAt.localeCompare(a.approvedAt)); + const selected = matching[0] ?? fallback[0]; + if (!selected) return null; + const record = await this.getCclPolicyByUri(selected.policyUri, { includeBody: opts.includeBody }); + if (!record) return null; + record.isActiveDefault = !selected.contextType; + record.activeContexts = selected.contextType ? [selected.contextType] : record.activeContexts; + return record; + } + + async evaluateCclPolicy(opts: { + paranetId: string; + name: string; + facts: CclFactTuple[]; + contextType?: string; + view?: string; + snapshotId?: string; + scopeUal?: string; + }): Promise<{ + policy: Pick; + context: { + paranetId: string; + contextType?: string; + view?: string; + snapshotId?: string; + scopeUal?: string; + }; + factSetHash: string; + result: CclEvaluationResult; + }> { + const policy = await this.resolveCclPolicy({ + paranetId: opts.paranetId, + name: opts.name, + contextType: opts.contextType, + includeBody: true, + }); + if (!policy?.body) { + throw new Error(`No approved policy found for ${opts.paranetId}/${opts.name}${opts.contextType ? `/${opts.contextType}` : ''}`); + } + + const parsed = parseCclPolicy(policy.body); + const evaluator = new CclEvaluator(parsed, opts.facts); + const result = evaluator.run(); + + return { + policy: { + policyUri: policy.policyUri, + paranetId: policy.paranetId, + name: policy.name, + version: policy.version, + hash: policy.hash, + language: policy.language, + format: policy.format, + contextType: opts.contextType ?? policy.contextType, + }, + context: { + paranetId: opts.paranetId, + contextType: opts.contextType, + view: opts.view, + snapshotId: opts.snapshotId, + scopeUal: opts.scopeUal, + }, + factSetHash: hashCclFacts(opts.facts), + result, + }; + } + + async evaluateAndPublishCclPolicy(opts: { + paranetId: string; + name: string; + facts: CclFactTuple[]; + contextType?: string; + view?: string; + snapshotId?: string; + scopeUal?: string; + }): Promise<{ + evaluationUri: string; + publish: PublishResult; + evaluation: { + policy: Pick; + context: { + paranetId: string; + contextType?: string; + view?: string; + snapshotId?: string; + scopeUal?: string; + }; + factSetHash: string; + result: CclEvaluationResult; + }; + }> { + const evaluation = await this.evaluateCclPolicy(opts); + const graph = paranetDataGraphUri(opts.paranetId); + const { evaluationUri, quads } = buildCclEvaluationQuads({ + paranetId: opts.paranetId, + policyUri: evaluation.policy.policyUri, + factSetHash: evaluation.factSetHash, + result: evaluation.result, + evaluatedAt: new Date().toISOString(), + view: evaluation.context.view, + snapshotId: evaluation.context.snapshotId, + scopeUal: evaluation.context.scopeUal, + contextType: evaluation.context.contextType, + }, graph); + const publish = await this.publish(opts.paranetId, quads); + return { evaluationUri, publish, evaluation }; + } + + async listCclEvaluations(opts: { + paranetId: string; + policyUri?: string; + snapshotId?: string; + view?: string; + contextType?: string; + resultKind?: 'derived' | 'decision'; + resultName?: string; + }): Promise { + const graph = paranetDataGraphUri(opts.paranetId); + const filters: string[] = []; + if (opts.policyUri) filters.push(`?policy = <${opts.policyUri}>`); + if (opts.snapshotId) filters.push(`?snapshotId = ${sparqlString(opts.snapshotId)}`); + if (opts.view) filters.push(`?view = ${sparqlString(opts.view)}`); + if (opts.contextType) filters.push(`?contextType = ${sparqlString(opts.contextType)}`); + if (opts.resultKind) filters.push(`?kind = ${sparqlString(opts.resultKind)}`); + if (opts.resultName) filters.push(`?resultName = ${sparqlString(opts.resultName)}`); + const filterBlock = filters.length > 0 ? `FILTER(${filters.join(' && ')})` : ''; + + const result = await this.store.query(` + SELECT ?evaluation ?policy ?factSetHash ?createdAt ?view ?snapshotId ?scopeUal ?contextType ?entry ?kind ?resultName ?arg ?argIndex ?argValue WHERE { + GRAPH <${graph}> { + ?evaluation <${DKG_ONTOLOGY.RDF_TYPE}> <${DKG_ONTOLOGY.DKG_CCL_EVALUATION}> ; + <${DKG_ONTOLOGY.DKG_EVALUATED_POLICY}> ?policy ; + <${DKG_ONTOLOGY.DKG_FACT_SET_HASH}> ?factSetHash . + OPTIONAL { ?evaluation <${DKG_ONTOLOGY.DKG_CREATED_AT}> ?createdAt } + OPTIONAL { ?evaluation <${DKG_ONTOLOGY.DKG_VIEW}> ?view } + OPTIONAL { ?evaluation <${DKG_ONTOLOGY.DKG_SNAPSHOT_ID}> ?snapshotId } + OPTIONAL { ?evaluation <${DKG_ONTOLOGY.DKG_SCOPE_UAL}> ?scopeUal } + OPTIONAL { ?evaluation <${DKG_ONTOLOGY.DKG_POLICY_CONTEXT_TYPE}> ?contextType } + OPTIONAL { + ?evaluation <${DKG_ONTOLOGY.DKG_HAS_RESULT}> ?entry . + ?entry <${DKG_ONTOLOGY.DKG_RESULT_KIND}> ?kind ; + <${DKG_ONTOLOGY.DKG_RESULT_NAME}> ?resultName . + OPTIONAL { + ?entry <${DKG_ONTOLOGY.DKG_HAS_RESULT_ARG}> ?arg . + ?arg <${DKG_ONTOLOGY.DKG_RESULT_ARG_INDEX}> ?argIndex ; + <${DKG_ONTOLOGY.DKG_RESULT_ARG_VALUE}> ?argValue . + } + } + ${filterBlock} + } + } + ORDER BY DESC(?createdAt) ?evaluation ?kind ?resultName ?argIndex + `); + + if (result.type !== 'bindings') return []; + const records = new Map(); + const entryArgs = new Map>(); + for (const row of result.bindings as Record[]) { + const evaluationUri = row['evaluation']; + let record = records.get(evaluationUri); + if (!record) { + record = { + evaluationUri, + policyUri: row['policy'], + factSetHash: stripLiteral(row['factSetHash']), + createdAt: row['createdAt'] ? stripLiteral(row['createdAt']) : undefined, + view: row['view'] ? stripLiteral(row['view']) : undefined, + snapshotId: row['snapshotId'] ? stripLiteral(row['snapshotId']) : undefined, + scopeUal: row['scopeUal'] ? stripLiteral(row['scopeUal']) : undefined, + contextType: row['contextType'] ? stripLiteral(row['contextType']) : undefined, + results: [], + }; + records.set(evaluationUri, record); + } + + if (row['entry']) { + const entryUri = row['entry']; + let existing = record.results.find(resultEntry => resultEntry.entryUri === entryUri); + if (!existing) { + existing = { + entryUri, + kind: stripLiteral(row['kind']) as 'derived' | 'decision', + name: stripLiteral(row['resultName']), + tuple: [], + }; + record.results.push(existing); + } + + if (row['arg'] && row['argIndex'] && row['argValue']) { + let args = entryArgs.get(entryUri); + if (!args) { + args = new Map(); + entryArgs.set(entryUri, args); + } + args.set(Number(stripLiteral(row['argIndex'])), JSON.parse(stripLiteral(row['argValue']))); + } + } + } + + for (const record of records.values()) { + for (const resultEntry of record.results) { + const args = entryArgs.get(resultEntry.entryUri); + if (args && args.size > 0) { + resultEntry.tuple = [...args.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([, value]) => value); + } + } + } + + return Array.from(records.values()); + } + /** * Check whether a paranet is registered (definition triples exist in the * ontology graph). Always store-backed to avoid false positives from @@ -2028,6 +2431,100 @@ export class DKGAgent { return this.node.peerId; } + private async getCclPolicyByUri(policyUri: string, opts: { includeBody?: boolean } = {}): Promise { + const records = await this.listCclPolicies({ includeBody: opts.includeBody }); + return records.find(record => record.policyUri === policyUri) ?? null; + } + + private async assertParanetOwner(paranetId: string): Promise { + const owner = await this.getParanetOwner(paranetId); + const current = `did:dkg:agent:${this.peerId}`; + if (!owner) { + throw new Error(`Paranet "${paranetId}" has no registered owner; cannot approve policies.`); + } + if (owner !== current) { + throw new Error(`Only the paranet owner can approve policies for "${paranetId}". Owner=${owner}, current=${current}`); + } + } + + private async getParanetOwner(paranetId: string): Promise { + const ontologyGraph = paranetDataGraphUri(SYSTEM_PARANETS.ONTOLOGY); + const paranetUri = `did:dkg:paranet:${paranetId}`; + const result = await this.store.query(` + SELECT ?owner WHERE { + GRAPH <${ontologyGraph}> { + <${paranetUri}> <${DKG_ONTOLOGY.DKG_CREATOR}> ?owner . + } + } + LIMIT 1 + `); + if (result.type !== 'bindings' || result.bindings.length === 0) return null; + return (result.bindings[0] as Record)['owner'] ?? null; + } + + private async listCclPolicyBindings(opts: { + paranetId?: string; + name?: string; + } = {}): Promise { + const ontologyGraph = paranetDataGraphUri(SYSTEM_PARANETS.ONTOLOGY); + const filters: string[] = []; + if (opts.paranetId) filters.push(`?paranet = `); + if (opts.name) filters.push(`?name = ${sparqlString(opts.name)}`); + const filterBlock = filters.length > 0 ? `FILTER(${filters.join(' && ')})` : ''; + const result = await this.store.query(` + SELECT ?binding ?policy ?paranet ?name ?contextType ?approvedAt WHERE { + GRAPH <${ontologyGraph}> { + ?binding <${DKG_ONTOLOGY.RDF_TYPE}> <${DKG_ONTOLOGY.DKG_POLICY_BINDING}> ; + <${DKG_ONTOLOGY.DKG_POLICY_APPLIES_TO_PARANET}> ?paranet ; + <${DKG_ONTOLOGY.SCHEMA_NAME}> ?name ; + <${DKG_ONTOLOGY.DKG_ACTIVE_POLICY}> ?policy ; + <${DKG_ONTOLOGY.DKG_APPROVED_AT}> ?approvedAt . + OPTIONAL { ?binding <${DKG_ONTOLOGY.DKG_POLICY_CONTEXT_TYPE}> ?contextType } + ${filterBlock} + } + } + ORDER BY DESC(?approvedAt) + `); + + if (result.type !== 'bindings') return []; + return (result.bindings as Record[]).map((row) => ({ + bindingUri: row['binding'], + policyUri: row['policy'], + paranetId: row['paranet'].startsWith('did:dkg:paranet:') ? row['paranet'].slice('did:dkg:paranet:'.length) : row['paranet'], + name: stripLiteral(row['name']), + contextType: row['contextType'] ? stripLiteral(row['contextType']) : undefined, + approvedAt: stripLiteral(row['approvedAt']), + })); + } + + private async publishOntologyQuads(ual: string, quads: Quad[]): Promise { + const ontologyTopic = paranetPublishTopic(SYSTEM_PARANETS.ONTOLOGY); + const nquads = quads.map(q => { + const obj = q.object.startsWith('"') ? q.object : `<${q.object}>`; + return `<${q.subject}> <${q.predicate}> ${obj} <${q.graph}> .`; + }).join('\n'); + + const msg = encodePublishRequest({ + ual, + nquads: new TextEncoder().encode(nquads), + paranetId: SYSTEM_PARANETS.ONTOLOGY, + kas: [], + publisherIdentity: this.wallet.keypair.publicKey, + publisherAddress: '', + startKAId: 0, + endKAId: 0, + chainId: '', + publisherSignatureR: new Uint8Array(0), + publisherSignatureVs: new Uint8Array(0), + }); + + try { + await this.gossip.publish(ontologyTopic, msg); + } catch { + // No peers subscribed — ok for local-only operation + } + } + get identityId(): bigint { return this.publisher.getIdentityId(); } @@ -2358,12 +2855,21 @@ function strip(s: string): string { } function stripLiteral(s: string): string { - if (s.startsWith('"') && s.endsWith('"')) return s.slice(1, -1); + if (s.startsWith('"') && s.endsWith('"')) return unescapeLiteralContent(s.slice(1, -1)); const match = s.match(/^"(.*)"(\^\^.*|@.*)?$/); - if (match) return match[1]; + if (match) return unescapeLiteralContent(match[1]); return s; } +function unescapeLiteralContent(value: string): string { + return value + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\r') + .replace(/\\t/g, '\t') + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\'); +} + /** * Minimal N-Quads parser for sync responses. * Reuses the existing `splitNQuadLine` helper above. diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 89fa0c846..c2507e661 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -7,6 +7,28 @@ export { encrypt, decrypt, ed25519ToX25519Private, ed25519ToX25519Public, x25519 export { MessageHandler, type SkillRequest, type SkillResponse, type SkillHandler, type ChatHandler } from './messaging.js'; export { GossipPublishHandler, type GossipPublishHandlerCallbacks } from './gossip-publish-handler.js'; export { FinalizationHandler } from './finalization-handler.js'; +export { + CclEvaluator, + parseCclPolicy, + hashCclFacts, + type CclFactTuple, + type CclCanonicalPolicy, + type CclCondition, + type CclEvaluationResult, +} from './ccl-evaluator.js'; +export { + buildCclEvaluationQuads, + type PublishCclEvaluationInput, +} from './ccl-evaluation-publish.js'; +export { + buildCclPolicyQuads, + buildPolicyApprovalQuads, + hashCclPolicy, + type PublishCclPolicyInput, + type CclPolicyRecord, + type PolicyApprovalBinding, +} from './ccl-policy.js'; export { DKGAgent, type DKGAgentConfig, type ParanetSub, type PeerHealth } from './dkg-agent.js'; +export type { CclPublishedEvaluationRecord, CclPublishedResultEntry } from './dkg-agent.js'; export { monotonicTransition, versionedWrite, type MonotonicStages } from './workspace-consistency.js'; export { StaleWriteError, type CASCondition } from '@origintrail-official/dkg-publisher'; diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 1f6d989c0..21fde641b 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -617,6 +617,186 @@ describe('Genesis Knowledge', () => { await agent1.stop().catch(() => {}); await agent2.stop().catch(() => {}); }); + + it('publishes, approves, lists, and resolves CCL policies per paranet', async () => { + const store = new OxigraphStore(); + const agent = await DKGAgent.create({ + name: 'PolicyBot', + store, + chainAdapter: new MockChainAdapter(), + }); + await agent.start(); + + await agent.createParanet({ id: 'ops-policy', name: 'Ops Policy' }); + + const published = await agent.publishCclPolicy({ + paranetId: 'ops-policy', + name: 'incident-review', + version: '0.1.0', + content: `policy: incident-review +version: 0.1.0 +rules: [] +decisions: [] +`, + }); + + expect(published.policyUri).toContain('did:dkg:policy:'); + expect(published.hash).toContain('sha256:'); + + await agent.approveCclPolicy({ paranetId: 'ops-policy', policyUri: published.policyUri }); + + const listed = await agent.listCclPolicies({ paranetId: 'ops-policy' }); + expect(listed).toHaveLength(1); + expect(listed[0].name).toBe('incident-review'); + expect(listed[0].isActiveDefault).toBe(true); + + const resolved = await agent.resolveCclPolicy({ paranetId: 'ops-policy', name: 'incident-review', includeBody: true }); + expect(resolved?.policyUri).toBe(published.policyUri); + expect(resolved?.body).toContain('rules: []'); + + const evaluation = await agent.evaluateCclPolicy({ + paranetId: 'ops-policy', + name: 'incident-review', + facts: [['claim', 'c1']], + snapshotId: 'snap-1', + }); + expect(evaluation.policy.policyUri).toBe(published.policyUri); + expect(evaluation.factSetHash).toContain('sha256:'); + expect(evaluation.result.derived).toEqual({}); + + const publishedEval = await agent.evaluateAndPublishCclPolicy({ + paranetId: 'ops-policy', + name: 'incident-review', + facts: [['claim', 'c1']], + snapshotId: 'snap-2', + }); + expect(publishedEval.evaluationUri).toContain('did:dkg:ccl-eval:'); + expect(publishedEval.publish.status).toBeDefined(); + + const storedEval = await store.query( + `SELECT ?hash WHERE { GRAPH { <${publishedEval.evaluationUri}> ?hash } }`, + ); + expect(storedEval.type).toBe('bindings'); + if (storedEval.type === 'bindings') { + expect(storedEval.bindings.length).toBe(1); + } + + const listedEvals = await agent.listCclEvaluations({ + paranetId: 'ops-policy', + snapshotId: 'snap-2', + }); + expect(listedEvals).toHaveLength(1); + expect(listedEvals[0].evaluationUri).toBe(publishedEval.evaluationUri); + expect(listedEvals[0].results).toEqual([]); + + await agent.stop().catch(() => {}); + }); + + it('prefers stricter per-context policy overrides when resolving CCL policy', async () => { + const store = new OxigraphStore(); + const agent = await DKGAgent.create({ + name: 'ContextPolicyBot', + store, + chainAdapter: new MockChainAdapter(), + }); + await agent.start(); + + await agent.createParanet({ id: 'ops-context', name: 'Ops Context' }); + + const base = await agent.publishCclPolicy({ + paranetId: 'ops-context', + name: 'incident-review', + version: '0.1.0', + content: `policy: incident-review +version: 0.1.0 +rules: [] +decisions: [] +`, + }); + await agent.approveCclPolicy({ paranetId: 'ops-context', policyUri: base.policyUri }); + + const override = await agent.publishCclPolicy({ + paranetId: 'ops-context', + name: 'incident-review', + version: '0.2.0', + contextType: 'incident_review', + content: `policy: incident-review +version: 0.2.0 +rules: [] +decisions: [] +`, + }); + await agent.approveCclPolicy({ paranetId: 'ops-context', policyUri: override.policyUri, contextType: 'incident_review' }); + + const resolvedDefault = await agent.resolveCclPolicy({ paranetId: 'ops-context', name: 'incident-review' }); + expect(resolvedDefault?.policyUri).toBe(base.policyUri); + + const resolvedContext = await agent.resolveCclPolicy({ paranetId: 'ops-context', name: 'incident-review', contextType: 'incident_review' }); + expect(resolvedContext?.policyUri).toBe(override.policyUri); + expect(resolvedContext?.activeContexts).toContain('incident_review'); + + const evaluatedContext = await agent.evaluateCclPolicy({ + paranetId: 'ops-context', + name: 'incident-review', + contextType: 'incident_review', + facts: [['claim', 'c2']], + }); + expect(evaluatedContext.policy.policyUri).toBe(override.policyUri); + + const publishedContextEval = await agent.evaluateAndPublishCclPolicy({ + paranetId: 'ops-context', + name: 'incident-review', + contextType: 'incident_review', + facts: [['claim', 'c2']], + snapshotId: 'snap-ctx', + }); + const listedByContext = await agent.listCclEvaluations({ + paranetId: 'ops-context', + contextType: 'incident_review', + snapshotId: 'snap-ctx', + }); + expect(listedByContext.some(entry => entry.evaluationUri === publishedContextEval.evaluationUri)).toBe(true); + + await agent.stop().catch(() => {}); + }); + + it('restricts CCL policy approval to the paranet owner', async () => { + const store = new OxigraphStore(); + const owner = await DKGAgent.create({ + name: 'OwnerBot', + store, + chainAdapter: new MockChainAdapter(), + }); + const other = await DKGAgent.create({ + name: 'OtherBot', + store, + chainAdapter: new MockChainAdapter(), + }); + + await owner.start(); + await other.start(); + await owner.createParanet({ id: 'ops-owner', name: 'Ops Owner' }); + + const published = await owner.publishCclPolicy({ + paranetId: 'ops-owner', + name: 'incident-review', + version: '0.1.0', + content: `policy: incident-review +version: 0.1.0 +rules: [] +decisions: [] +`, + }); + + await expect(other.approveCclPolicy({ paranetId: 'ops-owner', policyUri: published.policyUri })) + .rejects.toThrow(/Only the paranet owner can approve policies/); + + await expect(owner.approveCclPolicy({ paranetId: 'ops-owner', policyUri: published.policyUri })) + .resolves.toBeTruthy(); + + await owner.stop().catch(() => {}); + await other.stop().catch(() => {}); + }); }); describe('Node Roles', () => { diff --git a/packages/cli/package.json b/packages/cli/package.json index 59c5aa892..7e7406c48 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -20,10 +20,12 @@ "@origintrail-official/dkg-node-ui": "workspace:*", "commander": "^13", "ethers": "^6", + "js-yaml": "^4.1.1", "n3": "^2.0.1", "typescript": "^5.7" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@types/n3": "^1.26.1", "vitest": "^4.0.18" }, diff --git a/packages/cli/src/api-client.ts b/packages/cli/src/api-client.ts index 3d89b25b8..1ec211441 100644 --- a/packages/cli/src/api-client.ts +++ b/packages/cli/src/api-client.ts @@ -221,6 +221,93 @@ export class ApiClient { return this.get(`/api/paranet/exists?id=${encodeURIComponent(id)}`); } + async publishCclPolicy(request: { + paranetId: string; + name: string; + version: string; + content: string; + description?: string; + contextType?: string; + language?: string; + format?: string; + }): Promise<{ policyUri: string; hash: string; status: 'proposed' }> { + return this.post('/api/ccl/policy/publish', request); + } + + async approveCclPolicy(request: { + paranetId: string; + policyUri: string; + contextType?: string; + }): Promise<{ policyUri: string; bindingUri: string; contextType?: string; approvedAt: string }> { + return this.post('/api/ccl/policy/approve', request); + } + + async listCclPolicies(opts: { + paranetId?: string; + name?: string; + contextType?: string; + status?: string; + includeBody?: boolean; + } = {}): Promise<{ policies: any[] }> { + const params = new URLSearchParams(); + if (opts.paranetId) params.set('paranetId', opts.paranetId); + if (opts.name) params.set('name', opts.name); + if (opts.contextType) params.set('contextType', opts.contextType); + if (opts.status) params.set('status', opts.status); + if (opts.includeBody) params.set('includeBody', 'true'); + const qs = params.toString(); + return this.get(`/api/ccl/policy/list${qs ? `?${qs}` : ''}`); + } + + async resolveCclPolicy(opts: { + paranetId: string; + name: string; + contextType?: string; + includeBody?: boolean; + }): Promise<{ policy: any | null }> { + const params = new URLSearchParams({ paranetId: opts.paranetId, name: opts.name }); + if (opts.contextType) params.set('contextType', opts.contextType); + if (opts.includeBody) params.set('includeBody', 'true'); + return this.get(`/api/ccl/policy/resolve?${params.toString()}`); + } + + async evaluateCclPolicy(request: { + paranetId: string; + name: string; + facts: Array<[string, ...unknown[]]>; + contextType?: string; + view?: string; + snapshotId?: string; + scopeUal?: string; + publishResult?: boolean; + }): Promise<{ + policy: any; + context: any; + factSetHash: string; + result: any; + }> { + return this.post('/api/ccl/eval', request); + } + + async listCclEvaluations(opts: { + paranetId: string; + policyUri?: string; + snapshotId?: string; + view?: string; + contextType?: string; + resultKind?: 'derived' | 'decision'; + resultName?: string; + }): Promise<{ evaluations: any[] }> { + const params = new URLSearchParams({ paranetId: opts.paranetId }); + if (opts.policyUri) params.set('policyUri', opts.policyUri); + if (opts.snapshotId) params.set('snapshotId', opts.snapshotId); + if (opts.view) params.set('view', opts.view); + if (opts.contextType) params.set('contextType', opts.contextType); + if (opts.resultKind) params.set('resultKind', opts.resultKind); + if (opts.resultName) params.set('resultName', opts.resultName); + return this.get(`/api/ccl/results?${params.toString()}`); + } + async shutdown(): Promise { try { await this.post('/api/shutdown', {}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 8226f3132..537d4eb7f 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -11,6 +11,7 @@ import { writeFile, unlink } from 'node:fs/promises'; import { ethers } from 'ethers'; import { requestFaucetFunding } from './faucet.js'; import { toErrorMessage, hasErrorCode } from '@origintrail-official/dkg-core'; +import yaml from 'js-yaml'; import { loadConfig, saveConfig, configExists, configPath, readPid, readApiPort, isProcessRunning, dkgDir, logPath, ensureDkgDir, @@ -66,6 +67,12 @@ function getCliVersion(): string { } } +function loadStructuredFile(filePath: string): any { + const content = readFileSync(filePath, 'utf8'); + if (filePath.endsWith('.json')) return JSON.parse(content); + return yaml.load(content); +} + function resolveDaemonEntryPoint(): string { const rDir = releasesDir(); if (existsSync(rDir)) { @@ -434,8 +441,11 @@ program process.exit(1); } - // Keep blue-green slots initialized for both foreground and daemonized start. - await migrateToBlueGreen((msg) => console.log(msg), { allowRemoteBootstrap: false }); + // In a local monorepo checkout, prefer direct execution during development. + // Blue-green slot bootstrap is mainly for installed/runtime environments. + if (isStandaloneInstall()) { + await migrateToBlueGreen((msg) => console.log(msg), { allowRemoteBootstrap: false }); + } if (opts.foreground) { await runForegroundSupervisor(); @@ -1116,6 +1126,223 @@ openclawCmd } }); +// ─── dkg ccl ──────────────────────────────────────────────────────── + +const cclCmd = program + .command('ccl') + .description('Manage paranet-scoped CCL policies'); + +const cclPolicyCmd = cclCmd + .command('policy') + .description('Publish, approve, list, and resolve CCL policies'); + +cclPolicyCmd + .command('publish ') + .description('Publish a CCL policy proposal into the ontology graph') + .requiredOption('--name ', 'Policy name') + .requiredOption('--version ', 'Policy version') + .requiredOption('--file ', 'Path to canonical policy file') + .option('--description ', 'Description of the policy') + .option('--context-type ', 'Optional stricter context override scope') + .option('--language ', 'Policy language identifier', 'ccl/v0.1') + .option('--format ', 'Canonical policy format', 'canonical-yaml') + .action(async (paranetId: string, opts: ActionOpts) => { + try { + const client = await ApiClient.connect(); + const content = readFileSync(opts.file, 'utf8'); + const result = await client.publishCclPolicy({ + paranetId, + name: opts.name, + version: opts.version, + content, + description: opts.description, + contextType: opts.contextType, + language: opts.language, + format: opts.format, + }); + console.log(`Policy published:`); + console.log(` URI: ${result.policyUri}`); + console.log(` Hash: ${result.hash}`); + console.log(` Status: ${result.status}`); + } catch (err: any) { + console.error(err.message); + process.exit(1); + } + }); + +cclPolicyCmd + .command('approve ') + .description('Approve a published CCL policy for a paranet or context override') + .option('--context-type ', 'Optional stricter context override scope') + .action(async (paranetId: string, policyUri: string, opts: ActionOpts) => { + try { + const client = await ApiClient.connect(); + const result = await client.approveCclPolicy({ paranetId, policyUri, contextType: opts.contextType }); + console.log(`Policy approved:`); + console.log(` Policy: ${result.policyUri}`); + console.log(` Binding: ${result.bindingUri}`); + if (result.contextType) console.log(` Context: ${result.contextType}`); + console.log(` Approved: ${result.approvedAt}`); + } catch (err: any) { + console.error(err.message); + process.exit(1); + } + }); + +cclPolicyCmd + .command('list') + .description('List known CCL policies') + .option('--paranet ', 'Filter by paranet id') + .option('--name ', 'Filter by policy name') + .option('--context-type ', 'Filter by context type') + .option('--status ', 'Filter by status') + .option('--include-body', 'Include policy body in output') + .action(async (opts: ActionOpts) => { + try { + const client = await ApiClient.connect(); + const { policies } = await client.listCclPolicies({ + paranetId: opts.paranet, + name: opts.name, + contextType: opts.contextType, + status: opts.status, + includeBody: !!opts.includeBody, + }); + if (policies.length === 0) { + console.log('No CCL policies found.'); + return; + } + for (const policy of policies) { + console.log(`${policy.name}@${policy.version} ${policy.policyUri}`); + console.log(` Paranet: ${policy.paranetId}`); + console.log(` Status: ${policy.status}${policy.isActiveDefault ? ' (active default)' : ''}`); + if (policy.contextType) console.log(` Context: ${policy.contextType}`); + if (policy.activeContexts?.length) console.log(` Active in contexts: ${policy.activeContexts.join(', ')}`); + console.log(` Hash: ${policy.hash}`); + if (policy.description) console.log(` Desc: ${policy.description}`); + if (opts.includeBody && policy.body) console.log(` Body:\n${policy.body}`); + } + } catch (err: any) { + console.error(err.message); + process.exit(1); + } + }); + +cclPolicyCmd + .command('resolve ') + .description('Resolve the active approved policy for a paranet and policy name') + .requiredOption('--name ', 'Policy name') + .option('--context-type ', 'Optional stricter context override scope') + .option('--include-body', 'Include policy body in output') + .action(async (paranetId: string, opts: ActionOpts) => { + try { + const client = await ApiClient.connect(); + const { policy } = await client.resolveCclPolicy({ + paranetId, + name: opts.name, + contextType: opts.contextType, + includeBody: !!opts.includeBody, + }); + if (!policy) { + console.log('No approved policy found for that scope.'); + return; + } + console.log(`Resolved policy:`); + console.log(` URI: ${policy.policyUri}`); + console.log(` Name: ${policy.name}@${policy.version}`); + console.log(` Paranet: ${policy.paranetId}`); + console.log(` Hash: ${policy.hash}`); + if (policy.contextType) console.log(` Context: ${policy.contextType}`); + if (policy.body) console.log(` Body:\n${policy.body}`); + } catch (err: any) { + console.error(err.message); + process.exit(1); + } + }); + +cclCmd + .command('eval ') + .description('Resolve the approved CCL policy for a paranet and evaluate it against facts') + .requiredOption('--name ', 'Policy name') + .option('--context-type ', 'Optional stricter context override scope') + .option('--case ', 'YAML/JSON file with { facts, context? }') + .option('--facts-file ', 'YAML/JSON file containing facts array') + .option('--publish-result', 'Publish the evaluation output back into the paranet as typed records') + .option('--view ', 'Declared view, for example accepted') + .option('--snapshot-id ', 'Snapshot identifier') + .option('--scope-ual ', 'Scope UAL for evaluation') + .action(async (paranetId: string, opts: ActionOpts) => { + try { + const client = await ApiClient.connect(); + let payload: { facts: Array<[string, ...unknown[]]>; view?: string; snapshotId?: string; scopeUal?: string } | null = null; + + if (opts.case) { + const parsed = loadStructuredFile(opts.case) as any; + payload = { + facts: parsed?.facts ?? [], + view: opts.view ?? parsed?.context?.view, + snapshotId: opts.snapshotId ?? parsed?.context?.snapshot_id, + scopeUal: opts.scopeUal ?? parsed?.context?.scope_ual, + }; + } else if (opts.factsFile) { + const parsed = loadStructuredFile(opts.factsFile) as any; + payload = { + facts: Array.isArray(parsed) ? parsed : parsed?.facts ?? [], + view: opts.view, + snapshotId: opts.snapshotId, + scopeUal: opts.scopeUal, + }; + } + + if (!payload || !Array.isArray(payload.facts) || payload.facts.length === 0) { + throw new Error('Provide --case or --facts-file with a non-empty facts array'); + } + + const result = await client.evaluateCclPolicy({ + paranetId, + name: opts.name, + contextType: opts.contextType, + facts: payload.facts, + view: payload.view, + snapshotId: payload.snapshotId, + scopeUal: payload.scopeUal, + publishResult: !!opts.publishResult, + }); + + console.log(JSON.stringify(result, null, 2)); + } catch (err: any) { + console.error(err.message); + process.exit(1); + } + }); + +cclCmd + .command('results ') + .description('List published CCL evaluation results in a paranet') + .option('--policy-uri ', 'Filter by evaluated policy URI') + .option('--snapshot-id ', 'Filter by snapshot id') + .option('--view ', 'Filter by view') + .option('--context-type ', 'Filter by context type') + .option('--result-kind ', 'Filter by result kind: derived or decision') + .option('--result-name ', 'Filter by result predicate/decision name') + .action(async (paranetId: string, opts: ActionOpts) => { + try { + const client = await ApiClient.connect(); + const { evaluations } = await client.listCclEvaluations({ + paranetId, + policyUri: opts.policyUri, + snapshotId: opts.snapshotId, + view: opts.view, + contextType: opts.contextType, + resultKind: opts.resultKind, + resultName: opts.resultName, + }); + console.log(JSON.stringify({ evaluations }, null, 2)); + } catch (err: any) { + console.error(err.message); + process.exit(1); + } + }); + // ─── dkg index ────────────────────────────────────────────────────── program diff --git a/packages/cli/src/daemon.ts b/packages/cli/src/daemon.ts index 292779c11..fae5b5509 100644 --- a/packages/cli/src/daemon.ts +++ b/packages/cli/src/daemon.ts @@ -2087,6 +2087,87 @@ async function handleRequest( return jsonResponse(res, 200, { id, exists }); } + // POST /api/ccl/policy/publish + if (req.method === 'POST' && path === '/api/ccl/policy/publish') { + const body = await readBody(req, SMALL_BODY_BYTES * 4); + const { paranetId, name, version, content, description, contextType, language, format } = JSON.parse(body); + if (!paranetId || !name || !version || !content) { + return jsonResponse(res, 400, { error: 'Missing required fields: paranetId, name, version, content' }); + } + const result = await agent.publishCclPolicy({ paranetId, name, version, content, description, contextType, language, format }); + return jsonResponse(res, 200, result); + } + + // POST /api/ccl/policy/approve + if (req.method === 'POST' && path === '/api/ccl/policy/approve') { + const body = await readBody(req, SMALL_BODY_BYTES); + const { paranetId, policyUri, contextType } = JSON.parse(body); + if (!paranetId || !policyUri) { + return jsonResponse(res, 400, { error: 'Missing required fields: paranetId, policyUri' }); + } + const result = await agent.approveCclPolicy({ paranetId, policyUri, contextType }); + return jsonResponse(res, 200, result); + } + + // GET /api/ccl/policy/list + if (req.method === 'GET' && path === '/api/ccl/policy/list') { + const policies = await agent.listCclPolicies({ + paranetId: url.searchParams.get('paranetId') ?? undefined, + name: url.searchParams.get('name') ?? undefined, + contextType: url.searchParams.get('contextType') ?? undefined, + status: url.searchParams.get('status') ?? undefined, + includeBody: url.searchParams.get('includeBody') === 'true', + }); + return jsonResponse(res, 200, { policies }); + } + + // GET /api/ccl/policy/resolve?paranetId=&name=&contextType= + if (req.method === 'GET' && path === '/api/ccl/policy/resolve') { + const paranetId = url.searchParams.get('paranetId'); + const name = url.searchParams.get('name'); + if (!paranetId || !name) { + return jsonResponse(res, 400, { error: 'Missing required query params: paranetId, name' }); + } + const policy = await agent.resolveCclPolicy({ + paranetId, + name, + contextType: url.searchParams.get('contextType') ?? undefined, + includeBody: url.searchParams.get('includeBody') === 'true', + }); + return jsonResponse(res, 200, { policy }); + } + + // POST /api/ccl/eval + if (req.method === 'POST' && path === '/api/ccl/eval') { + const body = await readBody(req, SMALL_BODY_BYTES * 8); + const { paranetId, name, facts, contextType, view, snapshotId, scopeUal, publishResult } = JSON.parse(body); + if (!paranetId || !name || !Array.isArray(facts)) { + return jsonResponse(res, 400, { error: 'Missing required fields: paranetId, name, facts[]' }); + } + const result = publishResult + ? await agent.evaluateAndPublishCclPolicy({ paranetId, name, facts, contextType, view, snapshotId, scopeUal }) + : await agent.evaluateCclPolicy({ paranetId, name, facts, contextType, view, snapshotId, scopeUal }); + return jsonResponse(res, 200, result); + } + + // GET /api/ccl/results?paranetId=&... + if (req.method === 'GET' && path === '/api/ccl/results') { + const paranetId = url.searchParams.get('paranetId'); + if (!paranetId) { + return jsonResponse(res, 400, { error: 'Missing required query param: paranetId' }); + } + const evaluations = await agent.listCclEvaluations({ + paranetId, + policyUri: url.searchParams.get('policyUri') ?? undefined, + snapshotId: url.searchParams.get('snapshotId') ?? undefined, + view: url.searchParams.get('view') ?? undefined, + contextType: url.searchParams.get('contextType') ?? undefined, + resultKind: (url.searchParams.get('resultKind') as 'derived' | 'decision' | null) ?? undefined, + resultName: url.searchParams.get('resultName') ?? undefined, + }); + return jsonResponse(res, 200, { evaluations }); + } + // GET /api/wallets (list addresses only) if (req.method === 'GET' && (path === '/api/wallet' || path === '/api/wallets')) { return jsonResponse(res, 200, { diff --git a/packages/cli/test/api-client.test.ts b/packages/cli/test/api-client.test.ts index ddc35dfd1..42826ab55 100644 --- a/packages/cli/test/api-client.test.ts +++ b/packages/cli/test/api-client.test.ts @@ -74,6 +74,18 @@ describe('ApiClient', () => { const [url] = (globalThis.fetch as ReturnType).mock.calls[0]; expect(url).toContain('my%20paranet'); }); + + it('listCclPolicies() builds query string from filters', async () => { + globalThis.fetch = mockFetchOk({ policies: [] }); + await client.listCclPolicies({ paranetId: 'ops', name: 'incident', contextType: 'review', includeBody: true }); + + const [url] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toContain('/api/ccl/policy/list?'); + expect(url).toContain('paranetId=ops'); + expect(url).toContain('name=incident'); + expect(url).toContain('contextType=review'); + expect(url).toContain('includeBody=true'); + }); }); describe('POST endpoints', () => { @@ -109,6 +121,51 @@ describe('ApiClient', () => { expect(body.sparql).toBe('SELECT * { ?s ?p ?o }'); expect(body.paranetId).toBe('my-paranet'); }); + + it('publishCclPolicy() posts policy payload', async () => { + globalThis.fetch = mockFetchOk({ policyUri: 'urn:policy', hash: 'sha256:abc', status: 'proposed' }); + await client.publishCclPolicy({ paranetId: 'ops', name: 'incident', version: '0.1.0', content: 'rules: []' }); + + const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe(`http://127.0.0.1:${PORT}/api/ccl/policy/publish`); + const body = JSON.parse(opts.body); + expect(body.paranetId).toBe('ops'); + expect(body.name).toBe('incident'); + }); + + it('approveCclPolicy() posts approval payload', async () => { + globalThis.fetch = mockFetchOk({ policyUri: 'urn:policy', bindingUri: 'urn:binding', approvedAt: 'now' }); + await client.approveCclPolicy({ paranetId: 'ops', policyUri: 'urn:policy', contextType: 'incident_review' }); + + const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe(`http://127.0.0.1:${PORT}/api/ccl/policy/approve`); + const body = JSON.parse(opts.body); + expect(body.contextType).toBe('incident_review'); + }); + + it('evaluateCclPolicy() posts evaluation payload', async () => { + globalThis.fetch = mockFetchOk({ policy: { name: 'incident' }, factSetHash: 'sha256:abc', result: { derived: {}, decisions: {} } }); + await client.evaluateCclPolicy({ paranetId: 'ops', name: 'incident', facts: [['claim', 'c1']], snapshotId: 'snap-1', publishResult: true }); + + const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe(`http://127.0.0.1:${PORT}/api/ccl/eval`); + const body = JSON.parse(opts.body); + expect(body.facts).toEqual([['claim', 'c1']]); + expect(body.snapshotId).toBe('snap-1'); + expect(body.publishResult).toBe(true); + }); + + it('listCclEvaluations() builds result query string', async () => { + globalThis.fetch = mockFetchOk({ evaluations: [] }); + await client.listCclEvaluations({ paranetId: 'ops', snapshotId: 'snap-2', resultKind: 'decision', resultName: 'propose_accept' }); + + const [url] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toContain('/api/ccl/results?'); + expect(url).toContain('paranetId=ops'); + expect(url).toContain('snapshotId=snap-2'); + expect(url).toContain('resultKind=decision'); + expect(url).toContain('resultName=propose_accept'); + }); }); describe('messages() query string building', () => { diff --git a/packages/core/src/genesis.ts b/packages/core/src/genesis.ts index f6f1865b2..c15cfd704 100644 --- a/packages/core/src/genesis.ts +++ b/packages/core/src/genesis.ts @@ -171,6 +171,33 @@ export const DKG_ONTOLOGY = { DKG_CREATED_AT: `${DKG}createdAt`, DKG_GOSSIP_TOPIC: `${DKG}gossipTopic`, DKG_REPLICATION_POLICY: `${DKG}replicationPolicy`, + DKG_CCL_POLICY: `${DKG}CCLPolicy`, + DKG_POLICY_BINDING: `${DKG}PolicyBinding`, + DKG_POLICY_APPLIES_TO_PARANET: `${DKG}appliesToParanet`, + DKG_POLICY_VERSION: `${DKG}policyVersion`, + DKG_POLICY_LANGUAGE: `${DKG}policyLanguage`, + DKG_POLICY_FORMAT: `${DKG}policyFormat`, + DKG_POLICY_HASH: `${DKG}policyHash`, + DKG_POLICY_BODY: `${DKG}policyBody`, + DKG_POLICY_STATUS: `${DKG}policyStatus`, + DKG_POLICY_CONTEXT_TYPE: `${DKG}contextType`, + DKG_ACTIVE_POLICY: `${DKG}activePolicy`, + DKG_APPROVED_BY: `${DKG}approvedBy`, + DKG_APPROVED_AT: `${DKG}approvedAt`, + DKG_CCL_EVALUATION: `${DKG}CCLEvaluation`, + DKG_CCL_RESULT_ENTRY: `${DKG}CCLResultEntry`, + DKG_EVALUATED_POLICY: `${DKG}evaluatedPolicy`, + DKG_FACT_SET_HASH: `${DKG}factSetHash`, + DKG_SCOPE_UAL: `${DKG}scopeUal`, + DKG_VIEW: `${DKG}view`, + DKG_SNAPSHOT_ID: `${DKG}snapshotId`, + DKG_RESULT_KIND: `${DKG}resultKind`, + DKG_RESULT_NAME: `${DKG}resultName`, + DKG_HAS_RESULT: `${DKG}hasResult`, + DKG_CCL_RESULT_ARG: `${DKG}CCLResultArg`, + DKG_HAS_RESULT_ARG: `${DKG}hasResultArg`, + DKG_RESULT_ARG_INDEX: `${DKG}resultArgIndex`, + DKG_RESULT_ARG_VALUE: `${DKG}resultArgValue`, ERC8004_CAPABILITY: `${ERC8004}Capability`, ERC8004_CAPABILITIES: `${ERC8004}capabilities`, PROV_GENERATED_BY: `${PROV}wasGeneratedBy`, diff --git a/packages/core/test/genesis.test.ts b/packages/core/test/genesis.test.ts index c3ef78c07..6d79d2564 100644 --- a/packages/core/test/genesis.test.ts +++ b/packages/core/test/genesis.test.ts @@ -136,6 +136,22 @@ describe('DKG_ONTOLOGY', () => { for (const [key, expectedUri] of Object.entries(expectedUris)) { expect((DKG_ONTOLOGY as Record)[key]).toBe(expectedUri); } + + const cclKeys = [ + 'DKG_CCL_POLICY', 'DKG_POLICY_BINDING', 'DKG_POLICY_APPLIES_TO_PARANET', + 'DKG_POLICY_VERSION', 'DKG_POLICY_LANGUAGE', 'DKG_POLICY_FORMAT', + 'DKG_POLICY_HASH', 'DKG_POLICY_BODY', 'DKG_POLICY_STATUS', + 'DKG_POLICY_CONTEXT_TYPE', 'DKG_ACTIVE_POLICY', 'DKG_APPROVED_BY', + 'DKG_APPROVED_AT', 'DKG_CCL_EVALUATION', 'DKG_CCL_RESULT_ENTRY', + 'DKG_EVALUATED_POLICY', 'DKG_FACT_SET_HASH', 'DKG_SCOPE_UAL', + 'DKG_VIEW', 'DKG_SNAPSHOT_ID', 'DKG_RESULT_KIND', 'DKG_RESULT_NAME', + 'DKG_HAS_RESULT', 'DKG_CCL_RESULT_ARG', + 'DKG_HAS_RESULT_ARG', 'DKG_RESULT_ARG_INDEX', 'DKG_RESULT_ARG_VALUE', + ]; + for (const key of cclKeys) { + expect((DKG_ONTOLOGY as Record)[key]).toBeDefined(); + expect((DKG_ONTOLOGY as Record)[key]).toMatch(/^https?:\/\//); + } }); it('all values are unique URIs', () => { From 8e0a3f2d1852e098b500d0a2878a50cc831db7cc Mon Sep 17 00:00:00 2001 From: Viktor Pelle Date: Tue, 24 Mar 2026 12:38:48 +0100 Subject: [PATCH 2/3] add snapshot-backed CCL resolution and e2e coverage --- ccl_v0_1/IMPLEMENTATION_TASKS.md | 290 ++++++++++++++++++ ccl_v0_1/LANGUAGE_SPEC.md | 20 +- ccl_v0_1/README.md | 41 +++ ccl_v0_1/SURFACE_SYNTAX.md | 100 +++--- ccl_v0_1/evaluator/surface_compiler.js | 121 ++++++++ ccl_v0_1/examples/context_corroboration.ccl | 52 ++-- ccl_v0_1/examples/owner_assertion.ccl | 14 +- ccl_v0_1/package.json | 2 +- .../context_corroboration_readable.yaml | 52 ++++ .../policies/owner_assertion_readable.yaml | 18 ++ ccl_v0_1/tests/TEST_RESULTS.md | 4 +- .../cases/09_owner_valid_readable_policy.yaml | 18 ++ ...context_quorum_accept_readable_policy.yaml | 31 ++ ccl_v0_1/tests/run_all_tests.js | 18 +- ccl_v0_1/tests/run_inline_surface_tests.js | 276 +++++++++++++++++ ccl_v0_1/tests/run_surface_tests.js | 55 ++++ packages/adapter-openclaw/skills/ccl/SKILL.md | 1 + packages/agent/src/ccl-evaluation-publish.ts | 6 + packages/agent/src/ccl-evaluator.ts | 84 +++++ packages/agent/src/ccl-fact-resolution.ts | 206 +++++++++++++ packages/agent/src/dkg-agent.ts | 87 +++++- packages/agent/src/index.ts | 10 + packages/agent/test/agent.test.ts | 259 +++++++++++++++- packages/agent/test/e2e-flows.test.ts | 151 +++++++++ packages/cli/src/api-client.ts | 5 +- packages/cli/src/daemon.ts | 12 +- packages/core/src/genesis.ts | 3 + packages/core/test/genesis.test.ts | 3 +- .../test/e2e/game-ccl-e2e.test.ts | 183 +++++++++++ .../origin-trail-game/test/e2e/helpers.ts | 51 +++ 30 files changed, 2057 insertions(+), 116 deletions(-) create mode 100644 ccl_v0_1/IMPLEMENTATION_TASKS.md create mode 100644 ccl_v0_1/evaluator/surface_compiler.js create mode 100644 ccl_v0_1/policies/context_corroboration_readable.yaml create mode 100644 ccl_v0_1/policies/owner_assertion_readable.yaml create mode 100644 ccl_v0_1/tests/cases/09_owner_valid_readable_policy.yaml create mode 100644 ccl_v0_1/tests/cases/10_context_quorum_accept_readable_policy.yaml create mode 100644 ccl_v0_1/tests/run_inline_surface_tests.js create mode 100644 ccl_v0_1/tests/run_surface_tests.js create mode 100644 packages/agent/src/ccl-fact-resolution.ts create mode 100644 packages/origin-trail-game/test/e2e/game-ccl-e2e.test.ts diff --git a/ccl_v0_1/IMPLEMENTATION_TASKS.md b/ccl_v0_1/IMPLEMENTATION_TASKS.md new file mode 100644 index 000000000..47ad04a4a --- /dev/null +++ b/ccl_v0_1/IMPLEMENTATION_TASKS.md @@ -0,0 +1,290 @@ +# CCL Implementation Tasks + +This document turns the current CCL review gaps into concrete implementation tasks. It is intended as a practical follow-up queue for moving CCL from a good v0.1 foundation toward production-safe behavior. + +## Priority Order + +1. Snapshot-backed fact resolution +2. Explicit policy lifecycle controls +3. Peer-verifiable approvals +4. Evaluator resource limits +5. Deterministic binding identifiers + +The remaining items are already partly addressed on this branch: + +- policy content validation before publish/approve +- duplicate republish protection for the same `paranetId + name + version` +- reference-evaluator vs agent-evaluator parity tests +- surface syntax compiler support + +## Task 1: Snapshot-Backed Fact Resolution + +### Problem + +CCL evaluation is currently deterministic only for the policy body plus the caller-supplied fact tuples. The API records `snapshotId`, `view`, and `scopeUal`, but does not yet resolve or verify facts against DKG state. + +### Goal + +Make `same policy + same snapshot + same resolver version` produce the same fact set on every node. + +### Proposed solution + +- Add `resolveFactsFromSnapshot({ paranetId, snapshotId, view, scopeUal?, policyName?, contextType? })` +- Implement a canonical extraction layer that: + - queries the relevant paranet graph + - applies a resolver profile for the policy family or context type + - emits canonical `CclFactTuple[]` + - sorts tuples deterministically before hashing +- Extend evaluation so callers can either: + - pass `facts` directly for manual/dev mode, or + - omit `facts` and let the agent resolve them from the DKG +- Record extra provenance on evaluations: + - `factResolverVersion` + - `factQueryHash` + - `factResolutionMode` (`manual` or `snapshot-resolved`) + +### Deliverables + +- agent API for snapshot-backed fact resolution +- one concrete resolver for existing bundled policy families +- tests proving two nodes resolve the same facts for the same snapshot +- docs clarifying manual vs snapshot-resolved evaluation modes + +### Notes + +Avoid pretending arbitrary RDF can be evaluated directly. Resolver profiles should define how RDF is projected into canonical CCL facts. + +## Task 2: Policy Revocation and Deactivation + +### Problem + +Policies can be published and approved, but not explicitly revoked, deactivated, or superseded. + +### Goal + +Allow paranet owners to retire policies cleanly and make resolution semantics explicit. + +### Proposed solution + +- Add a revoke flow: + - `revokeCclPolicy({ paranetId, policyUri, contextType? })` + - CLI/API endpoint: `dkg ccl policy revoke` +- Extend lifecycle state with explicit binding or policy statuses: + - `proposed` + - `approved` + - `revoked` + - optionally `superseded` +- Update resolution rules to choose: + - latest non-revoked binding for exact context + - otherwise latest non-revoked default binding +- Preserve old bindings for auditability, but mark them inactive in a machine-readable way + +### Deliverables + +- revoke API and CLI command +- updated resolver semantics +- tests covering default bindings, context bindings, and revoked bindings +- docs describing how supersession works + +## Task 3: Peer-Verifiable Approval Ingestion + +### Problem + +Approval is validated by the approving node before publish, but peers currently trust the approval quads they receive. + +### Goal + +Prevent a modified node from gossiping fake approvals that other nodes accept without verification. + +### Proposed solution + +- Short term: + - validate approval bindings on ingest against locally known paranet owner state + - reject bindings where `approvedBy` is not the current owner for the paranet +- Long term: + - introduce signed approval envelopes + - sign the approval payload with the paranet owner key + - verify signatures before accepting approval triples into the ontology store + +### Deliverables + +- gossip-ingest validation for approval bindings +- failure logs and rejection reasons for invalid approvals +- design doc or envelope schema for signed approvals +- tests showing forged approvals are rejected by peer nodes + +### Notes + +Signed approvals are the better long-term model because they avoid relying on local trust in the sender. + +## Task 4: Evaluator Resource Limits + +### Problem + +The evaluator caps fixpoint rounds, but not fact volume, join explosion, runtime, or memory growth. + +### Goal + +Bound evaluation cost so policies cannot accidentally or intentionally exhaust node resources. + +### Proposed solution + +- Extend evaluator config with hard limits such as: + - `maxFacts` + - `maxBindings` + - `maxDerivedTuples` + - `maxConditionMatches` + - `maxRounds` + - `deadlineMs` +- Make limit failures explicit and deterministic, for example: + - `CCL evaluation exceeded maxBindings` +- Thread these limits through the agent API and, later, optionally the policy envelope + +### Deliverables + +- configurable evaluator limits in `packages/agent/src/ccl-evaluator.ts` +- unit tests for limit-triggered failures +- docs describing safe defaults and operator tuning + +### Notes + +For network determinism, nodes that are expected to agree should run with compatible limit settings. + +## Task 5: Deterministic Binding Identifiers + +### Problem + +Policy binding URIs currently use `Date.now()`, so the same logical approval can produce different identifiers on different nodes. + +### Goal + +Make identifiers reproducible and reduce time-based ambiguity. + +### Proposed solution + +- Replace time-derived binding URIs with a hash-derived scheme based on stable fields such as: + - `paranetId` + - `policyUri` + - `contextType` + - `approvedBy` + - `approvedAt` +- Alternatively, make bindings deterministic per scope and express changes through status updates instead of minting a fresh URI per approval + +### Deliverables + +- updated `policyBindingUriFor(...)` +- migration strategy for existing bindings +- tests proving stable URI generation + +## Task 6: Stronger Policy Validation and Linting + +### Problem + +Basic validation now exists, but there is room for stronger structural and semantic checks. + +### Goal + +Catch bad policies earlier and make authoring errors cheaper to diagnose. + +### Proposed solution + +- Add a dedicated validator/linter entry point: + - `validateCclPolicy(content)` for strict validation + - optional linter warnings for quality issues +- Add checks for: + - duplicate rule names + - duplicate decision names + - malformed params + - unknown top-level keys + - empty or unreachable clauses where detectable +- Add CLI support: + - `dkg ccl validate policy.yaml` + +### Deliverables + +- validator module expansion +- CLI/API validation endpoint +- author-facing error messages with precise failure reasons + +## Task 7: Keep Cross-Evaluator Parity in CI + +### Problem + +The reference evaluator and the agent evaluator implement the same semantics independently and can drift over time. + +### Goal + +Ensure both evaluators produce identical outputs for the shared corpus. + +### Proposed solution + +- Keep the bundled parity test in `packages/agent/test/agent.test.ts` +- Optionally extract it into a dedicated `ccl-parity.test.ts` +- Run parity coverage in CI on every CCL change +- Later add randomized small-case fuzz coverage once the policy grammar stabilizes more + +### Deliverables + +- stable CI parity test +- future fuzz-testing backlog item + +## Task 8: Clarify Supported Inputs in CLI and Docs + +### Problem + +The repository now includes a surface compiler, but user-facing flows still need to be explicit about which input formats are accepted and when compilation occurs. + +### Goal + +Prevent confusion about whether users should submit `.ccl` or canonical YAML. + +### Proposed solution + +- document current accepted input formats in CLI help and README +- if CLI support is added, either: + - compile `.ccl` to canonical YAML on input, or + - reject `.ccl` with a helpful error until compilation is wired into the CLI path + +### Deliverables + +- updated CLI help text +- updated README examples +- optional `.ccl` input support in publish commands + +## Task 9: Production Mode Switch for Evaluation + +### Problem + +Manual fact mode is useful for tests and demos, but risky if used accidentally in production contexts. + +### Goal + +Make the safe path explicit. + +### Proposed solution + +- add evaluation modes: + - `manual` + - `snapshot-resolved` +- add a config flag or API option that can disable manual facts for production nodes +- mark published evaluations with the chosen mode + +### Deliverables + +- evaluation mode field in results +- node/operator option to disallow manual fact evaluation +- tests for both allowed and denied modes + +## Suggested Execution Sequence + +If these tasks are implemented incrementally, the recommended order is: + +1. snapshot-backed fact resolution +2. policy revocation/deactivation +3. peer-verifiable approvals +4. evaluator resource limits +5. deterministic binding identifiers +6. stronger validator/linter UX +7. CI parity hardening +8. CLI/docs input clarity +9. production-mode safety switch diff --git a/ccl_v0_1/LANGUAGE_SPEC.md b/ccl_v0_1/LANGUAGE_SPEC.md index fd9e6bd29..8bd4f1fc4 100644 --- a/ccl_v0_1/LANGUAGE_SPEC.md +++ b/ccl_v0_1/LANGUAGE_SPEC.md @@ -144,15 +144,15 @@ The reference evaluator uses a canonical YAML representation. ```yaml - name: corroborated - params: [C] + params: [Claim] all: - - atom: {pred: claim, args: ["$C"]} + - atom: {pred: claim, args: ["$Claim"]} - count_distinct: - vars: [E] + vars: [Evidence] where: - - atom: {pred: supports, args: ["$E", "$C"]} - - atom: {pred: evidence_view, args: ["$E", "accepted"]} - - atom: {pred: independent, args: ["$E"]} + - atom: {pred: supports, args: ["$Evidence", "$Claim"]} + - atom: {pred: evidence_view, args: ["$Evidence", "accepted"]} + - atom: {pred: independent, args: ["$Evidence"]} op: ">=" value: 2 ``` @@ -161,9 +161,9 @@ The reference evaluator uses a canonical YAML representation. ```yaml - name: propose_accept - params: [C] + params: [Claim] all: - - atom: {pred: promotable, args: ["$C"]} + - atom: {pred: promotable, args: ["$Claim"]} ``` --- @@ -177,6 +177,8 @@ An `atom` joins against either: Variables begin with `$`. +Use descriptive names such as `$Claim`, `$Evidence`, `$Agent`, or `$Epoch` in human-authored policies. + Example: ```yaml @@ -196,7 +198,7 @@ Example: ```yaml count_distinct: - vars: [E] + vars: [Evidence] where: ... op: ">=" value: 2 diff --git a/ccl_v0_1/README.md b/ccl_v0_1/README.md index e9ddab8d6..0160c36f6 100644 --- a/ccl_v0_1/README.md +++ b/ccl_v0_1/README.md @@ -22,6 +22,41 @@ CCL is for evaluating questions like: CCL is **not** a general reasoning engine and **not** an LLM-facing tool language. +## Current v0.1 boundary + +CCL v0.1 evaluation is deterministic with respect to the policy body and the fact tuples supplied to the evaluator. + +- facts may still be caller-provided for manual/dev evaluation +- the agent now also supports snapshot-resolved evaluation for bundled policy families via a canonical input-fact resolver +- `snapshotId`, `view`, and `scopeUal` are recorded as evaluation context metadata +- snapshot-backed resolution currently works only for resolver profiles that know how to project RDF into canonical CCL facts; arbitrary RDF is not evaluated directly + +That means `factSetHash` gives replayability and auditability for a concrete evaluation input, while snapshot-backed determinism depends on using an explicit resolver profile such as the canonical input-fact resolver. + +## Snapshot-resolved facts + +For bundled policy families such as `owner_assertion` and `context_corroboration`, the agent can resolve facts directly from snapshot-tagged RDF input facts instead of requiring the caller to provide tuples manually. + +Current canonical resolver expectations: + +- each fact is stored as a `cclf:InputFact` node +- the predicate is stored in `cclf:predicate` +- arguments are stored in `cclf:arg0`, `cclf:arg1`, ... +- each argument value is JSON-encoded in the RDF literal so strings, numbers, and booleans round-trip correctly +- `dkg:snapshotId`, `dkg:view`, and optional `dkg:scopeUal` are used to select the fact set + +The current resolver vocabulary is: + +- `cclf:InputFact` = `https://example.org/ccl-fact#InputFact` +- `cclf:predicate` = `https://example.org/ccl-fact#predicate` +- `cclf:argN` = `https://example.org/ccl-fact#argN` + +Published evaluations now also record: + +- `factResolutionMode` +- `factResolverVersion` +- `factQueryHash` + ## Trustless-network constraints CCL v0.1 is intentionally restricted: @@ -56,6 +91,8 @@ The reference evaluator consumes a canonical YAML policy format. This is deliber - nodes should evaluate a normalized canonical form - canonical form is easier to serialize, audit, hash, and replay +For human and agent authoring, prefer descriptive variable names in surface CCL such as `Claim`, `Evidence`, `Agent`, `Epoch`, or `Contradiction` instead of short names like `C`, `E`, or `A`. + ## Running the tests ```bash @@ -92,6 +129,10 @@ CCL produces two kinds of outputs: A decision is still **non-authoritative** until a normal DKG `PUBLISH` introduces it as a typed transition into shared state. +## Current lifecycle limitation + +CCL v0.1 supports `publish -> approve -> resolve -> evaluate`, but does not yet include explicit policy revocation or deactivation. If multiple approvals exist for the same `paranetId + policy name + context`, resolution currently selects the most recently approved binding for that scope. + ## Included policies ### 1. `owner_assertion` diff --git a/ccl_v0_1/SURFACE_SYNTAX.md b/ccl_v0_1/SURFACE_SYNTAX.md index d27ba6449..1fc1349ea 100644 --- a/ccl_v0_1/SURFACE_SYNTAX.md +++ b/ccl_v0_1/SURFACE_SYNTAX.md @@ -13,38 +13,38 @@ The surface syntax is intentionally small. ```ccl policy context_corroboration v0.1.0 -rule corroborated(C): - claim(C) - count_distinct E where - supports(E, C) - evidence_view(E, accepted) - independent(E) +rule corroborated(Claim): + claim(Claim) + count_distinct Evidence where + supports(Evidence, Claim) + evidence_view(Evidence, accepted) + independent(Evidence) >= 2 - exists E where - supports(E, C) - evidence_view(E, accepted) - authority_class(E, vendor) - not exists C2 where - contradicts(C2, C) - accepted_status(C2, accepted) - -rule disputed(C): - claim(C) - exists C2 where - contradicts(C2, C) - accepted_status(C2, accepted) - -rule promotable(C): - corroborated(C) - claim_epoch(C, E) - quorum_epoch(incident_review, E) + exists Evidence where + supports(Evidence, Claim) + evidence_view(Evidence, accepted) + authority_class(Evidence, vendor) + not exists Contradiction where + contradicts(Contradiction, Claim) + accepted_status(Contradiction, accepted) + +rule disputed(Claim): + claim(Claim) + exists Contradiction where + contradicts(Contradiction, Claim) + accepted_status(Contradiction, accepted) + +rule promotable(Claim): + corroborated(Claim) + claim_epoch(Claim, Epoch) + quorum_epoch(incident_review, Epoch) quorum_reached(incident_review, 3, 4) -decision propose_accept(C): - promotable(C) +decision propose_accept(Claim): + promotable(Claim) -decision propose_reject(C): - disputed(C) +decision propose_reject(Claim): + disputed(Claim) ``` --- @@ -52,21 +52,21 @@ decision propose_reject(C): ## 2. Design notes ### Variables -- Uppercase identifiers are variables: `C`, `E` +- Uppercase identifiers are variables: `Claim`, `Evidence`, `Agent` - Lowercase identifiers are predicate names or constants: `claim`, `accepted`, `vendor` ### Rule heads A rule head defines a derived predicate: ```ccl -rule corroborated(C): +rule corroborated(Claim): ``` ### Decisions A decision head defines a named output that may later feed a normal publish flow: ```ccl -decision propose_accept(C): +decision propose_accept(Claim): ``` ### Condition blocks @@ -75,23 +75,23 @@ Every listed condition must hold. ### Exists ```ccl -exists E where - supports(E, C) - authority_class(E, vendor) +exists Evidence where + supports(Evidence, Claim) + authority_class(Evidence, vendor) ``` ### Not exists ```ccl -not exists C2 where - contradicts(C2, C) - accepted_status(C2, accepted) +not exists Contradiction where + contradicts(Contradiction, Claim) + accepted_status(Contradiction, accepted) ``` ### Count distinct ```ccl -count_distinct E where - supports(E, C) - independent(E) +count_distinct Evidence where + supports(Evidence, Claim) + independent(Evidence) >= 2 ``` @@ -104,25 +104,25 @@ A surface rule compiles into canonical YAML. Example: ```ccl -rule owner_asserted(C): - claim(C) - exists A where - owner_of(C, A) - signed_by(C, A) +rule owner_asserted(Claim): + claim(Claim) + exists Agent where + owner_of(Claim, Agent) + signed_by(Claim, Agent) ``` Compiles conceptually to: ```yaml - name: owner_asserted - params: [C] + params: [Claim] all: - - atom: {pred: claim, args: ["$C"]} + - atom: {pred: claim, args: ["$Claim"]} - exists: - vars: [A] + vars: [Agent] where: - - atom: {pred: owner_of, args: ["$C", "$A"]} - - atom: {pred: signed_by, args: ["$C", "$A"]} + - atom: {pred: owner_of, args: ["$Claim", "$Agent"]} + - atom: {pred: signed_by, args: ["$Claim", "$Agent"]} ``` --- diff --git a/ccl_v0_1/evaluator/surface_compiler.js b/ccl_v0_1/evaluator/surface_compiler.js new file mode 100644 index 000000000..0c8a80c92 --- /dev/null +++ b/ccl_v0_1/evaluator/surface_compiler.js @@ -0,0 +1,121 @@ +const fs = require('node:fs'); + +function loadSurfacePolicy(filePath) { + return compileSurfacePolicy(fs.readFileSync(filePath, 'utf8')); +} + +function compileSurfacePolicy(source) { + const lines = source.replace(/\r\n/g, '\n').split('\n'); + const policy = { kind: 'canonical_policy', rules: [], decisions: [] }; + let index = 0; + + while (index < lines.length) { + const raw = lines[index]; + const line = raw.trim(); + index += 1; + if (!line) continue; + + const policyMatch = line.match(/^policy\s+(\w+)\s+v([^\s]+)$/); + if (policyMatch) { + policy.policy = policyMatch[1]; + policy.version = policyMatch[2]; + continue; + } + + const headMatch = line.match(/^(rule|decision)\s+(\w+)\(([^)]*)\):$/); + if (!headMatch) { + throw new Error(`Unsupported CCL line: ${line}`); + } + + const kind = headMatch[1]; + const name = headMatch[2]; + const params = splitArgs(headMatch[3]); + const parsed = parseBlock(lines, index, 2); + index = parsed.nextIndex; + + const entry = { name, params, all: parsed.conditions }; + if (kind === 'rule') policy.rules.push(entry); + else policy.decisions.push(entry); + } + + return policy; +} + +function parseBlock(lines, startIndex, indent) { + const conditions = []; + let index = startIndex; + + while (index < lines.length) { + const raw = lines[index]; + if (!raw.trim()) { + index += 1; + continue; + } + + const currentIndent = raw.match(/^ */)[0].length; + if (currentIndent < indent) break; + if (currentIndent > indent) { + throw new Error(`Unexpected indentation: ${raw}`); + } + + const line = raw.trim(); + + if (line.startsWith('count_distinct ')) { + const match = line.match(/^count_distinct\s+(\w+)\s+where$/); + if (!match) throw new Error(`Invalid count_distinct syntax: ${line}`); + const nested = parseBlock(lines, index + 1, indent + 2); + const compareLine = lines[nested.nextIndex]?.trim(); + const compareMatch = compareLine?.match(/^(>=|<=|==|>|<)\s+(\d+)$/); + if (!compareMatch) throw new Error(`Expected comparator after count_distinct: ${compareLine ?? ''}`); + conditions.push({ + count_distinct: { + vars: [match[1]], + where: nested.conditions, + op: compareMatch[1], + value: Number(compareMatch[2]), + }, + }); + index = nested.nextIndex + 1; + continue; + } + + if (line.startsWith('exists ') || line.startsWith('not exists ')) { + const match = line.match(/^(exists|not exists)\s+(\w+)\s+where$/); + if (!match) throw new Error(`Invalid existential syntax: ${line}`); + const nested = parseBlock(lines, index + 1, indent + 2); + const key = match[1] === 'exists' ? 'exists' : 'not_exists'; + conditions.push({ [key]: { vars: [match[2]], where: nested.conditions } }); + index = nested.nextIndex; + continue; + } + + conditions.push({ atom: parseAtom(line) }); + index += 1; + } + + return { conditions, nextIndex: index }; +} + +function parseAtom(line) { + const match = line.match(/^(\w+)\((.*)\)$/); + if (!match) throw new Error(`Invalid atom syntax: ${line}`); + return { + pred: match[1], + args: splitArgs(match[2]).map(toCanonicalArg), + }; +} + +function splitArgs(value) { + return value.split(',').map((part) => part.trim()).filter(Boolean); +} + +function toCanonicalArg(value) { + if (/^[A-Z][A-Za-z0-9_]*$/.test(value)) return `$${value}`; + if (/^-?\d+$/.test(value)) return Number(value); + return value; +} + +module.exports = { + compileSurfacePolicy, + loadSurfacePolicy, +}; diff --git a/ccl_v0_1/examples/context_corroboration.ccl b/ccl_v0_1/examples/context_corroboration.ccl index 6d6f7164d..db6af37e2 100644 --- a/ccl_v0_1/examples/context_corroboration.ccl +++ b/ccl_v0_1/examples/context_corroboration.ccl @@ -1,34 +1,34 @@ policy context_corroboration v0.1.0 -rule corroborated(C): - claim(C) - count_distinct E where - supports(E, C) - evidence_view(E, accepted) - independent(E) +rule corroborated(Claim): + claim(Claim) + count_distinct Evidence where + supports(Evidence, Claim) + evidence_view(Evidence, accepted) + independent(Evidence) >= 2 - exists E where - supports(E, C) - evidence_view(E, accepted) - authority_class(E, vendor) - not exists C2 where - contradicts(C2, C) - accepted_status(C2, accepted) + exists Evidence where + supports(Evidence, Claim) + evidence_view(Evidence, accepted) + authority_class(Evidence, vendor) + not exists Contradiction where + contradicts(Contradiction, Claim) + accepted_status(Contradiction, accepted) -rule disputed(C): - claim(C) - exists C2 where - contradicts(C2, C) - accepted_status(C2, accepted) +rule disputed(Claim): + claim(Claim) + exists Contradiction where + contradicts(Contradiction, Claim) + accepted_status(Contradiction, accepted) -rule promotable(C): - corroborated(C) - claim_epoch(C, E) - quorum_epoch(incident_review, E) +rule promotable(Claim): + corroborated(Claim) + claim_epoch(Claim, Epoch) + quorum_epoch(incident_review, Epoch) quorum_reached(incident_review, 3, 4) -decision propose_accept(C): - promotable(C) +decision propose_accept(Claim): + promotable(Claim) -decision propose_reject(C): - disputed(C) +decision propose_reject(Claim): + disputed(Claim) diff --git a/ccl_v0_1/examples/owner_assertion.ccl b/ccl_v0_1/examples/owner_assertion.ccl index d04fbbe07..88e0dd308 100644 --- a/ccl_v0_1/examples/owner_assertion.ccl +++ b/ccl_v0_1/examples/owner_assertion.ccl @@ -1,10 +1,10 @@ policy owner_assertion v0.1.0 -rule owner_asserted(C): - claim(C) - exists A where - owner_of(C, A) - signed_by(C, A) +rule owner_asserted(Claim): + claim(Claim) + exists Agent where + owner_of(Claim, Agent) + signed_by(Claim, Agent) -decision propose_accept(C): - owner_asserted(C) +decision propose_accept(Claim): + owner_asserted(Claim) diff --git a/ccl_v0_1/package.json b/ccl_v0_1/package.json index 87133859b..260a341f2 100644 --- a/ccl_v0_1/package.json +++ b/ccl_v0_1/package.json @@ -3,7 +3,7 @@ "private": true, "type": "commonjs", "scripts": { - "test": "node tests/run_all_tests.js" + "test": "node tests/run_all_tests.js && node tests/run_surface_tests.js && node tests/run_inline_surface_tests.js" }, "dependencies": { "js-yaml": "^4.1.1" diff --git a/ccl_v0_1/policies/context_corroboration_readable.yaml b/ccl_v0_1/policies/context_corroboration_readable.yaml new file mode 100644 index 000000000..863287c14 --- /dev/null +++ b/ccl_v0_1/policies/context_corroboration_readable.yaml @@ -0,0 +1,52 @@ +policy: context_corroboration_readable +version: 0.1.0 +kind: canonical_policy +description: Deterministic corroboration and promotion with descriptive variable names. +rules: + - name: corroborated + params: [Claim] + all: + - atom: {pred: claim, args: ["$Claim"]} + - count_distinct: + vars: [Evidence] + where: + - atom: {pred: supports, args: ["$Evidence", "$Claim"]} + - atom: {pred: evidence_view, args: ["$Evidence", "accepted"]} + - atom: {pred: independent, args: ["$Evidence"]} + op: ">=" + value: 2 + - exists: + where: + - atom: {pred: supports, args: ["$Evidence", "$Claim"]} + - atom: {pred: evidence_view, args: ["$Evidence", "accepted"]} + - atom: {pred: authority_class, args: ["$Evidence", "vendor"]} + - not_exists: + where: + - atom: {pred: contradicts, args: ["$Contradiction", "$Claim"]} + - atom: {pred: accepted_status, args: ["$Contradiction", "accepted"]} + - name: disputed + params: [Claim] + all: + - atom: {pred: claim, args: ["$Claim"]} + - exists: + where: + - atom: {pred: contradicts, args: ["$Contradiction", "$Claim"]} + - atom: {pred: accepted_status, args: ["$Contradiction", "accepted"]} + - name: promotable + params: [Claim] + all: + - atom: {pred: corroborated, args: ["$Claim"]} + - exists: + where: + - atom: {pred: claim_epoch, args: ["$Claim", "$Epoch"]} + - atom: {pred: quorum_epoch, args: ["incident_review", "$Epoch"]} + - atom: {pred: quorum_reached, args: ["incident_review", 3, 4]} +decisions: + - name: propose_accept + params: [Claim] + all: + - atom: {pred: promotable, args: ["$Claim"]} + - name: propose_reject + params: [Claim] + all: + - atom: {pred: disputed, args: ["$Claim"]} diff --git a/ccl_v0_1/policies/owner_assertion_readable.yaml b/ccl_v0_1/policies/owner_assertion_readable.yaml new file mode 100644 index 000000000..fe03c20eb --- /dev/null +++ b/ccl_v0_1/policies/owner_assertion_readable.yaml @@ -0,0 +1,18 @@ +policy: owner_assertion_readable +version: 0.1.0 +kind: canonical_policy +description: Deterministic owner-scope assertion adjudication with descriptive variable names. +rules: + - name: owner_asserted + params: [Claim] + all: + - atom: {pred: claim, args: ["$Claim"]} + - exists: + where: + - atom: {pred: owner_of, args: ["$Claim", "$Agent"]} + - atom: {pred: signed_by, args: ["$Claim", "$Agent"]} +decisions: + - name: propose_accept + params: [Claim] + all: + - atom: {pred: owner_asserted, args: ["$Claim"]} diff --git a/ccl_v0_1/tests/TEST_RESULTS.md b/ccl_v0_1/tests/TEST_RESULTS.md index 0e4ed375e..699a2409d 100644 --- a/ccl_v0_1/tests/TEST_RESULTS.md +++ b/ccl_v0_1/tests/TEST_RESULTS.md @@ -8,5 +8,7 @@ - 06_context_disputed.yaml: **PASS** - 07_context_epoch_mismatch.yaml: **PASS** - 08_context_quorum_accept.yaml: **PASS** +- 09_owner_valid_readable_policy.yaml: **PASS** +- 10_context_quorum_accept_readable_policy.yaml: **PASS** -Passed: 8, Failed: 0 +Passed: 10, Failed: 0 diff --git a/ccl_v0_1/tests/cases/09_owner_valid_readable_policy.yaml b/ccl_v0_1/tests/cases/09_owner_valid_readable_policy.yaml new file mode 100644 index 000000000..ec7f59773 --- /dev/null +++ b/ccl_v0_1/tests/cases/09_owner_valid_readable_policy.yaml @@ -0,0 +1,18 @@ +name: owner_valid_with_readable_variables +policy: owner_assertion_readable.yaml +context: + paranet: ops-paranet + scope_ual: ual:dkg:ops-paranet:auth:profile:p1 + view: accepted + snapshot_id: snap-owner-readable-01 +facts: + - [claim, p1] + - [owner_of, p1, 0xalice] + - [signed_by, p1, 0xalice] +expected: + derived: + owner_asserted: + - [p1] + decisions: + propose_accept: + - [p1] diff --git a/ccl_v0_1/tests/cases/10_context_quorum_accept_readable_policy.yaml b/ccl_v0_1/tests/cases/10_context_quorum_accept_readable_policy.yaml new file mode 100644 index 000000000..10603a988 --- /dev/null +++ b/ccl_v0_1/tests/cases/10_context_quorum_accept_readable_policy.yaml @@ -0,0 +1,31 @@ +name: context_quorum_accept_with_readable_variables +policy: context_corroboration_readable.yaml +context: + paranet: ops-paranet + scope_ual: ual:dkg:ops-paranet:auth:context:incident-128:claim-c1 + view: accepted + snapshot_id: snap-context-readable-10 +facts: + - [claim, c1] + - [supports, e1, c1] + - [supports, e2, c1] + - [evidence_view, e1, accepted] + - [evidence_view, e2, accepted] + - [independent, e1] + - [independent, e2] + - [authority_class, e1, vendor] + - [authority_class, e2, operator] + - [claim_epoch, c1, 7] + - [quorum_epoch, incident_review, 7] + - [quorum_reached, incident_review, 3, 4] +expected: + derived: + corroborated: + - [c1] + disputed: [] + promotable: + - [c1] + decisions: + propose_accept: + - [c1] + propose_reject: [] diff --git a/ccl_v0_1/tests/run_all_tests.js b/ccl_v0_1/tests/run_all_tests.js index 24f034861..0eaa008fe 100644 --- a/ccl_v0_1/tests/run_all_tests.js +++ b/ccl_v0_1/tests/run_all_tests.js @@ -1,14 +1,20 @@ #!/usr/bin/env node -const fs = require('node:fs'); -const path = require('node:path'); -const { compareExpected, loadYaml, resolvePolicyPath, runCase } = require('../evaluator/reference_evaluator.js'); +const fs = require("node:fs"); +const path = require("node:path"); +const { + compareExpected, + loadYaml, + resolvePolicyPath, + runCase, +} = require("../evaluator/reference_evaluator.js"); -const casesDir = path.resolve(__dirname, 'cases'); +const casesDir = path.resolve(__dirname, "cases"); function main() { - const caseFiles = fs.readdirSync(casesDir) - .filter((file) => file.endsWith('.yaml')) + const caseFiles = fs + .readdirSync(casesDir) + .filter((file) => file.endsWith(".yaml")) .sort(); let passed = 0; diff --git a/ccl_v0_1/tests/run_inline_surface_tests.js b/ccl_v0_1/tests/run_inline_surface_tests.js new file mode 100644 index 000000000..01b8d667b --- /dev/null +++ b/ccl_v0_1/tests/run_inline_surface_tests.js @@ -0,0 +1,276 @@ +#!/usr/bin/env node + +const { compileSurfacePolicy } = require('../evaluator/surface_compiler.js'); +const { compareExpected, Evaluator } = require('../evaluator/reference_evaluator.js'); + +const cases = [ + { + name: 'inline_owner_assertion_surface', + source: `policy owner_assertion v0.1.0 + +rule owner_asserted(Claim): + claim(Claim) + exists Agent where + owner_of(Claim, Agent) + signed_by(Claim, Agent) + +decision propose_accept(Claim): + owner_asserted(Claim) +`, + facts: [ + ['claim', 'p1'], + ['owner_of', 'p1', '0xalice'], + ['signed_by', 'p1', '0xalice'], + ], + expected: { + derived: { + owner_asserted: [['p1']], + }, + decisions: { + propose_accept: [['p1']], + }, + }, + }, + { + name: 'inline_context_corroboration_surface', + source: `policy context_corroboration v0.1.0 + +rule corroborated(Claim): + claim(Claim) + count_distinct Evidence where + supports(Evidence, Claim) + evidence_view(Evidence, accepted) + independent(Evidence) + >= 2 + exists Evidence where + supports(Evidence, Claim) + evidence_view(Evidence, accepted) + authority_class(Evidence, vendor) + not exists Contradiction where + contradicts(Contradiction, Claim) + accepted_status(Contradiction, accepted) + +rule promotable(Claim): + corroborated(Claim) + exists Epoch where + claim_epoch(Claim, Epoch) + quorum_epoch(incident_review, Epoch) + quorum_reached(incident_review, 3, 4) + +decision propose_accept(Claim): + promotable(Claim) +`, + facts: [ + ['claim', 'c1'], + ['supports', 'e1', 'c1'], + ['supports', 'e2', 'c1'], + ['evidence_view', 'e1', 'accepted'], + ['evidence_view', 'e2', 'accepted'], + ['independent', 'e1'], + ['independent', 'e2'], + ['authority_class', 'e1', 'vendor'], + ['authority_class', 'e2', 'operator'], + ['claim_epoch', 'c1', 7], + ['quorum_epoch', 'incident_review', 7], + ['quorum_reached', 'incident_review', 3, 4], + ], + expected: { + derived: { + corroborated: [['c1']], + promotable: [['c1']], + }, + decisions: { + propose_accept: [['c1']], + }, + }, + }, + { + name: 'inline_agents_reject_flat_earth_claim', + source: `policy scientific_consensus v0.1.0 + +rule flat_claim_rejected(Claim): + claim(Claim) + claim_topic(Claim, earth_shape) + claim_value(Claim, flat) + count_distinct Agent where + asserts(Agent, Claim) + >= 1 + count_distinct Agent where + submits_evidence(Agent, Evidence, Claim) + evidence_view(Evidence, accepted) + evidence_conclusion(Evidence, round) + >= 3 + +decision propose_reject(Claim): + flat_claim_rejected(Claim) +`, + facts: [ + ['claim', 'claim_flat_earth'], + ['claim_topic', 'claim_flat_earth', 'earth_shape'], + ['claim_value', 'claim_flat_earth', 'flat'], + ['asserts', 'agent_alex', 'claim_flat_earth'], + ['submits_evidence', 'agent_blair', 'evidence_satellite', 'claim_flat_earth'], + ['submits_evidence', 'agent_casey', 'evidence_horizon', 'claim_flat_earth'], + ['submits_evidence', 'agent_drew', 'evidence_circumnavigation', 'claim_flat_earth'], + ['evidence_view', 'evidence_satellite', 'accepted'], + ['evidence_view', 'evidence_horizon', 'accepted'], + ['evidence_view', 'evidence_circumnavigation', 'accepted'], + ['evidence_conclusion', 'evidence_satellite', 'round'], + ['evidence_conclusion', 'evidence_horizon', 'round'], + ['evidence_conclusion', 'evidence_circumnavigation', 'round'], + ], + expected: { + derived: { + flat_claim_rejected: [['claim_flat_earth']], + }, + decisions: { + propose_reject: [['claim_flat_earth']], + }, + }, + printRdf: true, + }, + { + name: 'inline_flat_earth_policy_fails_against_round_evidence', + source: `policy false_flat_earth_consensus v0.1.0 + +rule claim_has_supporter(Claim): + claim(Claim) + exists Agent where + asserts(Agent, Claim) + +rule flat_claim_supported(Claim): + claim_has_supporter(Claim) + claim_topic(Claim, earth_shape) + claim_value(Claim, flat) + count_distinct Agent where + submits_evidence(Agent, Evidence, Claim) + evidence_view(Evidence, accepted) + evidence_conclusion(Evidence, flat) + >= 3 + +decision propose_accept(Claim): + flat_claim_supported(Claim) +`, + facts: [ + ['claim', 'claim_flat_earth'], + ['claim_topic', 'claim_flat_earth', 'earth_shape'], + ['claim_value', 'claim_flat_earth', 'flat'], + ['asserts', 'agent_alex', 'claim_flat_earth'], + ['submits_evidence', 'agent_blair', 'evidence_satellite', 'claim_flat_earth'], + ['submits_evidence', 'agent_casey', 'evidence_horizon', 'claim_flat_earth'], + ['submits_evidence', 'agent_drew', 'evidence_circumnavigation', 'claim_flat_earth'], + ['evidence_view', 'evidence_satellite', 'accepted'], + ['evidence_view', 'evidence_horizon', 'accepted'], + ['evidence_view', 'evidence_circumnavigation', 'accepted'], + ['evidence_conclusion', 'evidence_satellite', 'round'], + ['evidence_conclusion', 'evidence_horizon', 'round'], + ['evidence_conclusion', 'evidence_circumnavigation', 'round'], + ], + expected: { + derived: { + claim_has_supporter: [['claim_flat_earth']], + flat_claim_supported: [], + }, + decisions: { + propose_accept: [], + }, + }, + printRdf: true, + }, +]; + +function main() { + let passed = 0; + + for (const testCase of cases) { + const compiled = compileSurfacePolicy(testCase.source); + const result = new Evaluator(compiled, testCase.facts).run(); + const comparison = compareExpected(result, testCase.expected); + + if (!comparison.ok) { + console.error(`FAIL ${testCase.name}`); + console.error(JSON.stringify(comparison.detail, null, 2)); + process.exitCode = 1; + return; + } + + if (testCase.printRdf) { + console.log(renderEvaluationAsTrig(testCase.name, testCase.facts, result)); + } + + passed += 1; + console.log(`PASS ${testCase.name}`); + } + + console.log(`\n${passed}/${cases.length} inline surface cases passed`); +} + +main(); + +function renderEvaluationAsTrig(name, facts, result) { + const graph = 'did:dkg:paranet:test-ccl'; + const evaluation = `did:dkg:ccl-eval:${name}`; + const lines = [ + '@prefix rdf: .', + '@prefix dkg: .', + '@prefix cclf: .', + '', + `GRAPH <${graph}> {`, + ]; + + appendFacts(lines, name, facts); + + lines.push( + ` <${evaluation}> rdf:type dkg:CCLEvaluation .`, + ); + + appendEntries(lines, evaluation, 'derived', result.derived, graph); + appendEntries(lines, evaluation, 'decision', result.decisions, graph); + + lines.push('}'); + + return `RDF for ${name}:\n${lines.join('\n')}`; +} + +function appendFacts(lines, name, facts) { + facts.forEach((fact, factIndex) => { + const [predicate, ...args] = fact; + const factNode = `did:dkg:ccl-fact:${name}:${factIndex}`; + lines.push( + ` <${factNode}> rdf:type cclf:InputFact .`, + ` <${factNode}> cclf:predicate "${predicate}" .`, + ); + + args.forEach((arg, argIndex) => { + lines.push(` <${factNode}> cclf:arg${argIndex} ${jsonLiteral(arg)} .`); + }); + }); +} + +function appendEntries(lines, evaluation, kind, entries, graph) { + for (const [predicate, tuples] of Object.entries(entries)) { + tuples.forEach((tuple, tupleIndex) => { + const entry = `${evaluation}/result/${kind}/${predicate}/${tupleIndex}`; + lines.push( + ` <${entry}> rdf:type dkg:CCLResultEntry .`, + ` <${evaluation}> dkg:hasResult <${entry}> .`, + ` <${entry}> dkg:resultKind "${kind}" .`, + ` <${entry}> dkg:resultName "${predicate}" .`, + ); + + tuple.forEach((value, argIndex) => { + const arg = `${entry}/arg/${argIndex}`; + lines.push( + ` <${arg}> rdf:type dkg:CCLResultArg .`, + ` <${entry}> dkg:hasResultArg <${arg}> .`, + ` <${arg}> dkg:resultArgIndex "${argIndex}" .`, + ` <${arg}> dkg:resultArgValue ${jsonLiteral(value)} .`, + ); + }); + }); + } +} + +function jsonLiteral(value) { + return `"${JSON.stringify(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} diff --git a/ccl_v0_1/tests/run_surface_tests.js b/ccl_v0_1/tests/run_surface_tests.js new file mode 100644 index 000000000..fe6df018d --- /dev/null +++ b/ccl_v0_1/tests/run_surface_tests.js @@ -0,0 +1,55 @@ +#!/usr/bin/env node + +const path = require('node:path'); +const { loadYaml, compareExpected, Evaluator } = require('../evaluator/reference_evaluator.js'); +const { loadSurfacePolicy } = require('../evaluator/surface_compiler.js'); + +const ROOT = path.resolve(__dirname, '..'); + +const suites = [ + { + name: 'owner_assertion_surface', + policy: path.join(ROOT, 'examples', 'owner_assertion.ccl'), + cases: ['01_owner_valid.yaml', '02_owner_invalid.yaml'], + }, + { + name: 'context_corroboration_surface', + policy: path.join(ROOT, 'examples', 'context_corroboration.ccl'), + cases: [ + '03_context_minimal_corroboration.yaml', + '04_context_missing_vendor.yaml', + '05_context_workspace_excluded.yaml', + '06_context_disputed.yaml', + '07_context_epoch_mismatch.yaml', + '08_context_quorum_accept.yaml', + ], + }, +]; + +function main() { + let passed = 0; + let total = 0; + + for (const suite of suites) { + const compiled = loadSurfacePolicy(suite.policy); + for (const caseFile of suite.cases) { + total += 1; + const casePath = path.join(ROOT, 'tests', 'cases', caseFile); + const testCase = loadYaml(casePath); + const result = new Evaluator(compiled, testCase.facts).run(); + const comparison = compareExpected(result, testCase.expected); + if (!comparison.ok) { + console.error(`FAIL ${suite.name} -> ${testCase.name}`); + console.error(JSON.stringify(comparison.detail, null, 2)); + process.exitCode = 1; + return; + } + passed += 1; + console.log(`PASS ${suite.name} -> ${testCase.name}`); + } + } + + console.log(`\n${passed}/${total} surface cases passed`); +} + +main(); diff --git a/packages/adapter-openclaw/skills/ccl/SKILL.md b/packages/adapter-openclaw/skills/ccl/SKILL.md index ba144ec61..d04091360 100644 --- a/packages/adapter-openclaw/skills/ccl/SKILL.md +++ b/packages/adapter-openclaw/skills/ccl/SKILL.md @@ -170,4 +170,5 @@ These outputs do not change authoritative DKG state by themselves. - Version policies explicitly. - Evaluate only against a declared snapshot or case input. - Prefer publishing the supporting facts first, then evaluating. +- Prefer descriptive surface-CCL variable names such as `Claim`, `Evidence`, `Agent`, and `Epoch` when authoring policies. - If agents disagree, check the facts, the snapshot boundary, and the policy version before anything else. diff --git a/packages/agent/src/ccl-evaluation-publish.ts b/packages/agent/src/ccl-evaluation-publish.ts index 016dd30f7..c87b2c86d 100644 --- a/packages/agent/src/ccl-evaluation-publish.ts +++ b/packages/agent/src/ccl-evaluation-publish.ts @@ -6,6 +6,9 @@ export interface PublishCclEvaluationInput { paranetId: string; policyUri: string; factSetHash: string; + factQueryHash?: string; + factResolverVersion?: string; + factResolutionMode?: 'manual' | 'snapshot-resolved'; result: CclEvaluationResult; evaluatedAt: string; view?: string; @@ -28,6 +31,9 @@ export function buildCclEvaluationQuads(input: PublishCclEvaluationInput, graph: { subject: evaluationUri, predicate: DKG_ONTOLOGY.DKG_CREATED_AT, object: sparqlString(input.evaluatedAt), graph: graphUri }, ]; + if (input.factQueryHash) quads.push({ subject: evaluationUri, predicate: DKG_ONTOLOGY.DKG_FACT_QUERY_HASH, object: sparqlString(input.factQueryHash), graph: graphUri }); + if (input.factResolverVersion) quads.push({ subject: evaluationUri, predicate: DKG_ONTOLOGY.DKG_FACT_RESOLVER_VERSION, object: sparqlString(input.factResolverVersion), graph: graphUri }); + if (input.factResolutionMode) quads.push({ subject: evaluationUri, predicate: DKG_ONTOLOGY.DKG_FACT_RESOLUTION_MODE, object: sparqlString(input.factResolutionMode), graph: graphUri }); if (input.view) quads.push({ subject: evaluationUri, predicate: DKG_ONTOLOGY.DKG_VIEW, object: sparqlString(input.view), graph: graphUri }); if (input.snapshotId) quads.push({ subject: evaluationUri, predicate: DKG_ONTOLOGY.DKG_SNAPSHOT_ID, object: sparqlString(input.snapshotId), graph: graphUri }); if (input.scopeUal) quads.push({ subject: evaluationUri, predicate: DKG_ONTOLOGY.DKG_SCOPE_UAL, object: sparqlString(input.scopeUal), graph: graphUri }); diff --git a/packages/agent/src/ccl-evaluator.ts b/packages/agent/src/ccl-evaluator.ts index 800d6b43b..067dd7f02 100644 --- a/packages/agent/src/ccl-evaluator.ts +++ b/packages/agent/src/ccl-evaluator.ts @@ -29,6 +29,11 @@ export interface CclEvaluationResult { decisions: Record; } +export interface ValidateCclPolicyOptions { + expectedName?: string; + expectedVersion?: string; +} + type Binding = Record; function isVar(value: unknown): value is string { @@ -55,6 +60,31 @@ export function parseCclPolicy(content: string): CclCanonicalPolicy { return parsed as CclCanonicalPolicy; } +export function validateCclPolicy(content: string, opts: ValidateCclPolicyOptions = {}): CclCanonicalPolicy { + const policy = parseCclPolicy(content); + if (!policy.policy || typeof policy.policy !== 'string') { + throw new Error('CCL policy must define a string "policy" name'); + } + if (!policy.version || typeof policy.version !== 'string') { + throw new Error('CCL policy must define a string "version"'); + } + if (opts.expectedName && policy.policy !== opts.expectedName) { + throw new Error(`CCL policy name mismatch: expected ${opts.expectedName}, got ${policy.policy}`); + } + if (opts.expectedVersion && policy.version !== opts.expectedVersion) { + throw new Error(`CCL policy version mismatch: expected ${opts.expectedVersion}, got ${policy.version}`); + } + if (policy.rules != null && !Array.isArray(policy.rules)) { + throw new Error('CCL policy "rules" must be an array when provided'); + } + if (policy.decisions != null && !Array.isArray(policy.decisions)) { + throw new Error('CCL policy "decisions" must be an array when provided'); + } + for (const rule of policy.rules ?? []) validateEntry(rule, 'rule'); + for (const decision of policy.decisions ?? []) validateEntry(decision, 'decision'); + return policy; +} + export function hashCclFacts(facts: CclFactTuple[]): string { const normalized = facts.map(tuple => [...tuple]).sort(compareTuples); return `sha256:${createHash('sha256').update(JSON.stringify(normalized)).digest('hex')}`; @@ -209,3 +239,57 @@ function compareInts(left: number, op: string, right: number): boolean { default: throw new Error(`Unsupported CCL comparison operator: ${op}`); } } + +function validateEntry(entry: { name: string; params?: string[]; all?: CclCondition[] }, kind: 'rule' | 'decision'): void { + if (!entry || typeof entry !== 'object') { + throw new Error(`CCL ${kind} entry must be an object`); + } + if (!entry.name || typeof entry.name !== 'string') { + throw new Error(`CCL ${kind} entry must define a string name`); + } + if (entry.params != null && !Array.isArray(entry.params)) { + throw new Error(`CCL ${kind} ${entry.name} params must be an array when provided`); + } + if (entry.all != null && !Array.isArray(entry.all)) { + throw new Error(`CCL ${kind} ${entry.name} all-clause must be an array when provided`); + } + for (const condition of entry.all ?? []) validateCondition(condition); +} + +function validateCondition(condition: CclCondition): void { + if ('atom' in condition) { + if (!condition.atom?.pred || typeof condition.atom.pred !== 'string') { + throw new Error('CCL atom condition must define a string pred'); + } + if (condition.atom.args != null && !Array.isArray(condition.atom.args)) { + throw new Error(`CCL atom ${condition.atom.pred} args must be an array when provided`); + } + return; + } + if ('exists' in condition || 'not_exists' in condition) { + const where = 'exists' in condition ? condition.exists.where : condition.not_exists.where; + if (where != null && !Array.isArray(where)) { + throw new Error(`CCL ${'exists' in condition ? 'exists' : 'not_exists'} where-clause must be an array when provided`); + } + for (const nested of where ?? []) validateCondition(nested); + return; + } + if ('count_distinct' in condition) { + const spec = condition.count_distinct; + if (spec.vars != null && !Array.isArray(spec.vars)) { + throw new Error('CCL count_distinct vars must be an array when provided'); + } + if (!['>=', '>', '==', '<=', '<'].includes(spec.op)) { + throw new Error(`Unsupported CCL comparison operator: ${spec.op}`); + } + if (typeof spec.value !== 'number' || !Number.isFinite(spec.value)) { + throw new Error('CCL count_distinct value must be a finite number'); + } + if (spec.where != null && !Array.isArray(spec.where)) { + throw new Error('CCL count_distinct where-clause must be an array when provided'); + } + for (const nested of spec.where ?? []) validateCondition(nested); + return; + } + throw new Error(`Unsupported CCL condition: ${JSON.stringify(condition)}`); +} diff --git a/packages/agent/src/ccl-fact-resolution.ts b/packages/agent/src/ccl-fact-resolution.ts new file mode 100644 index 000000000..24163ae5d --- /dev/null +++ b/packages/agent/src/ccl-fact-resolution.ts @@ -0,0 +1,206 @@ +import { createHash } from 'node:crypto'; +import { DKG_ONTOLOGY, paranetDataGraphUri, paranetWorkspaceGraphUri, sparqlString } from '@origintrail-official/dkg-core'; +import type { TripleStore } from '@origintrail-official/dkg-storage'; +import type { CclFactTuple } from './ccl-evaluator.js'; + +const CCL_FACT_NS = 'https://example.org/ccl-fact#'; +const CCL_INPUT_FACT = `${CCL_FACT_NS}InputFact`; +const CCL_FACT_PREDICATE = `${CCL_FACT_NS}predicate`; +const CCL_ARG_PREFIX = `${CCL_FACT_NS}arg`; + +const CANONICAL_FACT_RESOLVER_VERSION = 'canonical-input-facts/v1'; +const MANUAL_FACT_RESOLVER_VERSION = 'manual-input/v1'; +const SUPPORTED_POLICY_FAMILIES = new Set(['owner_assertion', 'context_corroboration']); + +export type CclFactResolutionMode = 'manual' | 'snapshot-resolved'; + +export interface ResolveCclFactsFromSnapshotOptions { + paranetId: string; + snapshotId?: string; + view?: string; + scopeUal?: string; + policyName?: string; + contextType?: string; +} + +export interface ResolvedCclFacts { + facts: CclFactTuple[]; + factSetHash: string; + factQueryHash: string; + factResolverVersion: string; + factResolutionMode: 'snapshot-resolved'; + context: { + paranetId: string; + contextType?: string; + view?: string; + snapshotId?: string; + scopeUal?: string; + }; +} + +export interface ManualCclFacts { + facts: CclFactTuple[]; + factSetHash: string; + factQueryHash: string; + factResolverVersion: string; + factResolutionMode: 'manual'; +} + +export async function resolveFactsFromSnapshot( + store: TripleStore, + opts: ResolveCclFactsFromSnapshotOptions, +): Promise { + const profile = resolveProfile(opts.policyName, opts.contextType); + const graph = opts.view === 'workspace' + ? paranetWorkspaceGraphUri(opts.paranetId) + : paranetDataGraphUri(opts.paranetId); + const query = ` + SELECT ?fact ?predicate ?snapshotId ?view ?scopeUal ?argPred ?argVal WHERE { + GRAPH <${graph}> { + ?fact <${DKG_ONTOLOGY.RDF_TYPE}> <${CCL_INPUT_FACT}> ; + <${CCL_FACT_PREDICATE}> ?predicate ; + ?argPred ?argVal . + FILTER(STRSTARTS(STR(?argPred), ${sparqlString(CCL_ARG_PREFIX)})) + OPTIONAL { ?fact <${DKG_ONTOLOGY.DKG_SNAPSHOT_ID}> ?snapshotId } + OPTIONAL { ?fact <${DKG_ONTOLOGY.DKG_VIEW}> ?view } + OPTIONAL { ?fact <${DKG_ONTOLOGY.DKG_SCOPE_UAL}> ?scopeUal } + } + } + ORDER BY ?fact ?argPred + `; + const result = await store.query(query); + const factsByNode = new Map(); + + if (result.type === 'bindings') { + for (const row of result.bindings as Record[]) { + const snapshotId = row['snapshotId'] ? stripLiteral(row['snapshotId']) : undefined; + const view = row['view'] ? stripLiteral(row['view']) : undefined; + const scopeUal = row['scopeUal'] ? stripLiteral(row['scopeUal']) : undefined; + if (opts.snapshotId && snapshotId !== opts.snapshotId) continue; + if (opts.view && view != null && view !== opts.view) continue; + if (opts.view && view == null && opts.view !== 'accepted') continue; + if (opts.scopeUal && scopeUal !== opts.scopeUal) continue; + + const factId = row['fact']; + const next = factsByNode.get(factId) ?? { + predicate: stripLiteral(row['predicate']), + args: new Map(), + }; + next.snapshotId = snapshotId; + next.view = view; + next.scopeUal = scopeUal; + const argIndex = parseArgIndex(row['argPred']); + next.args.set(argIndex, parseFactArg(stripLiteral(row['argVal']))); + factsByNode.set(factId, next); + } + } + + const deduped = new Map(); + for (const fact of factsByNode.values()) { + const tuple = [fact.predicate, ...materializeArgs(fact.args)] as CclFactTuple; + deduped.set(JSON.stringify(tuple), tuple); + } + + const facts = Array.from(deduped.values()).sort(compareTuples) as CclFactTuple[]; + return { + facts, + factSetHash: hashFacts(facts), + factQueryHash: hashString(`${profile.id}\n${query}`), + factResolverVersion: profile.version, + factResolutionMode: 'snapshot-resolved', + context: { + paranetId: opts.paranetId, + contextType: opts.contextType, + view: opts.view, + snapshotId: opts.snapshotId, + scopeUal: opts.scopeUal, + }, + }; +} + +export function buildManualCclFacts(facts: CclFactTuple[]): ManualCclFacts { + return { + facts, + factSetHash: hashFacts(facts), + factQueryHash: hashString('manual-input'), + factResolverVersion: MANUAL_FACT_RESOLVER_VERSION, + factResolutionMode: 'manual', + }; +} + +interface SnapshotFactNode { + predicate: string; + args: Map; + snapshotId?: string; + view?: string; + scopeUal?: string; +} + +function resolveProfile(policyName?: string, contextType?: string): { id: string; version: string } { + if (contextType && SUPPORTED_POLICY_FAMILIES.has(contextType)) { + return { id: `profile:${contextType}`, version: CANONICAL_FACT_RESOLVER_VERSION }; + } + if (policyName && SUPPORTED_POLICY_FAMILIES.has(policyName)) { + return { id: `policy:${policyName}`, version: CANONICAL_FACT_RESOLVER_VERSION }; + } + throw new Error( + `No snapshot fact resolver is configured for ${policyName ?? contextType ?? 'this policy'}. Pass facts explicitly or add a resolver profile.`, + ); +} + +function parseArgIndex(argPredicate: string): number { + const value = strip(argPredicate); + const suffix = value.startsWith(CCL_ARG_PREFIX) ? value.slice(CCL_ARG_PREFIX.length) : ''; + const index = Number.parseInt(suffix, 10); + if (!Number.isInteger(index) || index < 0) { + throw new Error(`Invalid CCL fact argument predicate: ${argPredicate}`); + } + return index; +} + +function materializeArgs(args: Map): unknown[] { + return Array.from(args.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([, value]) => value); +} + +function parseFactArg(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function hashFacts(facts: CclFactTuple[]): string { + return `sha256:${createHash('sha256').update(JSON.stringify(facts.map(tuple => [...tuple]).sort(compareTuples))).digest('hex')}`; +} + +function hashString(value: string): string { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} + +function compareTuples(left: unknown[], right: unknown[]): number { + return JSON.stringify(left).localeCompare(JSON.stringify(right)); +} + +function strip(value: string): string { + if (value.startsWith('<') && value.endsWith('>')) return value.slice(1, -1); + return value; +} + +function stripLiteral(s: string): string { + if (s.startsWith('"') && s.endsWith('"')) return unescapeLiteralContent(s.slice(1, -1)); + const match = s.match(/^"(.*)"(\^\^.*|@.*)?$/); + if (match) return unescapeLiteralContent(match[1]); + return s; +} + +function unescapeLiteralContent(value: string): string { + return value + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\r') + .replace(/\\t/g, '\t') + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\'); +} diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 9b304eab4..ac245a5b4 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -32,9 +32,10 @@ import { AGENT_REGISTRY_PARANET, type AgentProfileConfig } from './profile.js'; import { GossipPublishHandler } from './gossip-publish-handler.js'; import { FinalizationHandler } from './finalization-handler.js'; import { multiaddr } from '@multiformats/multiaddr'; -import { buildCclPolicyQuads, buildPolicyApprovalQuads, type CclPolicyRecord, type PolicyApprovalBinding } from './ccl-policy.js'; -import { CclEvaluator, hashCclFacts, parseCclPolicy, type CclEvaluationResult, type CclFactTuple } from './ccl-evaluator.js'; +import { buildCclPolicyQuads, buildPolicyApprovalQuads, hashCclPolicy, type CclPolicyRecord, type PolicyApprovalBinding } from './ccl-policy.js'; +import { CclEvaluator, parseCclPolicy, validateCclPolicy, type CclEvaluationResult, type CclFactTuple } from './ccl-evaluator.js'; import { buildCclEvaluationQuads } from './ccl-evaluation-publish.js'; +import { buildManualCclFacts, resolveFactsFromSnapshot, type CclFactResolutionMode } from './ccl-fact-resolution.js'; export interface CclPublishedResultEntry { entryUri: string; @@ -47,6 +48,9 @@ export interface CclPublishedEvaluationRecord { evaluationUri: string; policyUri: string; factSetHash: string; + factQueryHash?: string; + factResolverVersion?: string; + factResolutionMode?: CclFactResolutionMode; createdAt?: string; view?: string; snapshotId?: string; @@ -1955,6 +1959,19 @@ export class DKGAgent { throw new Error(`Paranet "${opts.paranetId}" does not exist. Create it first with createParanet().`); } + validateCclPolicy(opts.content, { expectedName: opts.name, expectedVersion: opts.version }); + + const existing = (await this.listCclPolicies({ paranetId: opts.paranetId, name: opts.name })) + .find(policy => policy.version === opts.version); + const existingHash = existing?.hash; + const nextHash = hashCclPolicy(opts.content); + if (existingHash && existingHash !== nextHash) { + throw new Error(`CCL policy ${opts.paranetId}/${opts.name}@${opts.version} already exists with different content`); + } + if (existing?.policyUri && existingHash === nextHash) { + return { policyUri: existing.policyUri, hash: existing.hash, status: 'proposed' }; + } + const ontologyGraph = paranetDataGraphUri(SYSTEM_PARANETS.ONTOLOGY); const now = new Date().toISOString(); const { policyUri, hash, quads } = buildCclPolicyQuads(opts, `did:dkg:agent:${this.peerId}`, ontologyGraph, now); @@ -1971,7 +1988,7 @@ export class DKGAgent { }): Promise<{ policyUri: string; bindingUri: string; contextType?: string; approvedAt: string }> { const ctx = createOperationContext('system'); await this.assertParanetOwner(opts.paranetId); - const record = await this.getCclPolicyByUri(opts.policyUri); + const record = await this.getCclPolicyByUri(opts.policyUri, { includeBody: true }); if (!record) throw new Error(`CCL policy not found: ${opts.policyUri}`); if (record.paranetId !== opts.paranetId) { throw new Error(`CCL policy ${opts.policyUri} belongs to paranet "${record.paranetId}", not "${opts.paranetId}"`); @@ -1979,6 +1996,8 @@ export class DKGAgent { if (record.contextType && opts.contextType && record.contextType !== opts.contextType) { throw new Error(`CCL policy contextType mismatch: policy=${record.contextType}, requested=${opts.contextType}`); } + if (!record.body) throw new Error(`CCL policy body missing: ${opts.policyUri}`); + validateCclPolicy(record.body, { expectedName: record.name, expectedVersion: record.version }); const ontologyGraph = paranetDataGraphUri(SYSTEM_PARANETS.ONTOLOGY); const approvedAt = new Date().toISOString(); @@ -2118,10 +2137,34 @@ export class DKGAgent { return record; } + async resolveFactsFromSnapshot(opts: { + paranetId: string; + snapshotId?: string; + view?: string; + scopeUal?: string; + policyName?: string; + contextType?: string; + }): Promise<{ + facts: CclFactTuple[]; + factSetHash: string; + factQueryHash: string; + factResolverVersion: string; + factResolutionMode: 'snapshot-resolved'; + context: { + paranetId: string; + contextType?: string; + view?: string; + snapshotId?: string; + scopeUal?: string; + }; + }> { + return resolveFactsFromSnapshot(this.store, opts); + } + async evaluateCclPolicy(opts: { paranetId: string; name: string; - facts: CclFactTuple[]; + facts?: CclFactTuple[]; contextType?: string; view?: string; snapshotId?: string; @@ -2136,6 +2179,9 @@ export class DKGAgent { scopeUal?: string; }; factSetHash: string; + factQueryHash: string; + factResolverVersion: string; + factResolutionMode: CclFactResolutionMode; result: CclEvaluationResult; }> { const policy = await this.resolveCclPolicy({ @@ -2149,7 +2195,17 @@ export class DKGAgent { } const parsed = parseCclPolicy(policy.body); - const evaluator = new CclEvaluator(parsed, opts.facts); + const factInput = opts.facts + ? buildManualCclFacts(opts.facts) + : await this.resolveFactsFromSnapshot({ + paranetId: opts.paranetId, + snapshotId: opts.snapshotId, + view: opts.view, + scopeUal: opts.scopeUal, + policyName: policy.name, + contextType: opts.contextType ?? policy.contextType, + }); + const evaluator = new CclEvaluator(parsed, factInput.facts); const result = evaluator.run(); return { @@ -2170,7 +2226,10 @@ export class DKGAgent { snapshotId: opts.snapshotId, scopeUal: opts.scopeUal, }, - factSetHash: hashCclFacts(opts.facts), + factSetHash: factInput.factSetHash, + factQueryHash: factInput.factQueryHash, + factResolverVersion: factInput.factResolverVersion, + factResolutionMode: factInput.factResolutionMode, result, }; } @@ -2178,7 +2237,7 @@ export class DKGAgent { async evaluateAndPublishCclPolicy(opts: { paranetId: string; name: string; - facts: CclFactTuple[]; + facts?: CclFactTuple[]; contextType?: string; view?: string; snapshotId?: string; @@ -2196,6 +2255,9 @@ export class DKGAgent { scopeUal?: string; }; factSetHash: string; + factQueryHash: string; + factResolverVersion: string; + factResolutionMode: CclFactResolutionMode; result: CclEvaluationResult; }; }> { @@ -2205,6 +2267,9 @@ export class DKGAgent { paranetId: opts.paranetId, policyUri: evaluation.policy.policyUri, factSetHash: evaluation.factSetHash, + factQueryHash: evaluation.factQueryHash, + factResolverVersion: evaluation.factResolverVersion, + factResolutionMode: evaluation.factResolutionMode, result: evaluation.result, evaluatedAt: new Date().toISOString(), view: evaluation.context.view, @@ -2236,11 +2301,14 @@ export class DKGAgent { const filterBlock = filters.length > 0 ? `FILTER(${filters.join(' && ')})` : ''; const result = await this.store.query(` - SELECT ?evaluation ?policy ?factSetHash ?createdAt ?view ?snapshotId ?scopeUal ?contextType ?entry ?kind ?resultName ?arg ?argIndex ?argValue WHERE { + SELECT ?evaluation ?policy ?factSetHash ?factQueryHash ?factResolverVersion ?factResolutionMode ?createdAt ?view ?snapshotId ?scopeUal ?contextType ?entry ?kind ?resultName ?arg ?argIndex ?argValue WHERE { GRAPH <${graph}> { ?evaluation <${DKG_ONTOLOGY.RDF_TYPE}> <${DKG_ONTOLOGY.DKG_CCL_EVALUATION}> ; <${DKG_ONTOLOGY.DKG_EVALUATED_POLICY}> ?policy ; <${DKG_ONTOLOGY.DKG_FACT_SET_HASH}> ?factSetHash . + OPTIONAL { ?evaluation <${DKG_ONTOLOGY.DKG_FACT_QUERY_HASH}> ?factQueryHash } + OPTIONAL { ?evaluation <${DKG_ONTOLOGY.DKG_FACT_RESOLVER_VERSION}> ?factResolverVersion } + OPTIONAL { ?evaluation <${DKG_ONTOLOGY.DKG_FACT_RESOLUTION_MODE}> ?factResolutionMode } OPTIONAL { ?evaluation <${DKG_ONTOLOGY.DKG_CREATED_AT}> ?createdAt } OPTIONAL { ?evaluation <${DKG_ONTOLOGY.DKG_VIEW}> ?view } OPTIONAL { ?evaluation <${DKG_ONTOLOGY.DKG_SNAPSHOT_ID}> ?snapshotId } @@ -2273,6 +2341,9 @@ export class DKGAgent { evaluationUri, policyUri: row['policy'], factSetHash: stripLiteral(row['factSetHash']), + factQueryHash: row['factQueryHash'] ? stripLiteral(row['factQueryHash']) : undefined, + factResolverVersion: row['factResolverVersion'] ? stripLiteral(row['factResolverVersion']) : undefined, + factResolutionMode: row['factResolutionMode'] ? stripLiteral(row['factResolutionMode']) as CclFactResolutionMode : undefined, createdAt: row['createdAt'] ? stripLiteral(row['createdAt']) : undefined, view: row['view'] ? stripLiteral(row['view']) : undefined, snapshotId: row['snapshotId'] ? stripLiteral(row['snapshotId']) : undefined, diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index c2507e661..fa30e0c19 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -10,12 +10,22 @@ export { FinalizationHandler } from './finalization-handler.js'; export { CclEvaluator, parseCclPolicy, + validateCclPolicy, hashCclFacts, type CclFactTuple, type CclCanonicalPolicy, type CclCondition, type CclEvaluationResult, + type ValidateCclPolicyOptions, } from './ccl-evaluator.js'; +export { + buildManualCclFacts, + resolveFactsFromSnapshot, + type CclFactResolutionMode, + type ManualCclFacts, + type ResolveCclFactsFromSnapshotOptions, + type ResolvedCclFacts, +} from './ccl-fact-resolution.js'; export { buildCclEvaluationQuads, type PublishCclEvaluationInput, diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 21fde641b..9f2dddb76 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { DKGAgentWallet, buildAgentProfile, + CclEvaluator, DiscoveryClient, ProfileManager, encrypt, @@ -11,15 +12,60 @@ import { x25519SharedSecret, DKGAgent, AGENT_REGISTRY_PARANET, + parseCclPolicy, } from '../src/index.js'; -import { OxigraphStore } from '@origintrail-official/dkg-storage'; -import { getGenesisQuads, computeNetworkId, PROTOCOL_SYNC, SYSTEM_PARANETS } from '@origintrail-official/dkg-core'; +import { OxigraphStore, type Quad } from '@origintrail-official/dkg-storage'; +import { getGenesisQuads, computeNetworkId, PROTOCOL_SYNC, SYSTEM_PARANETS, DKG_ONTOLOGY, paranetDataGraphUri, paranetWorkspaceGraphUri, sparqlString } from '@origintrail-official/dkg-core'; import { DKGQueryEngine } from '@origintrail-official/dkg-query'; import { sha256 } from '@noble/hashes/sha2.js'; import { MockChainAdapter } from '@origintrail-official/dkg-chain'; import { tmpdir } from 'node:os'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { createRequire } from 'node:module'; import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const { Evaluator: ReferenceEvaluator, loadYaml } = require(fileURLToPath(new URL('../../../ccl_v0_1/evaluator/reference_evaluator.js', import.meta.url))); +const CCL_FACT_NS = 'https://example.org/ccl-fact#'; + +function buildSnapshotFactQuads(opts: { + paranetId: string; + snapshotId: string; + view: 'accepted' | 'workspace'; + scopeUal?: string; + facts: Array<[string, ...unknown[]]>; +}): Quad[] { + const graph = opts.view === 'workspace' + ? paranetWorkspaceGraphUri(opts.paranetId) + : paranetDataGraphUri(opts.paranetId); + + return opts.facts.flatMap((fact, index) => { + const [predicate, ...args] = fact; + const subject = `did:dkg:ccl-fact:${opts.paranetId}:${opts.snapshotId}:${index}`; + const quads: Quad[] = [ + { subject, predicate: DKG_ONTOLOGY.RDF_TYPE, object: `${CCL_FACT_NS}InputFact`, graph }, + { subject, predicate: `${CCL_FACT_NS}predicate`, object: sparqlString(predicate), graph }, + { subject, predicate: DKG_ONTOLOGY.DKG_SNAPSHOT_ID, object: sparqlString(opts.snapshotId), graph }, + { subject, predicate: DKG_ONTOLOGY.DKG_VIEW, object: sparqlString(opts.view), graph }, + ]; + + if (opts.scopeUal) { + quads.push({ subject, predicate: DKG_ONTOLOGY.DKG_SCOPE_UAL, object: sparqlString(opts.scopeUal), graph }); + } + + args.forEach((arg, argIndex) => { + quads.push({ + subject, + predicate: `${CCL_FACT_NS}arg${argIndex}`, + object: sparqlString(JSON.stringify(arg)), + graph, + }); + }); + + return quads; + }); +} afterEach(() => { vi.restoreAllMocks(); @@ -797,6 +843,213 @@ decisions: [] await owner.stop().catch(() => {}); await other.stop().catch(() => {}); }); + + it('validates CCL policy content before publish', async () => { + const store = new OxigraphStore(); + const agent = await DKGAgent.create({ + name: 'ValidateBot', + store, + chainAdapter: new MockChainAdapter(), + }); + await agent.start(); + await agent.createParanet({ id: 'ops-validate', name: 'Ops Validate' }); + + await expect(agent.publishCclPolicy({ + paranetId: 'ops-validate', + name: 'incident-review', + version: '0.1.0', + content: `policy: wrong-name +version: 0.1.0 +rules: [] +decisions: [] +`, + })).rejects.toThrow(/name mismatch/); + + await expect(agent.publishCclPolicy({ + paranetId: 'ops-validate', + name: 'incident-review', + version: '0.1.0', + content: 'rules: []', + })).rejects.toThrow(/must define a string "policy" name/); + + await agent.stop().catch(() => {}); + }); + + it('rejects conflicting CCL republish for the same name and version', async () => { + const store = new OxigraphStore(); + const agent = await DKGAgent.create({ + name: 'CollisionBot', + store, + chainAdapter: new MockChainAdapter(), + }); + await agent.start(); + await agent.createParanet({ id: 'ops-collision', name: 'Ops Collision' }); + + await agent.publishCclPolicy({ + paranetId: 'ops-collision', + name: 'incident-review', + version: '0.1.0', + content: `policy: incident-review +version: 0.1.0 +rules: [] +decisions: [] +`, + }); + + await expect(agent.publishCclPolicy({ + paranetId: 'ops-collision', + name: 'incident-review', + version: '0.1.0', + content: `policy: incident-review +version: 0.1.0 +rules: + - name: flagged + params: [Claim] + all: + - atom: { pred: claim, args: ["$Claim"] } +decisions: [] +`, + })).rejects.toThrow(/already exists with different content/); + + await agent.stop().catch(() => {}); + }); + + it('resolves canonical snapshot facts and evaluates bundled policies without caller facts', async () => { + const store = new OxigraphStore(); + const agent = await DKGAgent.create({ + name: 'SnapshotBot', + store, + chainAdapter: new MockChainAdapter(), + }); + await agent.start(); + await agent.createParanet({ id: 'ops-snapshot', name: 'Ops Snapshot' }); + + const published = await agent.publishCclPolicy({ + paranetId: 'ops-snapshot', + name: 'owner_assertion', + version: '0.1.0', + content: `policy: owner_assertion +version: 0.1.0 +rules: + - name: owner_asserted + params: [Claim] + all: + - atom: { pred: claim, args: ["$Claim"] } + - exists: + where: + - atom: { pred: owner_of, args: ["$Claim", "$Agent"] } + - atom: { pred: signed_by, args: ["$Claim", "$Agent"] } +decisions: + - name: propose_accept + params: [Claim] + all: + - atom: { pred: owner_asserted, args: ["$Claim"] } +`, + }); + await agent.approveCclPolicy({ paranetId: 'ops-snapshot', policyUri: published.policyUri }); + + await store.insert(buildSnapshotFactQuads({ + paranetId: 'ops-snapshot', + snapshotId: 'snap-owner-01', + view: 'accepted', + scopeUal: 'ual:dkg:example:owner-assertion', + facts: [ + ['signed_by', 'p1', '0xalice'], + ['claim', 'p1'], + ['owner_of', 'p1', '0xalice'], + ], + })); + + const resolved = await agent.resolveFactsFromSnapshot({ + paranetId: 'ops-snapshot', + policyName: 'owner_assertion', + snapshotId: 'snap-owner-01', + view: 'accepted', + scopeUal: 'ual:dkg:example:owner-assertion', + }); + + expect(resolved.factResolutionMode).toBe('snapshot-resolved'); + expect(resolved.factResolverVersion).toBe('canonical-input-facts/v1'); + expect(resolved.facts).toEqual([ + ['claim', 'p1'], + ['owner_of', 'p1', '0xalice'], + ['signed_by', 'p1', '0xalice'], + ]); + + const evaluation = await agent.evaluateCclPolicy({ + paranetId: 'ops-snapshot', + name: 'owner_assertion', + snapshotId: 'snap-owner-01', + view: 'accepted', + scopeUal: 'ual:dkg:example:owner-assertion', + }); + + expect(evaluation.factResolutionMode).toBe('snapshot-resolved'); + expect(evaluation.factQueryHash).toContain('sha256:'); + expect(evaluation.result.derived.owner_asserted).toEqual([['p1']]); + expect(evaluation.result.decisions.propose_accept).toEqual([['p1']]); + + await agent.stop().catch(() => {}); + }); + + it('resolves the same snapshot facts deterministically across nodes', async () => { + const snapshotFacts: Array<[string, ...unknown[]]> = [ + ['signed_by', 'p1', '0xalice'], + ['claim', 'p1'], + ['owner_of', 'p1', '0xalice'], + ]; + const quads = buildSnapshotFactQuads({ + paranetId: 'ops-deterministic', + snapshotId: 'snap-owner-02', + view: 'accepted', + scopeUal: 'ual:dkg:example:owner-assertion', + facts: snapshotFacts, + }); + + const storeA = new OxigraphStore(); + const storeB = new OxigraphStore(); + await storeA.insert(quads); + await storeB.insert(quads); + + const agentA = await DKGAgent.create({ name: 'DeterministicA', store: storeA, chainAdapter: new MockChainAdapter() }); + const agentB = await DKGAgent.create({ name: 'DeterministicB', store: storeB, chainAdapter: new MockChainAdapter() }); + + const resolvedA = await agentA.resolveFactsFromSnapshot({ + paranetId: 'ops-deterministic', + policyName: 'owner_assertion', + snapshotId: 'snap-owner-02', + view: 'accepted', + scopeUal: 'ual:dkg:example:owner-assertion', + }); + const resolvedB = await agentB.resolveFactsFromSnapshot({ + paranetId: 'ops-deterministic', + policyName: 'owner_assertion', + snapshotId: 'snap-owner-02', + view: 'accepted', + scopeUal: 'ual:dkg:example:owner-assertion', + }); + + expect(resolvedA.facts).toEqual(resolvedB.facts); + expect(resolvedA.factSetHash).toBe(resolvedB.factSetHash); + expect(resolvedA.factQueryHash).toBe(resolvedB.factQueryHash); + expect(resolvedA.factResolverVersion).toBe(resolvedB.factResolverVersion); + }); + + it('matches the reference evaluator across bundled CCL cases', async () => { + const casesDir = fileURLToPath(new URL('../../../ccl_v0_1/tests/cases', import.meta.url)); + const policiesDir = fileURLToPath(new URL('../../../ccl_v0_1/policies', import.meta.url)); + const caseFiles = (await readdir(casesDir)).filter(name => name.endsWith('.yaml')).sort(); + + for (const caseFile of caseFiles) { + const testCase = loadYaml(join(casesDir, caseFile)); + const policyBody = await readFile(join(policiesDir, testCase.policy), 'utf8'); + const parsed = parseCclPolicy(policyBody); + const agentResult = new CclEvaluator(parsed, testCase.facts).run(); + const referenceResult = new ReferenceEvaluator(parsed, testCase.facts).run(); + expect(agentResult).toEqual(referenceResult); + expect(agentResult).toEqual(testCase.expected); + } + }); }); describe('Node Roles', () => { diff --git a/packages/agent/test/e2e-flows.test.ts b/packages/agent/test/e2e-flows.test.ts index d7dc95b49..af4237a72 100644 --- a/packages/agent/test/e2e-flows.test.ts +++ b/packages/agent/test/e2e-flows.test.ts @@ -27,6 +27,59 @@ afterEach(async () => { function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)); } +function literal(value: unknown) { + return JSON.stringify(String(value)); +} + +function jsonLiteral(value: unknown) { + return JSON.stringify(JSON.stringify(value)); +} + +function buildSnapshotFactQuads(paranetId: string, snapshotId: string, scopeUal: string, facts: Array<[string, ...unknown[]]>) { + return facts.flatMap((fact, index) => { + const [predicate, ...args] = fact; + const subject = `did:dkg:ccl-fact:${paranetId}:${snapshotId}:${index}`; + return [ + { + subject, + predicate: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type', + object: 'https://example.org/ccl-fact#InputFact', + graph: '', + }, + { + subject, + predicate: 'https://example.org/ccl-fact#predicate', + object: literal(predicate), + graph: '', + }, + ...args.map((arg, argIndex) => ({ + subject, + predicate: `https://example.org/ccl-fact#arg${argIndex}`, + object: jsonLiteral(arg), + graph: '', + })), + { + subject, + predicate: 'https://dkg.network/ontology#snapshotId', + object: literal(snapshotId), + graph: '', + }, + { + subject, + predicate: 'https://dkg.network/ontology#view', + object: literal('accepted'), + graph: '', + }, + { + subject, + predicate: 'https://dkg.network/ontology#scopeUal', + object: literal(scopeUal), + graph: '', + }, + ]; + }); +} + // --------------------------------------------------------------------------- // Publish + Query (single agent) // --------------------------------------------------------------------------- @@ -134,6 +187,104 @@ describe('Publish → Replicate → Query (two agents)', () => { }, 20000); }); +describe('CCL snapshot-resolved evaluation (two agents)', () => { + it('resolves the same snapshot facts on both nodes and evaluates without caller facts', async () => { + const agentA = await DKGAgent.create({ + name: 'CclSnapshotA', listenPort: 0, skills: [], chainAdapter: new MockChainAdapter(), + }); + const agentB = await DKGAgent.create({ + name: 'CclSnapshotB', listenPort: 0, skills: [], chainAdapter: new MockChainAdapter(), + }); + agents.push(agentA, agentB); + + await agentA.start(); + await agentB.start(); + await agentB.connectTo(agentA.multiaddrs[0]); + await sleep(1000); + + await agentA.createParanet({ id: 'ccl-snapshot-e2e', name: 'CCL Snapshot', description: '' }); + agentA.subscribeToParanet('ccl-snapshot-e2e'); + agentB.subscribeToParanet('ccl-snapshot-e2e'); + await sleep(1000); + + const published = await agentA.publishCclPolicy({ + paranetId: 'ccl-snapshot-e2e', + name: 'owner_assertion', + version: '0.1.0', + content: `policy: owner_assertion +version: 0.1.0 +rules: + - name: owner_asserted + params: [Claim] + all: + - atom: { pred: claim, args: ["$Claim"] } + - exists: + where: + - atom: { pred: owner_of, args: ["$Claim", "$Agent"] } + - atom: { pred: signed_by, args: ["$Claim", "$Agent"] } +decisions: + - name: propose_accept + params: [Claim] + all: + - atom: { pred: owner_asserted, args: ["$Claim"] } +`, + }); + await agentA.approveCclPolicy({ paranetId: 'ccl-snapshot-e2e', policyUri: published.policyUri }); + + await agentA.publish( + 'ccl-snapshot-e2e', + buildSnapshotFactQuads('ccl-snapshot-e2e', 'snap-01', 'ual:dkg:example:owner-assertion', [ + ['claim', 'p1'], + ['owner_of', 'p1', '0xalice'], + ['signed_by', 'p1', '0xalice'], + ]), + ); + + await sleep(4000); + + const resolvedA = await agentA.resolveFactsFromSnapshot({ + paranetId: 'ccl-snapshot-e2e', + policyName: 'owner_assertion', + snapshotId: 'snap-01', + view: 'accepted', + scopeUal: 'ual:dkg:example:owner-assertion', + }); + const resolvedB = await agentB.resolveFactsFromSnapshot({ + paranetId: 'ccl-snapshot-e2e', + policyName: 'owner_assertion', + snapshotId: 'snap-01', + view: 'accepted', + scopeUal: 'ual:dkg:example:owner-assertion', + }); + + expect(resolvedA.facts).toEqual(resolvedB.facts); + expect(resolvedA.factSetHash).toBe(resolvedB.factSetHash); + expect(resolvedA.factQueryHash).toBe(resolvedB.factQueryHash); + expect(resolvedA.factResolutionMode).toBe('snapshot-resolved'); + + const evaluationA = await agentA.evaluateCclPolicy({ + paranetId: 'ccl-snapshot-e2e', + name: 'owner_assertion', + snapshotId: 'snap-01', + view: 'accepted', + scopeUal: 'ual:dkg:example:owner-assertion', + }); + const evaluationB = await agentB.evaluateCclPolicy({ + paranetId: 'ccl-snapshot-e2e', + name: 'owner_assertion', + snapshotId: 'snap-01', + view: 'accepted', + scopeUal: 'ual:dkg:example:owner-assertion', + }); + + expect(evaluationA.factResolutionMode).toBe('snapshot-resolved'); + expect(evaluationA.factSetHash).toBe(evaluationB.factSetHash); + expect(evaluationA.result).toEqual(evaluationB.result); + expect(evaluationA.result.derived.owner_asserted).toEqual([['p1']]); + expect(evaluationA.result.decisions.propose_accept).toEqual([['p1']]); + }, 30000); +}); + // --------------------------------------------------------------------------- // Update flow // --------------------------------------------------------------------------- diff --git a/packages/cli/src/api-client.ts b/packages/cli/src/api-client.ts index 1ec211441..f779a6a6a 100644 --- a/packages/cli/src/api-client.ts +++ b/packages/cli/src/api-client.ts @@ -274,7 +274,7 @@ export class ApiClient { async evaluateCclPolicy(request: { paranetId: string; name: string; - facts: Array<[string, ...unknown[]]>; + facts?: Array<[string, ...unknown[]]>; contextType?: string; view?: string; snapshotId?: string; @@ -284,6 +284,9 @@ export class ApiClient { policy: any; context: any; factSetHash: string; + factQueryHash: string; + factResolverVersion: string; + factResolutionMode: 'manual' | 'snapshot-resolved'; result: any; }> { return this.post('/api/ccl/eval', request); diff --git a/packages/cli/src/daemon.ts b/packages/cli/src/daemon.ts index fae5b5509..dc19e827c 100644 --- a/packages/cli/src/daemon.ts +++ b/packages/cli/src/daemon.ts @@ -2141,8 +2141,11 @@ async function handleRequest( if (req.method === 'POST' && path === '/api/ccl/eval') { const body = await readBody(req, SMALL_BODY_BYTES * 8); const { paranetId, name, facts, contextType, view, snapshotId, scopeUal, publishResult } = JSON.parse(body); - if (!paranetId || !name || !Array.isArray(facts)) { - return jsonResponse(res, 400, { error: 'Missing required fields: paranetId, name, facts[]' }); + if (!paranetId || !name) { + return jsonResponse(res, 400, { error: 'Missing required fields: paranetId, name' }); + } + if (facts != null && !Array.isArray(facts)) { + return jsonResponse(res, 400, { error: 'facts must be an array when provided' }); } const result = publishResult ? await agent.evaluateAndPublishCclPolicy({ paranetId, name, facts, contextType, view, snapshotId, scopeUal }) @@ -2425,12 +2428,15 @@ function parsePublishRequestBody(body: string): function jsonResponse(res: ServerResponse, status: number, data: unknown): void { + const body = JSON.stringify(data, (_key, value) => + typeof value === 'bigint' ? value.toString() : value, + ); res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', }); - res.end(JSON.stringify(data)); + res.end(body); } const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB — default for data-heavy endpoints (publish, update) diff --git a/packages/core/src/genesis.ts b/packages/core/src/genesis.ts index c15cfd704..66403141a 100644 --- a/packages/core/src/genesis.ts +++ b/packages/core/src/genesis.ts @@ -188,6 +188,9 @@ export const DKG_ONTOLOGY = { DKG_CCL_RESULT_ENTRY: `${DKG}CCLResultEntry`, DKG_EVALUATED_POLICY: `${DKG}evaluatedPolicy`, DKG_FACT_SET_HASH: `${DKG}factSetHash`, + DKG_FACT_QUERY_HASH: `${DKG}factQueryHash`, + DKG_FACT_RESOLVER_VERSION: `${DKG}factResolverVersion`, + DKG_FACT_RESOLUTION_MODE: `${DKG}factResolutionMode`, DKG_SCOPE_UAL: `${DKG}scopeUal`, DKG_VIEW: `${DKG}view`, DKG_SNAPSHOT_ID: `${DKG}snapshotId`, diff --git a/packages/core/test/genesis.test.ts b/packages/core/test/genesis.test.ts index 6d79d2564..0d18a0417 100644 --- a/packages/core/test/genesis.test.ts +++ b/packages/core/test/genesis.test.ts @@ -143,7 +143,8 @@ describe('DKG_ONTOLOGY', () => { 'DKG_POLICY_HASH', 'DKG_POLICY_BODY', 'DKG_POLICY_STATUS', 'DKG_POLICY_CONTEXT_TYPE', 'DKG_ACTIVE_POLICY', 'DKG_APPROVED_BY', 'DKG_APPROVED_AT', 'DKG_CCL_EVALUATION', 'DKG_CCL_RESULT_ENTRY', - 'DKG_EVALUATED_POLICY', 'DKG_FACT_SET_HASH', 'DKG_SCOPE_UAL', + 'DKG_EVALUATED_POLICY', 'DKG_FACT_SET_HASH', 'DKG_FACT_QUERY_HASH', + 'DKG_FACT_RESOLVER_VERSION', 'DKG_FACT_RESOLUTION_MODE', 'DKG_SCOPE_UAL', 'DKG_VIEW', 'DKG_SNAPSHOT_ID', 'DKG_RESULT_KIND', 'DKG_RESULT_NAME', 'DKG_HAS_RESULT', 'DKG_CCL_RESULT_ARG', 'DKG_HAS_RESULT_ARG', 'DKG_RESULT_ARG_INDEX', 'DKG_RESULT_ARG_VALUE', diff --git a/packages/origin-trail-game/test/e2e/game-ccl-e2e.test.ts b/packages/origin-trail-game/test/e2e/game-ccl-e2e.test.ts new file mode 100644 index 000000000..0ed5487f5 --- /dev/null +++ b/packages/origin-trail-game/test/e2e/game-ccl-e2e.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { startTestCluster, stopTestCluster, nodeApi, sleep, type TestNode } from './helpers.js'; + +const PARANET_ID = 'game-ccl-e2e'; +const POLICY_NAME = 'game-readiness'; +const POLICY_VERSION = '0.1.0'; + +function buildGameFacts(swarm: any): Array<[string, ...unknown[]]> { + const facts: Array<[string, ...unknown[]]> = [ + ['swarm', swarm.id], + ['current_turn', swarm.id, swarm.currentTurn], + ['player_count', swarm.id, swarm.playerCount], + ['game_status', swarm.id, swarm.gameState.status], + ]; + + if ((swarm.gameState?.epochs ?? 0) > 0) { + facts.push(['epochs_positive', swarm.id]); + } + + for (const player of swarm.players ?? []) { + facts.push(['player', swarm.id, player.name]); + } + + return facts; +} + +const POLICY_BODY = `policy: ${POLICY_NAME} +version: ${POLICY_VERSION} +rules: + - name: ready_swarm + params: [Swarm] + all: + - atom: { pred: swarm, args: ["$Swarm"] } + - atom: { pred: player_count, args: ["$Swarm", 3] } + - atom: { pred: game_status, args: ["$Swarm", "active"] } + - atom: { pred: epochs_positive, args: ["$Swarm"] } + - count_distinct: + vars: [Player] + where: + - atom: { pred: player, args: ["$Swarm", "$Player"] } + op: ">=" + value: 3 +decisions: + - name: propose_continue + params: [Swarm] + all: + - atom: { pred: ready_swarm, args: ["$Swarm"] } +`; + +describe('OriginTrail Game CCL e2e', () => { + let nodes: TestNode[]; + let apiA: ReturnType; + let apiB: ReturnType; + let apiC: ReturnType; + let swarmId: string; + + beforeAll(async () => { + nodes = await startTestCluster(3); + apiA = nodeApi(nodes[0]); + apiB = nodeApi(nodes[1]); + apiC = nodeApi(nodes[2]); + }, 120_000); + + afterAll(async () => { + if (nodes) await stopTestCluster(nodes); + }, 30_000); + + it('evaluates a CCL policy against live game state and publishes the result', async () => { + await apiA.createParanet(PARANET_ID, 'Game CCL E2E', 'CCL policy evaluation using OriginTrail Game state'); + + const published = await apiA.publishCclPolicy({ + paranetId: PARANET_ID, + name: POLICY_NAME, + version: POLICY_VERSION, + content: POLICY_BODY, + description: 'Promote swarms that reached active play with a full party.', + }); + + expect(published.policyUri).toContain('did:dkg:policy:'); + expect(published.status).toBe('proposed'); + + const approved = await apiA.approveCclPolicy({ + paranetId: PARANET_ID, + policyUri: published.policyUri, + }); + expect(approved.policyUri).toBe(published.policyUri); + + const resolved = await apiA.resolveCclPolicy(PARANET_ID, POLICY_NAME, { includeBody: true }); + expect(resolved.policy?.policyUri).toBe(published.policyUri); + expect(resolved.policy?.body).toContain('ready_swarm'); + + const created = await apiA.create('Alice', 'Consensus Caravan'); + swarmId = created.id; + + await sleep(1500); + await apiB.join(swarmId, 'Bob'); + await sleep(1000); + await apiC.join(swarmId, 'Charlie'); + await sleep(2000); + + const started = await apiA.start(swarmId); + expect(started.status).toBe('traveling'); + + await sleep(2000); + await apiA.vote(swarmId, 'advance', { pace: 2 }); + await sleep(500); + await apiB.vote(swarmId, 'advance', { pace: 2 }); + await sleep(500); + await apiC.vote(swarmId, 'advance', { pace: 2 }); + await sleep(5000); + + const swarm = await apiA.swarm(swarmId); + expect(swarm.currentTurn).toBeGreaterThanOrEqual(2); + expect(swarm.gameState.status).toBe('active'); + expect(swarm.gameState.epochs).toBeGreaterThan(0); + + const facts = buildGameFacts(swarm); + const snapshotId = `game-snapshot-${swarm.currentTurn}`; + + const evaluation = await apiA.evaluateCclPolicy({ + paranetId: PARANET_ID, + name: POLICY_NAME, + facts, + snapshotId, + }); + + expect(evaluation.policy.policyUri).toBe(published.policyUri); + expect(evaluation.result.derived.ready_swarm).toEqual([[swarmId]]); + expect(evaluation.result.decisions.propose_continue).toEqual([[swarmId]]); + + const publishedEvaluation = await apiA.evaluateCclPolicy({ + paranetId: PARANET_ID, + name: POLICY_NAME, + facts, + snapshotId, + publishResult: true, + }); + + expect(publishedEvaluation.evaluationUri).toContain('did:dkg:ccl-eval:'); + expect(publishedEvaluation.publish.status).toBeDefined(); + expect(publishedEvaluation.evaluation.result.decisions.propose_continue).toEqual([[swarmId]]); + + const listed = await apiA.listCclEvaluations(PARANET_ID, { + snapshotId, + resultKind: 'decision', + resultName: 'propose_continue', + }); + + expect(listed.evaluations).toHaveLength(1); + expect(listed.evaluations[0].evaluationUri).toBe(publishedEvaluation.evaluationUri); + expect(listed.evaluations[0].policyUri).toBe(published.policyUri); + expect(listed.evaluations[0].results).toEqual([ + expect.objectContaining({ + kind: 'decision', + name: 'propose_continue', + tuple: [swarmId], + }), + ]); + }, 90_000); + + it('does not propose continuation for a recruiting swarm without enough players', async () => { + const created = await apiA.create('Dana', 'Half-Full Caravan'); + + expect(created.status).toBe('recruiting'); + expect(created.playerCount).toBe(1); + + const swarm = await apiA.swarm(created.id); + const facts = buildGameFacts({ + ...swarm, + gameState: swarm.gameState ?? { status: 'recruiting' }, + }); + + const evaluation = await apiA.evaluateCclPolicy({ + paranetId: PARANET_ID, + name: POLICY_NAME, + facts, + snapshotId: `recruiting-${created.id}`, + }); + + expect(evaluation.result.derived.ready_swarm).toEqual([]); + expect(evaluation.result.decisions.propose_continue).toEqual([]); + }, 30_000); +}); diff --git a/packages/origin-trail-game/test/e2e/helpers.ts b/packages/origin-trail-game/test/e2e/helpers.ts index b47c23a31..ccb812893 100644 --- a/packages/origin-trail-game/test/e2e/helpers.ts +++ b/packages/origin-trail-game/test/e2e/helpers.ts @@ -376,6 +376,57 @@ export function nodeApi(node: TestNode) { return { status: () => httpGet(`${base}/api/status`), apps: () => httpGet(`${base}/api/apps`, token), + createParanet: (id: string, name: string, description?: string) => + httpPost(`${base}/api/paranet/create`, { id, name, description }, token), + listParanets: () => httpGet(`${base}/api/paranet/list`, token), + publishCclPolicy: (body: { + paranetId: string; + name: string; + version: string; + content: string; + description?: string; + contextType?: string; + language?: string; + format?: string; + }) => httpPost(`${base}/api/ccl/policy/publish`, body, token), + approveCclPolicy: (body: { + paranetId: string; + policyUri: string; + contextType?: string; + }) => httpPost(`${base}/api/ccl/policy/approve`, body, token), + resolveCclPolicy: (paranetId: string, name: string, opts?: { contextType?: string; includeBody?: boolean }) => { + const params = new URLSearchParams({ paranetId, name }); + if (opts?.contextType) params.set('contextType', opts.contextType); + if (opts?.includeBody) params.set('includeBody', 'true'); + return httpGet(`${base}/api/ccl/policy/resolve?${params.toString()}`, token); + }, + evaluateCclPolicy: (body: { + paranetId: string; + name: string; + facts: Array<[string, ...unknown[]]>; + contextType?: string; + view?: string; + snapshotId?: string; + scopeUal?: string; + publishResult?: boolean; + }) => httpPost(`${base}/api/ccl/eval`, body, token), + listCclEvaluations: (paranetId: string, opts?: { + policyUri?: string; + snapshotId?: string; + view?: string; + contextType?: string; + resultKind?: 'derived' | 'decision'; + resultName?: string; + }) => { + const params = new URLSearchParams({ paranetId }); + if (opts?.policyUri) params.set('policyUri', opts.policyUri); + if (opts?.snapshotId) params.set('snapshotId', opts.snapshotId); + if (opts?.view) params.set('view', opts.view); + if (opts?.contextType) params.set('contextType', opts.contextType); + if (opts?.resultKind) params.set('resultKind', opts.resultKind); + if (opts?.resultName) params.set('resultName', opts.resultName); + return httpGet(`${base}/api/ccl/results?${params.toString()}`, token); + }, info: () => httpGet(`${game}/info`, token), lobby: () => httpGet(`${game}/lobby`, token), swarm: (id: string) => httpGet(`${game}/swarm/${id}`, token), From 46a5a33916f4ef558426c88dd6f3782eab8b5e20 Mon Sep 17 00:00:00 2001 From: Viktor Pelle Date: Mon, 30 Mar 2026 13:34:25 +0200 Subject: [PATCH 3/3] CCL updates --- ccl_v0_1/README.md | 11 +- ccl_v0_1/SIGNED_APPROVAL_ENVELOPES.md | 81 +++++++++ packages/agent/src/ccl-policy.ts | 18 ++ packages/agent/src/dkg-agent.ts | 161 ++++++++++++++---- packages/agent/src/gossip-publish-handler.ts | 62 +++++++ packages/agent/test/agent.test.ts | 148 +++++++++++++++- .../agent/test/gossip-publish-handler.test.ts | 119 ++++++++++++- packages/cli/src/api-client.ts | 8 + packages/cli/src/cli.ts | 22 ++- packages/cli/src/daemon.ts | 11 ++ packages/cli/test/api-client.test.ts | 10 ++ packages/core/src/genesis.ts | 3 + packages/core/test/genesis.test.ts | 5 +- 13 files changed, 617 insertions(+), 42 deletions(-) create mode 100644 ccl_v0_1/SIGNED_APPROVAL_ENVELOPES.md diff --git a/ccl_v0_1/README.md b/ccl_v0_1/README.md index 0160c36f6..eeb684aae 100644 --- a/ccl_v0_1/README.md +++ b/ccl_v0_1/README.md @@ -129,9 +129,16 @@ CCL produces two kinds of outputs: A decision is still **non-authoritative** until a normal DKG `PUBLISH` introduces it as a typed transition into shared state. -## Current lifecycle limitation +## Policy lifecycle and supersession -CCL v0.1 supports `publish -> approve -> resolve -> evaluate`, but does not yet include explicit policy revocation or deactivation. If multiple approvals exist for the same `paranetId + policy name + context`, resolution currently selects the most recently approved binding for that scope. +CCL policy bindings now support explicit revocation in addition to `publish -> approve -> resolve -> evaluate`. + +- approving a newer binding in the same scope supersedes older bindings for resolution purposes +- revoking the currently active binding removes it from selection without deleting audit history +- resolution selects the latest non-revoked binding for the exact context +- if no exact-context binding remains active, resolution falls back to the latest non-revoked default binding + +This makes supersession explicit while preserving old bindings for auditability. ## Included policies diff --git a/ccl_v0_1/SIGNED_APPROVAL_ENVELOPES.md b/ccl_v0_1/SIGNED_APPROVAL_ENVELOPES.md new file mode 100644 index 000000000..f46cda40d --- /dev/null +++ b/ccl_v0_1/SIGNED_APPROVAL_ENVELOPES.md @@ -0,0 +1,81 @@ +# Signed Approval Envelopes + +This note sketches the long-term replacement for trusting raw ontology binding quads during CCL policy approval gossip. + +## Goal + +Make policy approval and revocation verifiable from a signed payload, not from trust in the sending peer. + +## Envelope shape + +Each approval or revocation should be broadcast with a detached, signed envelope containing: + +- `type`: `ccl-policy-approval` or `ccl-policy-revocation` +- `paranetId` +- `policyUri` +- `policyName` +- `contextType` when scoped +- `bindingUri` +- `status`: `approved` or `revoked` +- `approvedAt` or `revokedAt` +- `actorDid`: expected paranet owner DID +- `chainId` +- `nonce` or monotonic sequence value +- `payloadHash`: hash of the canonical RDF quads being asserted +- `signature` + +## Canonicalization + +The signer should sign a canonical JSON payload, not raw RDF serialization. That avoids signature drift from harmless quad reordering. + +Recommended canonical payload rules: + +- UTF-8 JSON +- lexicographically sorted keys +- omit undefined fields +- timestamps in ISO-8601 UTC +- `payloadHash` derived from sorted canonical quads + +## Verification flow + +On gossip ingest, peers should: + +1. parse the envelope +2. resolve the locally known paranet owner +3. ensure `actorDid` matches the current owner +4. recompute `payloadHash` from the incoming binding quads +5. verify the signature against the owner key +6. insert quads only if verification succeeds + +If any step fails, reject the approval or revocation binding and log the reason. + +## Keying options + +Two realistic options: + +- reuse the existing agent wallet signing key and bind it to the paranet owner DID +- introduce a dedicated approval-signing key referenced from the paranet profile + +The first option is simpler for v0.x. The second is cleaner if approval authority needs rotation without changing the node identity key. + +## Replay protection + +Signed envelopes should include replay resistance. Acceptable options: + +- `nonce` tracked per `(paranetId, contextType)` +- monotonic sequence number per binding scope +- chain-anchored version or block reference + +Without replay protection, a revoked approval could be replayed later even if the signature is valid. + +## Migration path + +Short term: + +- keep local owner-state validation on raw binding quads +- optionally attach unsigned envelope fields for observability + +Next step: + +- require a signed envelope for all new approval/revocation gossip +- continue reading legacy bindings locally, but reject unsigned peer gossip by default once the network is upgraded diff --git a/packages/agent/src/ccl-policy.ts b/packages/agent/src/ccl-policy.ts index 4a204bc6d..fc2e6508e 100644 --- a/packages/agent/src/ccl-policy.ts +++ b/packages/agent/src/ccl-policy.ts @@ -39,7 +39,11 @@ export interface PolicyApprovalBinding { paranetId: string; name: string; contextType?: string; + status: 'approved' | 'revoked'; approvedAt: string; + approvedBy?: string; + revokedAt?: string; + revokedBy?: string; } export function hashCclPolicy(content: string): string { @@ -114,6 +118,7 @@ export function buildPolicyApprovalQuads(opts: { { subject: bindingUri, predicate: DKG_ONTOLOGY.DKG_POLICY_APPLIES_TO_PARANET, object: paranetUri, graph: opts.graph }, { subject: bindingUri, predicate: DKG_ONTOLOGY.SCHEMA_NAME, object: sparqlString(opts.policyName), graph: opts.graph }, { subject: bindingUri, predicate: DKG_ONTOLOGY.DKG_ACTIVE_POLICY, object: opts.policyUri, graph: opts.graph }, + { subject: bindingUri, predicate: DKG_ONTOLOGY.DKG_POLICY_BINDING_STATUS, object: sparqlString('approved'), graph: opts.graph }, { subject: bindingUri, predicate: DKG_ONTOLOGY.DKG_APPROVED_BY, object: opts.creator, graph: opts.graph }, { subject: bindingUri, predicate: DKG_ONTOLOGY.DKG_APPROVED_AT, object: sparqlString(opts.approvedAt), graph: opts.graph }, { subject: bindingUri, predicate: DKG_ONTOLOGY.DKG_CREATED_AT, object: sparqlString(opts.approvedAt), graph: opts.graph }, @@ -131,6 +136,19 @@ export function buildPolicyApprovalQuads(opts: { return { bindingUri, quads }; } +export function buildPolicyRevocationQuads(opts: { + bindingUri: string; + revoker: string; + graph: string; + revokedAt: string; +}): Quad[] { + return [ + { subject: opts.bindingUri, predicate: DKG_ONTOLOGY.DKG_POLICY_BINDING_STATUS, object: sparqlString('revoked'), graph: opts.graph }, + { subject: opts.bindingUri, predicate: DKG_ONTOLOGY.DKG_REVOKED_BY, object: opts.revoker, graph: opts.graph }, + { subject: opts.bindingUri, predicate: DKG_ONTOLOGY.DKG_REVOKED_AT, object: sparqlString(opts.revokedAt), graph: opts.graph }, + ]; +} + function encodeSegment(value: string): string { return encodeURIComponent(value).replace(/%/g, '_'); } diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index ac245a5b4..b3219434b 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -32,7 +32,7 @@ import { AGENT_REGISTRY_PARANET, type AgentProfileConfig } from './profile.js'; import { GossipPublishHandler } from './gossip-publish-handler.js'; import { FinalizationHandler } from './finalization-handler.js'; import { multiaddr } from '@multiformats/multiaddr'; -import { buildCclPolicyQuads, buildPolicyApprovalQuads, hashCclPolicy, type CclPolicyRecord, type PolicyApprovalBinding } from './ccl-policy.js'; +import { buildCclPolicyQuads, buildPolicyApprovalQuads, buildPolicyRevocationQuads, hashCclPolicy, type CclPolicyRecord, type PolicyApprovalBinding } from './ccl-policy.js'; import { CclEvaluator, parseCclPolicy, validateCclPolicy, type CclEvaluationResult, type CclFactTuple } from './ccl-evaluator.js'; import { buildCclEvaluationQuads } from './ccl-evaluation-publish.js'; import { buildManualCclFacts, resolveFactsFromSnapshot, type CclFactResolutionMode } from './ccl-fact-resolution.js'; @@ -1634,6 +1634,7 @@ export class DKGAgent { this.subscribedParanets, { paranetExists: (id) => this.paranetExists(id), + getParanetOwner: (id) => this.getParanetOwner(id), subscribeToParanet: (id, options) => this.subscribeToParanet(id, options), }, ); @@ -2024,6 +2025,38 @@ export class DKGAgent { return { policyUri: opts.policyUri, bindingUri, contextType: effectiveContextType, approvedAt }; } + async revokeCclPolicy(opts: { + paranetId: string; + policyUri: string; + contextType?: string; + }): Promise<{ policyUri: string; bindingUri: string; contextType?: string; revokedAt: string; status: 'revoked' }> { + const ctx = createOperationContext('system'); + await this.assertParanetOwner(opts.paranetId); + + const target = await this.getActiveCclPolicyBinding({ + paranetId: opts.paranetId, + policyUri: opts.policyUri, + contextType: opts.contextType, + }); + if (!target) { + throw new Error(`No active CCL policy binding found for ${opts.policyUri} in paranet "${opts.paranetId}"${opts.contextType ? ` and context "${opts.contextType}"` : ''}.`); + } + + const ontologyGraph = paranetDataGraphUri(SYSTEM_PARANETS.ONTOLOGY); + const revokedAt = new Date().toISOString(); + const quads = buildPolicyRevocationQuads({ + bindingUri: target.bindingUri, + revoker: `did:dkg:agent:${this.peerId}`, + graph: ontologyGraph, + revokedAt, + }); + + await this.store.insert(quads); + await this.publishOntologyQuads(target.bindingUri, quads); + this.log.info(ctx, `Revoked CCL policy binding ${target.bindingUri} for paranet "${opts.paranetId}"${target.contextType ? ` (context ${target.contextType})` : ''}`); + return { policyUri: opts.policyUri, bindingUri: target.bindingUri, contextType: target.contextType, revokedAt, status: 'revoked' }; + } + async listCclPolicies(opts: { paranetId?: string; name?: string; @@ -2036,7 +2069,6 @@ export class DKGAgent { if (opts.paranetId) filters.push(`?paranet = `); if (opts.name) filters.push(`?name = ${sparqlString(opts.name)}`); if (opts.contextType) filters.push(`?contextType = ${sparqlString(opts.contextType)}`); - if (opts.status) filters.push(`?status = ${sparqlString(opts.status)}`); const filterBlock = filters.length > 0 ? `FILTER(${filters.join(' && ')})` : ''; const bodyClause = opts.includeBody ? `OPTIONAL { ?policy <${DKG_ONTOLOGY.DKG_POLICY_BODY}> ?body }` : ''; @@ -2065,14 +2097,7 @@ export class DKGAgent { `); const bindings = await this.listCclPolicyBindings({ paranetId: opts.paranetId, name: opts.name }); - const latestByScope = new Map(); - for (const binding of bindings) { - const key = `${binding.paranetId}|${binding.name}|${binding.contextType ?? ''}`; - const current = latestByScope.get(key); - if (!current || binding.approvedAt > current.approvedAt) { - latestByScope.set(key, binding); - } - } + const latestByScope = this.selectLatestNonRevokedBindings(bindings); const records = new Map(); if (result.type === 'bindings') { @@ -2093,7 +2118,7 @@ export class DKGAgent { hash: stripLiteral(row['hash']), language: stripLiteral(row['language']), format: stripLiteral(row['format']), - status: stripLiteral(row['status']), + status: this.deriveCclPolicyStatus(row['policy'], stripLiteral(row['status']), bindings, latestByScope), creator: row['creator'], createdAt: row['created'] ? stripLiteral(row['created']) : undefined, approvedBy: row['approvedBy'], @@ -2112,7 +2137,9 @@ export class DKGAgent { } } - return Array.from(records.values()).sort((a, b) => `${a.name}@${a.version}`.localeCompare(`${b.name}@${b.version}`)); + return Array.from(records.values()) + .filter(record => !opts.status || record.status === opts.status) + .sort((a, b) => `${a.name}@${a.version}`.localeCompare(`${b.name}@${b.version}`)); } async resolveCclPolicy(opts: { @@ -2122,13 +2149,8 @@ export class DKGAgent { includeBody?: boolean; }): Promise { const bindings = await this.listCclPolicyBindings({ paranetId: opts.paranetId, name: opts.name }); - const matching = bindings - .filter(binding => binding.contextType === opts.contextType) - .sort((a, b) => b.approvedAt.localeCompare(a.approvedAt)); - const fallback = bindings - .filter(binding => binding.contextType == null) - .sort((a, b) => b.approvedAt.localeCompare(a.approvedAt)); - const selected = matching[0] ?? fallback[0]; + const latestByScope = this.selectLatestNonRevokedBindings(bindings); + const selected = this.resolveCclPolicyBinding(latestByScope, opts.paranetId, opts.name, opts.contextType); if (!selected) return null; const record = await this.getCclPolicyByUri(selected.policyUri, { includeBody: opts.includeBody }); if (!record) return null; @@ -2511,10 +2533,10 @@ export class DKGAgent { const owner = await this.getParanetOwner(paranetId); const current = `did:dkg:agent:${this.peerId}`; if (!owner) { - throw new Error(`Paranet "${paranetId}" has no registered owner; cannot approve policies.`); + throw new Error(`Paranet "${paranetId}" has no registered owner; cannot manage policies.`); } if (owner !== current) { - throw new Error(`Only the paranet owner can approve policies for "${paranetId}". Owner=${owner}, current=${current}`); + throw new Error(`Only the paranet owner can manage policies for "${paranetId}". Owner=${owner}, current=${current}`); } } @@ -2543,13 +2565,17 @@ export class DKGAgent { if (opts.name) filters.push(`?name = ${sparqlString(opts.name)}`); const filterBlock = filters.length > 0 ? `FILTER(${filters.join(' && ')})` : ''; const result = await this.store.query(` - SELECT ?binding ?policy ?paranet ?name ?contextType ?approvedAt WHERE { + SELECT ?binding ?policy ?paranet ?name ?contextType ?bindingStatus ?approvedAt ?approvedBy ?revokedAt ?revokedBy WHERE { GRAPH <${ontologyGraph}> { ?binding <${DKG_ONTOLOGY.RDF_TYPE}> <${DKG_ONTOLOGY.DKG_POLICY_BINDING}> ; <${DKG_ONTOLOGY.DKG_POLICY_APPLIES_TO_PARANET}> ?paranet ; <${DKG_ONTOLOGY.SCHEMA_NAME}> ?name ; <${DKG_ONTOLOGY.DKG_ACTIVE_POLICY}> ?policy ; <${DKG_ONTOLOGY.DKG_APPROVED_AT}> ?approvedAt . + OPTIONAL { ?binding <${DKG_ONTOLOGY.DKG_POLICY_BINDING_STATUS}> ?bindingStatus } + OPTIONAL { ?binding <${DKG_ONTOLOGY.DKG_APPROVED_BY}> ?approvedBy } + OPTIONAL { ?binding <${DKG_ONTOLOGY.DKG_REVOKED_AT}> ?revokedAt } + OPTIONAL { ?binding <${DKG_ONTOLOGY.DKG_REVOKED_BY}> ?revokedBy } OPTIONAL { ?binding <${DKG_ONTOLOGY.DKG_POLICY_CONTEXT_TYPE}> ?contextType } ${filterBlock} } @@ -2558,14 +2584,89 @@ export class DKGAgent { `); if (result.type !== 'bindings') return []; - return (result.bindings as Record[]).map((row) => ({ - bindingUri: row['binding'], - policyUri: row['policy'], - paranetId: row['paranet'].startsWith('did:dkg:paranet:') ? row['paranet'].slice('did:dkg:paranet:'.length) : row['paranet'], - name: stripLiteral(row['name']), - contextType: row['contextType'] ? stripLiteral(row['contextType']) : undefined, - approvedAt: stripLiteral(row['approvedAt']), - })); + const byBinding = new Map(); + for (const row of result.bindings as Record[]) { + const bindingUri = row['binding']; + const revokedAt = row['revokedAt'] ? stripLiteral(row['revokedAt']) : undefined; + const next: PolicyApprovalBinding = { + bindingUri, + policyUri: row['policy'], + paranetId: row['paranet'].startsWith('did:dkg:paranet:') ? row['paranet'].slice('did:dkg:paranet:'.length) : row['paranet'], + name: stripLiteral(row['name']), + contextType: row['contextType'] ? stripLiteral(row['contextType']) : undefined, + status: revokedAt || (row['bindingStatus'] && stripLiteral(row['bindingStatus']) === 'revoked') ? 'revoked' : 'approved', + approvedAt: stripLiteral(row['approvedAt']), + approvedBy: row['approvedBy'], + revokedAt, + revokedBy: row['revokedBy'], + }; + const current = byBinding.get(bindingUri); + if (!current) { + byBinding.set(bindingUri, next); + continue; + } + byBinding.set(bindingUri, { + ...current, + status: current.status === 'revoked' || next.status === 'revoked' ? 'revoked' : 'approved', + revokedAt: current.revokedAt ?? next.revokedAt, + revokedBy: current.revokedBy ?? next.revokedBy, + approvedBy: current.approvedBy ?? next.approvedBy, + }); + } + return Array.from(byBinding.values()).sort((a, b) => b.approvedAt.localeCompare(a.approvedAt)); + } + + private selectLatestNonRevokedBindings(bindings: PolicyApprovalBinding[]): Map { + const latestByScope = new Map(); + for (const binding of bindings) { + if (binding.status === 'revoked') continue; + const key = `${binding.paranetId}|${binding.name}|${binding.contextType ?? ''}`; + const current = latestByScope.get(key); + if (!current || binding.approvedAt > current.approvedAt) { + latestByScope.set(key, binding); + } + } + return latestByScope; + } + + private resolveCclPolicyBinding( + latestByScope: Map, + paranetId: string, + name: string, + contextType?: string, + ): PolicyApprovalBinding | null { + return latestByScope.get(`${paranetId}|${name}|${contextType ?? ''}`) + ?? latestByScope.get(`${paranetId}|${name}|`) + ?? null; + } + + private async getActiveCclPolicyBinding(opts: { + paranetId: string; + policyUri: string; + contextType?: string; + }): Promise { + const record = await this.getCclPolicyByUri(opts.policyUri); + if (!record) return null; + const bindings = await this.listCclPolicyBindings({ paranetId: opts.paranetId, name: record.name }); + const latestByScope = this.selectLatestNonRevokedBindings(bindings); + const active = this.resolveCclPolicyBinding(latestByScope, opts.paranetId, record.name, opts.contextType); + if (!active || active.policyUri !== opts.policyUri) return null; + return active; + } + + private deriveCclPolicyStatus( + policyUri: string, + storedStatus: string, + bindings: PolicyApprovalBinding[], + latestByScope: Map, + ): string { + if (Array.from(latestByScope.values()).some(binding => binding.policyUri === policyUri)) { + return 'approved'; + } + if (bindings.some(binding => binding.policyUri === policyUri)) { + return 'revoked'; + } + return storedStatus; } private async publishOntologyQuads(ual: string, quads: Quad[]): Promise { diff --git a/packages/agent/src/gossip-publish-handler.ts b/packages/agent/src/gossip-publish-handler.ts index 4f082d927..91dc7e798 100644 --- a/packages/agent/src/gossip-publish-handler.ts +++ b/packages/agent/src/gossip-publish-handler.ts @@ -18,6 +18,7 @@ export type GossipPhaseCallback = (phase: string, status: 'start' | 'end') => vo export interface GossipPublishHandlerCallbacks { paranetExists: (id: string) => Promise; + getParanetOwner: (id: string) => Promise; subscribeToParanet: (id: string, options?: { trackSyncScope?: boolean }) => void; onPhase?: GossipPhaseCallback; } @@ -124,6 +125,8 @@ export class GossipPublishHandler { this.log.info(ctx, `Discovered paranet "${name}" (${newId}) via gossip — auto-subscribed`); } } + + normalized = await this.filterInvalidOntologyPolicyBindings(normalized, ctx); } // Structural validation (I-002): reject malformed gossip before inserting. @@ -319,6 +322,65 @@ export class GossipPublishHandler { ); } } + + private async filterInvalidOntologyPolicyBindings(quads: Quad[], ctx: OperationContext): Promise { + const bindingSubjects = new Set( + quads + .filter(q => q.predicate === DKG_ONTOLOGY.RDF_TYPE && q.object === DKG_ONTOLOGY.DKG_POLICY_BINDING) + .map(q => q.subject), + ); + if (bindingSubjects.size === 0) return quads; + + const invalidBindings = new Set(); + for (const bindingUri of bindingSubjects) { + const bindingQuads = quads.filter(q => q.subject === bindingUri); + const paranetUri = bindingQuads.find(q => q.predicate === DKG_ONTOLOGY.DKG_POLICY_APPLIES_TO_PARANET)?.object; + const approvedAt = bindingQuads.find(q => q.predicate === DKG_ONTOLOGY.DKG_APPROVED_AT)?.object; + const approvedBy = bindingQuads.find(q => q.predicate === DKG_ONTOLOGY.DKG_APPROVED_BY)?.object; + const revokedAt = bindingQuads.find(q => q.predicate === DKG_ONTOLOGY.DKG_REVOKED_AT)?.object; + const revokedBy = bindingQuads.find(q => q.predicate === DKG_ONTOLOGY.DKG_REVOKED_BY)?.object; + const paranetId = paranetUri?.startsWith('did:dkg:paranet:') ? paranetUri.slice('did:dkg:paranet:'.length) : null; + + if (!paranetId) { + invalidBindings.add(bindingUri); + this.log.warn(ctx, `Rejected gossip policy binding ${bindingUri}: missing or invalid paranet reference`); + continue; + } + + const owner = await this.callbacks.getParanetOwner(paranetId); + if (!owner) { + invalidBindings.add(bindingUri); + this.log.warn(ctx, `Rejected gossip policy binding ${bindingUri}: paranet "${paranetId}" owner is unknown locally`); + continue; + } + + if (approvedAt && !approvedBy) { + invalidBindings.add(bindingUri); + this.log.warn(ctx, `Rejected gossip policy binding ${bindingUri}: approvedBy is required when approvedAt is present`); + continue; + } + + if (approvedBy && approvedBy !== owner) { + invalidBindings.add(bindingUri); + this.log.warn(ctx, `Rejected gossip policy binding ${bindingUri}: approvedBy ${approvedBy} does not match owner ${owner}`); + continue; + } + + if (revokedAt && !revokedBy) { + invalidBindings.add(bindingUri); + this.log.warn(ctx, `Rejected gossip policy binding ${bindingUri}: revokedBy is required when revokedAt is present`); + continue; + } + + if (revokedBy && revokedBy !== owner) { + invalidBindings.add(bindingUri); + this.log.warn(ctx, `Rejected gossip policy binding ${bindingUri}: revokedBy ${revokedBy} does not match owner ${owner}`); + } + } + + if (invalidBindings.size === 0) return quads; + return quads.filter(q => !invalidBindings.has(q.subject)); + } } function protoToNumber(val: number | { low: number; high: number; unsigned: boolean }): number { diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 9f2dddb76..7fdc5f347 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -806,6 +806,113 @@ decisions: [] await agent.stop().catch(() => {}); }); + it('falls back to the previous default policy after revoking a superseding binding', async () => { + const store = new OxigraphStore(); + const agent = await DKGAgent.create({ + name: 'RevokeDefaultBot', + store, + chainAdapter: new MockChainAdapter(), + }); + await agent.start(); + + await agent.createParanet({ id: 'ops-revoke-default', name: 'Ops Revoke Default' }); + + const v1 = await agent.publishCclPolicy({ + paranetId: 'ops-revoke-default', + name: 'incident-review', + version: '0.1.0', + content: `policy: incident-review +version: 0.1.0 +rules: [] +decisions: [] +`, + }); + const v2 = await agent.publishCclPolicy({ + paranetId: 'ops-revoke-default', + name: 'incident-review', + version: '0.2.0', + content: `policy: incident-review +version: 0.2.0 +rules: [] +decisions: [] +`, + }); + + await agent.approveCclPolicy({ paranetId: 'ops-revoke-default', policyUri: v1.policyUri }); + await agent.approveCclPolicy({ paranetId: 'ops-revoke-default', policyUri: v2.policyUri }); + + const resolvedLatest = await agent.resolveCclPolicy({ paranetId: 'ops-revoke-default', name: 'incident-review' }); + expect(resolvedLatest?.policyUri).toBe(v2.policyUri); + + const revoked = await agent.revokeCclPolicy({ paranetId: 'ops-revoke-default', policyUri: v2.policyUri }); + expect(revoked.status).toBe('revoked'); + + const resolvedFallback = await agent.resolveCclPolicy({ paranetId: 'ops-revoke-default', name: 'incident-review' }); + expect(resolvedFallback?.policyUri).toBe(v1.policyUri); + + const listed = await agent.listCclPolicies({ paranetId: 'ops-revoke-default', name: 'incident-review' }); + const revokedRecord = listed.find(policy => policy.policyUri === v2.policyUri); + const activeRecord = listed.find(policy => policy.policyUri === v1.policyUri); + expect(revokedRecord?.status).toBe('revoked'); + expect(activeRecord?.status).toBe('approved'); + expect(activeRecord?.isActiveDefault).toBe(true); + + await agent.stop().catch(() => {}); + }); + + it('falls back from a revoked context override to the default policy', async () => { + const store = new OxigraphStore(); + const agent = await DKGAgent.create({ + name: 'RevokeContextBot', + store, + chainAdapter: new MockChainAdapter(), + }); + await agent.start(); + + await agent.createParanet({ id: 'ops-revoke-context', name: 'Ops Revoke Context' }); + + const base = await agent.publishCclPolicy({ + paranetId: 'ops-revoke-context', + name: 'incident-review', + version: '0.1.0', + content: `policy: incident-review +version: 0.1.0 +rules: [] +decisions: [] +`, + }); + const override = await agent.publishCclPolicy({ + paranetId: 'ops-revoke-context', + name: 'incident-review', + version: '0.2.0', + contextType: 'incident_review', + content: `policy: incident-review +version: 0.2.0 +rules: [] +decisions: [] +`, + }); + + await agent.approveCclPolicy({ paranetId: 'ops-revoke-context', policyUri: base.policyUri }); + await agent.approveCclPolicy({ paranetId: 'ops-revoke-context', policyUri: override.policyUri, contextType: 'incident_review' }); + + const resolvedOverride = await agent.resolveCclPolicy({ paranetId: 'ops-revoke-context', name: 'incident-review', contextType: 'incident_review' }); + expect(resolvedOverride?.policyUri).toBe(override.policyUri); + + const revoked = await agent.revokeCclPolicy({ + paranetId: 'ops-revoke-context', + policyUri: override.policyUri, + contextType: 'incident_review', + }); + expect(revoked.contextType).toBe('incident_review'); + + const resolvedFallback = await agent.resolveCclPolicy({ paranetId: 'ops-revoke-context', name: 'incident-review', contextType: 'incident_review' }); + expect(resolvedFallback?.policyUri).toBe(base.policyUri); + expect(resolvedFallback?.isActiveDefault).toBe(true); + + await agent.stop().catch(() => {}); + }); + it('restricts CCL policy approval to the paranet owner', async () => { const store = new OxigraphStore(); const owner = await DKGAgent.create({ @@ -835,7 +942,7 @@ decisions: [] }); await expect(other.approveCclPolicy({ paranetId: 'ops-owner', policyUri: published.policyUri })) - .rejects.toThrow(/Only the paranet owner can approve policies/); + .rejects.toThrow(/Only the paranet owner can manage policies/); await expect(owner.approveCclPolicy({ paranetId: 'ops-owner', policyUri: published.policyUri })) .resolves.toBeTruthy(); @@ -844,6 +951,45 @@ decisions: [] await other.stop().catch(() => {}); }); + it('restricts CCL policy revocation to the paranet owner', async () => { + const store = new OxigraphStore(); + const owner = await DKGAgent.create({ + name: 'OwnerRevokeBot', + store, + chainAdapter: new MockChainAdapter(), + }); + const other = await DKGAgent.create({ + name: 'OtherRevokeBot', + store, + chainAdapter: new MockChainAdapter(), + }); + + await owner.start(); + await other.start(); + await owner.createParanet({ id: 'ops-owner-revoke', name: 'Ops Owner Revoke' }); + + const published = await owner.publishCclPolicy({ + paranetId: 'ops-owner-revoke', + name: 'incident-review', + version: '0.1.0', + content: `policy: incident-review +version: 0.1.0 +rules: [] +decisions: [] +`, + }); + await owner.approveCclPolicy({ paranetId: 'ops-owner-revoke', policyUri: published.policyUri }); + + await expect(other.revokeCclPolicy({ paranetId: 'ops-owner-revoke', policyUri: published.policyUri })) + .rejects.toThrow(/Only the paranet owner can manage policies/); + + await expect(owner.revokeCclPolicy({ paranetId: 'ops-owner-revoke', policyUri: published.policyUri })) + .resolves.toMatchObject({ status: 'revoked' }); + + await owner.stop().catch(() => {}); + await other.stop().catch(() => {}); + }); + it('validates CCL policy content before publish', async () => { const store = new OxigraphStore(); const agent = await DKGAgent.create({ diff --git a/packages/agent/test/gossip-publish-handler.test.ts b/packages/agent/test/gossip-publish-handler.test.ts index 31c8f3a8f..19bae37a8 100644 --- a/packages/agent/test/gossip-publish-handler.test.ts +++ b/packages/agent/test/gossip-publish-handler.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, vi } from 'vitest'; import { encodePublishRequest, + DKG_ONTOLOGY, + SYSTEM_PARANETS, } from '@origintrail-official/dkg-core'; import { OxigraphStore, type Quad } from '@origintrail-official/dkg-storage'; import { GossipPublishHandler } from '../src/gossip-publish-handler.js'; @@ -28,7 +30,7 @@ function makePublishMessage(opts: { }); } -function createHandler(store?: OxigraphStore, callbacks?: Partial<{ paranetExists: (id: string) => Promise; subscribeToParanet: (id: string) => void }>) { +function createHandler(store?: OxigraphStore, callbacks?: Partial<{ paranetExists: (id: string) => Promise; getParanetOwner: (id: string) => Promise; subscribeToParanet: (id: string) => void }>) { const s = store ?? new OxigraphStore(); return { store: s, @@ -36,11 +38,12 @@ function createHandler(store?: OxigraphStore, callbacks?: Partial<{ paranetExist s, undefined, new Map(), - { - paranetExists: callbacks?.paranetExists ?? (async () => false), - subscribeToParanet: callbacks?.subscribeToParanet ?? (() => {}), - }, - ), + { + paranetExists: callbacks?.paranetExists ?? (async () => false), + getParanetOwner: callbacks?.getParanetOwner ?? (async () => null), + subscribeToParanet: callbacks?.subscribeToParanet ?? (() => {}), + }, + ), }; } @@ -147,4 +150,108 @@ describe('GossipPublishHandler', () => { const bindings = result.type === 'bindings' ? result.bindings : []; expect(bindings.length).toBeGreaterThan(0); }); + + it('rejects forged ontology policy approvals from non-owners', async () => { + const { store, handler } = createHandler(undefined, { + getParanetOwner: async (id) => id === 'ops-policy' ? 'did:dkg:agent:owner' : null, + }); + + const data = makePublishMessage({ + paranetId: SYSTEM_PARANETS.ONTOLOGY, + nquads: [ + ' .', + ' .', + ' "incident-review" .', + ' .', + ' .', + ' "2026-03-24T00:00:00.000Z" .', + ].join('\n'), + }); + + await handler.handlePublishMessage(data, SYSTEM_PARANETS.ONTOLOGY); + + const result = await store.query( + `SELECT ?binding WHERE { GRAPH { ?binding <${DKG_ONTOLOGY.DKG_ACTIVE_POLICY}> ?policy } }`, + ); + const bindings = result.type === 'bindings' ? result.bindings : []; + expect(bindings).toHaveLength(0); + }); + + it('rejects ontology policy approvals that omit approvedBy', async () => { + const { store, handler } = createHandler(undefined, { + getParanetOwner: async (id) => id === 'ops-policy' ? 'did:dkg:agent:owner' : null, + }); + + const data = makePublishMessage({ + paranetId: SYSTEM_PARANETS.ONTOLOGY, + nquads: [ + ' .', + ' .', + ' "incident-review" .', + ' .', + ' "2026-03-24T00:00:00.000Z" .', + ].join('\n'), + }); + + await handler.handlePublishMessage(data, SYSTEM_PARANETS.ONTOLOGY); + + const result = await store.query( + `SELECT ?binding WHERE { GRAPH { ?binding <${DKG_ONTOLOGY.DKG_ACTIVE_POLICY}> } }`, + ); + const bindings = result.type === 'bindings' ? result.bindings : []; + expect(bindings).toHaveLength(0); + }); + + it('rejects ontology policy revocations that omit revokedBy', async () => { + const { store, handler } = createHandler(undefined, { + getParanetOwner: async (id) => id === 'ops-policy' ? 'did:dkg:agent:owner' : null, + }); + + const data = makePublishMessage({ + paranetId: SYSTEM_PARANETS.ONTOLOGY, + nquads: [ + ' .', + ' .', + ' "incident-review" .', + ' .', + ' .', + ' "2026-03-24T00:00:00.000Z" .', + ' "2026-03-25T00:00:00.000Z" .', + ].join('\n'), + }); + + await handler.handlePublishMessage(data, SYSTEM_PARANETS.ONTOLOGY); + + const result = await store.query( + `SELECT ?binding WHERE { GRAPH { ?binding <${DKG_ONTOLOGY.DKG_ACTIVE_POLICY}> } }`, + ); + const bindings = result.type === 'bindings' ? result.bindings : []; + expect(bindings).toHaveLength(0); + }); + + it('accepts ontology policy approvals from the current paranet owner', async () => { + const { store, handler } = createHandler(undefined, { + getParanetOwner: async (id) => id === 'ops-policy' ? 'did:dkg:agent:owner' : null, + }); + + const data = makePublishMessage({ + paranetId: SYSTEM_PARANETS.ONTOLOGY, + nquads: [ + ' .', + ' .', + ' "incident-review" .', + ' .', + ' .', + ' "2026-03-24T00:00:00.000Z" .', + ].join('\n'), + }); + + await handler.handlePublishMessage(data, SYSTEM_PARANETS.ONTOLOGY); + + const result = await store.query( + `SELECT ?binding WHERE { GRAPH { ?binding <${DKG_ONTOLOGY.DKG_ACTIVE_POLICY}> } }`, + ); + const bindings = result.type === 'bindings' ? result.bindings : []; + expect(bindings).toHaveLength(1); + }); }); diff --git a/packages/cli/src/api-client.ts b/packages/cli/src/api-client.ts index f779a6a6a..4bdc43a6b 100644 --- a/packages/cli/src/api-client.ts +++ b/packages/cli/src/api-client.ts @@ -242,6 +242,14 @@ export class ApiClient { return this.post('/api/ccl/policy/approve', request); } + async revokeCclPolicy(request: { + paranetId: string; + policyUri: string; + contextType?: string; + }): Promise<{ policyUri: string; bindingUri: string; contextType?: string; revokedAt: string; status: 'revoked' }> { + return this.post('/api/ccl/policy/revoke', request); + } + async listCclPolicies(opts: { paranetId?: string; name?: string; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 537d4eb7f..0f74a3eeb 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1134,7 +1134,7 @@ const cclCmd = program const cclPolicyCmd = cclCmd .command('policy') - .description('Publish, approve, list, and resolve CCL policies'); + .description('Publish, approve, revoke, list, and resolve CCL policies'); cclPolicyCmd .command('publish ') @@ -1189,6 +1189,26 @@ cclPolicyCmd } }); +cclPolicyCmd + .command('revoke ') + .description('Revoke the currently active CCL policy binding for a paranet or context override') + .option('--context-type ', 'Optional stricter context override scope') + .action(async (paranetId: string, policyUri: string, opts: ActionOpts) => { + try { + const client = await ApiClient.connect(); + const result = await client.revokeCclPolicy({ paranetId, policyUri, contextType: opts.contextType }); + console.log(`Policy revoked:`); + console.log(` Policy: ${result.policyUri}`); + console.log(` Binding: ${result.bindingUri}`); + if (result.contextType) console.log(` Context: ${result.contextType}`); + console.log(` Revoked: ${result.revokedAt}`); + console.log(` Status: ${result.status}`); + } catch (err: any) { + console.error(err.message); + process.exit(1); + } + }); + cclPolicyCmd .command('list') .description('List known CCL policies') diff --git a/packages/cli/src/daemon.ts b/packages/cli/src/daemon.ts index dc19e827c..a7c6653f4 100644 --- a/packages/cli/src/daemon.ts +++ b/packages/cli/src/daemon.ts @@ -2109,6 +2109,17 @@ async function handleRequest( return jsonResponse(res, 200, result); } + // POST /api/ccl/policy/revoke + if (req.method === 'POST' && path === '/api/ccl/policy/revoke') { + const body = await readBody(req, SMALL_BODY_BYTES); + const { paranetId, policyUri, contextType } = JSON.parse(body); + if (!paranetId || !policyUri) { + return jsonResponse(res, 400, { error: 'Missing required fields: paranetId, policyUri' }); + } + const result = await agent.revokeCclPolicy({ paranetId, policyUri, contextType }); + return jsonResponse(res, 200, result); + } + // GET /api/ccl/policy/list if (req.method === 'GET' && path === '/api/ccl/policy/list') { const policies = await agent.listCclPolicies({ diff --git a/packages/cli/test/api-client.test.ts b/packages/cli/test/api-client.test.ts index 42826ab55..994cf7603 100644 --- a/packages/cli/test/api-client.test.ts +++ b/packages/cli/test/api-client.test.ts @@ -143,6 +143,16 @@ describe('ApiClient', () => { expect(body.contextType).toBe('incident_review'); }); + it('revokeCclPolicy() posts revocation payload', async () => { + globalThis.fetch = mockFetchOk({ policyUri: 'urn:policy', bindingUri: 'urn:binding', revokedAt: 'now', status: 'revoked' }); + await client.revokeCclPolicy({ paranetId: 'ops', policyUri: 'urn:policy', contextType: 'incident_review' }); + + const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe(`http://127.0.0.1:${PORT}/api/ccl/policy/revoke`); + const body = JSON.parse(opts.body); + expect(body.contextType).toBe('incident_review'); + }); + it('evaluateCclPolicy() posts evaluation payload', async () => { globalThis.fetch = mockFetchOk({ policy: { name: 'incident' }, factSetHash: 'sha256:abc', result: { derived: {}, decisions: {} } }); await client.evaluateCclPolicy({ paranetId: 'ops', name: 'incident', facts: [['claim', 'c1']], snapshotId: 'snap-1', publishResult: true }); diff --git a/packages/core/src/genesis.ts b/packages/core/src/genesis.ts index 66403141a..4ea622f82 100644 --- a/packages/core/src/genesis.ts +++ b/packages/core/src/genesis.ts @@ -182,8 +182,11 @@ export const DKG_ONTOLOGY = { DKG_POLICY_STATUS: `${DKG}policyStatus`, DKG_POLICY_CONTEXT_TYPE: `${DKG}contextType`, DKG_ACTIVE_POLICY: `${DKG}activePolicy`, + DKG_POLICY_BINDING_STATUS: `${DKG}policyBindingStatus`, DKG_APPROVED_BY: `${DKG}approvedBy`, DKG_APPROVED_AT: `${DKG}approvedAt`, + DKG_REVOKED_BY: `${DKG}revokedBy`, + DKG_REVOKED_AT: `${DKG}revokedAt`, DKG_CCL_EVALUATION: `${DKG}CCLEvaluation`, DKG_CCL_RESULT_ENTRY: `${DKG}CCLResultEntry`, DKG_EVALUATED_POLICY: `${DKG}evaluatedPolicy`, diff --git a/packages/core/test/genesis.test.ts b/packages/core/test/genesis.test.ts index 0d18a0417..202d40ba1 100644 --- a/packages/core/test/genesis.test.ts +++ b/packages/core/test/genesis.test.ts @@ -141,8 +141,9 @@ describe('DKG_ONTOLOGY', () => { 'DKG_CCL_POLICY', 'DKG_POLICY_BINDING', 'DKG_POLICY_APPLIES_TO_PARANET', 'DKG_POLICY_VERSION', 'DKG_POLICY_LANGUAGE', 'DKG_POLICY_FORMAT', 'DKG_POLICY_HASH', 'DKG_POLICY_BODY', 'DKG_POLICY_STATUS', - 'DKG_POLICY_CONTEXT_TYPE', 'DKG_ACTIVE_POLICY', 'DKG_APPROVED_BY', - 'DKG_APPROVED_AT', 'DKG_CCL_EVALUATION', 'DKG_CCL_RESULT_ENTRY', + 'DKG_POLICY_CONTEXT_TYPE', 'DKG_ACTIVE_POLICY', 'DKG_POLICY_BINDING_STATUS', + 'DKG_APPROVED_BY', 'DKG_APPROVED_AT', 'DKG_REVOKED_BY', 'DKG_REVOKED_AT', + 'DKG_CCL_EVALUATION', 'DKG_CCL_RESULT_ENTRY', 'DKG_EVALUATED_POLICY', 'DKG_FACT_SET_HASH', 'DKG_FACT_QUERY_HASH', 'DKG_FACT_RESOLVER_VERSION', 'DKG_FACT_RESOLUTION_MODE', 'DKG_SCOPE_UAL', 'DKG_VIEW', 'DKG_SNAPSHOT_ID', 'DKG_RESULT_KIND', 'DKG_RESULT_NAME',