From 24469904b039a34302db2dd4efb89b1592b72f14 Mon Sep 17 00:00:00 2001 From: Evanfeenstra Date: Thu, 17 Sep 2026 11:41:06 -0700 Subject: [PATCH 01/11] claims step 1: schema, claims.ts, status rule, graph gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The truth layer's foundation (plans/claims.md step 1). Claim / Check / Evidence are jarvis types resolved from the :Schema meta-graph, so nothing here adds a Strut node type; writes keep going through NodeWriter/EdgeWriter. - fixture: re-dump jarvis-ontology.ts from a post-125 jarvis (153 schemas, 346 edge schemas): Check, Evidence, Claim keyed on `claim-id` in the Epistemic domain, and the ABOUT / TESTS / PRODUCED_BY / SUPERSEDES pairs. Also un-mangles HiveInitiative's node_key (a vein->strut rename casualty) - claim-schema-upgrade.ts: one-shot standalone mirror of jarvis 124. The ontology seed is add-only, so an already-seeded DB would keep Claim at `claim-claim_text-speaker_name` and reject every claim. Runs before the seed and ONLY with STRUT_GRAPH_SEED_ONTOLOGY: it deletes old-shape :Claim nodes, which on a jarvis-hosted graph could be real podcast claims - strut-schemas: StrutRun -EXECUTED-> StrutStepVersion (single-step runs) - claims.ts: ids (lowercase alphanumeric; deterministic Evidence.id over check|run|path), the check subject/result contract, claimStatus() — computed on read per (claim, subject), never stored — and ClaimsReader (claimsFor / checksFor / evidenceFor / statusFor; muted edges invisible) - status rule refinement: a stream's latest is its newest evidence plus everything it observed in the same source run, so a foreach's last passing iteration cannot paper over an earlier failing one - gate: WorkspaceStore.graph (set by Neo4jWorkspaceStore) -> strut.claims, null on a filesystem workspace. Keyed on the workspace, not StrutOptions.graph: the lab host passes no `graph` and still needs claims - schema-crud.test: its live cases invented `Evidence` and extended Claim with `verdict` as examples; jarvis ships both now, so they use FieldNote / review_note instead --- AGENTS.md | 6 +- plans/claims.md | 29 +- plans/jarvis-graph-compat.md | 2 +- src/createStrut.ts | 9 + src/graph/backend.ts | 22 +- src/graph/claim-schema-upgrade.test.ts | 161 ++++++++ src/graph/claim-schema-upgrade.ts | 144 +++++++ src/graph/claims.test.ts | 391 +++++++++++++++++++ src/graph/claims.ts | 504 +++++++++++++++++++++++++ src/graph/fixtures/jarvis-ontology.ts | 2 +- src/graph/schema-crud.test.ts | 48 +-- src/graph/schema-resolver.test.ts | 4 +- src/graph/schema-seed.test.ts | 2 +- src/graph/strut-schemas.ts | 3 + src/graph/workspace-store.ts | 5 + src/index.ts | 34 ++ src/workspace.ts | 7 + 17 files changed, 1332 insertions(+), 41 deletions(-) create mode 100644 src/graph/claim-schema-upgrade.test.ts create mode 100644 src/graph/claim-schema-upgrade.ts create mode 100644 src/graph/claims.test.ts create mode 100644 src/graph/claims.ts diff --git a/AGENTS.md b/AGENTS.md index b976a5e..e4ac0a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,11 +78,13 @@ strut/ │ │ └── routes.ts # /audio/models (+ SSE download), /audio/transcribe (WAV body), /audio/hotwords/:name, /audio/sessions/:id (+ corrections) │ ├── graph/ # jarvis-compatible Neo4j graph backend over bolt, no jarvis in the loop (plans/jarvis-graph-compat.md). Opt-in via openGraphBackend │ │ ├── bolt.ts # neo4j-driver wrapper; int() for Integer writes (plain JS numbers write as FLOAT) -│ │ ├── strut-schemas.ts# the 9 Strut node types + 14-row edge registry (label registry in plans/generic-storage.md); author-time checks +│ │ ├── strut-schemas.ts# the 9 Strut node types + 15-row edge registry (label registry in plans/generic-storage.md); author-time checks │ │ ├── schema-seed.ts # idempotent domain registration: Thing root, Schema nodes, CHILD_OF, constraints, vector/fulltext indexes, migration stamp │ │ ├── node-writer.ts # §6 validation gate + node_key composition + Data_Bank + MERGE (create/upsert/restore/update), UNWIND batches │ │ ├── edge-writer.ts # edge MERGE by ref_id with IS_ALIAS rewrite (ON CREATE only); closed (source, edge, target) registry; update() = jarvis PATCH /v2/edges/:ref_id (stamps protected) │ │ ├── schema-crud.ts # createNodeSchema(): register a non-Strut node type like jarvis POST /v2/schema (parent, attribute grammar, node_key, CHILD_OF, constraint) or add-only extend an existing one +│ │ ├── claims.ts # the truth layer (plans/claims.md): Claim/Check/Evidence contract (ids, check subject + result shapes), claimStatus() — status computed on read per (claim, subject) — and ClaimsReader (claimsFor/checksFor/evidenceFor/statusFor; muted edges invisible). `strut.claims` is null unless the workspace is graph-backed +│ │ ├── claim-schema-upgrade.ts # one-shot standalone mirror of jarvis migration 124 (Claim re-keyed on id, Epistemic/Thing); runs before the ontology seed, only with STRUT_GRAPH_SEED_ONTOLOGY │ │ ├── embeddings.ts # local all-MiniLM-L6-v2 via transformers.js, tokenized like sentence-transformers (256 incl. specials); NULL-scan backfill │ │ ├── search.ts # the read surface: hybrid search (RRF + title boost + usage tiebreak), get/neighbors/counts, ontology, namespaces │ │ ├── backend.ts # openGraphBackend(): cached per config; runs seed + backfill on first open @@ -183,7 +185,7 @@ docker compose run --rm --no-deps --service-ports -e STRUT_WORKSPACE_BACKEND=fs | `NEO4J_URI` / `NEO4J_HOST` | (unset) / `localhost:7687` | Graph backend connection — same names and defaults as mcp's own Neo4j client: `NEO4J_URI` wins, else `bolt://`; `NEO4J_USER`/`NEO4J_PASSWORD` default `neo4j`/`testtest`; optional `NEO4J_DATABASE`. The `graph/*` lib steps read these via the secrets capability (secret store → env) and need nothing configured for a local Neo4j; `openGraphBackendFromEnv` stays opt-in (null when neither is set). | | `STRUT_GRAPH_NAMESPACE` | `default` | jarvis namespace every Strut node is written into | | `STRUT_GRAPH_EMBEDDINGS` | (on) | `off` disables the local MiniLM embedder (vectors stay NULL; search is fulltext-only) | -| `STRUT_GRAPH_SEED_ONTOLOGY` | (off) | `1` seeds the bundled jarvis ontology (151 schemas + edge schemas + indexes, add-only) on first open, so a standalone Neo4j can host jarvis-typed data (Document, EvalSet, Concept, …) with no jarvis process. No-op on a jarvis-seeded DB. | +| `STRUT_GRAPH_SEED_ONTOLOGY` | (off) | `1` seeds the bundled jarvis ontology (153 schemas + edge schemas + indexes, add-only) on first open, so a standalone Neo4j can host jarvis-typed data (Document, EvalSet, Concept, …) with no jarvis process. No-op on a jarvis-seeded DB. | | `STRUT_MODEL_DIR` | `~/.cache/strut-models` | Local model files: MiniLM's ONNX cache and STT models under `stt//`. `STRUT_MODEL_CACHE` is the older alias. | | `STRUT_STT_MODEL` | `zipformer-en-kroko` | Finals recognizer for `/audio/stream` + `/audio/transcribe` (hotword-capable) | | `STRUT_STT_PARTIAL_MODEL` | `nemo-fast-conformer-en-80ms` | Fast greedy recognizer whose output is shown as live partials; `off` for single-recognizer streams | diff --git a/plans/claims.md b/plans/claims.md index e73b06c..4536b1e 100644 --- a/plans/claims.md +++ b/plans/claims.md @@ -42,7 +42,7 @@ the failure mode EVOLVE_SPEC §6 already forbids for graders. | Where | Has | Lacks | | --- | --- | --- | -| jarvis (migrations 119/120/124, + 125 in review) | `Claim` — since 124 keyed on a caller-supplied `id` (`claim-id`), `speaker_name` optional, `claim_text` not paid, so a claim nobody "said" is writable; bitemporal `belief_valid_from/to`; claim-to-claim pairs `SUPERSEDES`, `PARENT_OF`, `DERIVED_FROM`. `Evidence` (Epistemic domain: `content`, `evidence_mode` observed\|asserted, `evidence_status` planned\|collected, `observed_at`). Pairs `Claim —EVIDENCED_BY {strength −1..1}→ Evidence`, `Evidence —HAS_SOURCE {authority_level, locators}→ Thing` | anything that produces or scores evidence; a template layer — both named as deferred in its doc. (The `Check` node it also deferred is migration 125, written for this plan) | +| jarvis (migrations 119/120/124/125) | `Claim` — since 124 keyed on a caller-supplied `id` (`claim-id`), `speaker_name` optional, `claim_text` not paid, so a claim nobody "said" is writable; bitemporal `belief_valid_from/to`; claim-to-claim pairs `SUPERSEDES`, `PARENT_OF`, `DERIVED_FROM`. `Evidence` (Epistemic domain: `content`, `evidence_mode` observed\|asserted, `evidence_status` planned\|collected, `observed_at`). Pairs `Claim —EVIDENCED_BY {strength −1..1}→ Evidence`, `Evidence —HAS_SOURCE {authority_level, locators}→ Thing` | anything that produces or scores evidence; a template layer — both named as deferred in its doc. (The `Check` node it also deferred is migration 125, written for this plan) | | hive | evals as graph nodes (`EvalRequirement` → `EvalTriggerOutput`), one LLM judge, "not evaluated is never a fail", `evaluates: workflow\|output` | any observed evidence; any link from feature requirements to evals | | strut | versioned steps/workflows, persisted runs, `exec`/`agent`/`llm` steps, per-run artifacts, cassettes, the post-hoc projector, `SchemaResolver` (the node writer already accepts any type whose `:Schema` exists in the DB) | any notion of a claim; `run_step` runs are not persisted | @@ -56,7 +56,11 @@ check. The Hive eval chain is out of scope here. One new node type (`Check`), two reused (`Claim`, `Evidence`), a handful of edge pairs, two tool changes, one post-run pass. Requires the graph backend: on `STRUT_WORKSPACE_BACKEND=fs` none of the -claim tools are offered and the verify pass is a no-op. +claim tools are offered and the verify pass is a no-op. The gate is the +WORKSPACE, not `StrutOptions.graph`: claims hang off the subjects' graph +nodes, so `WorkspaceStore.graph` (set by `Neo4jWorkspaceStore`) is what +turns the layer on, surfaced as `strut.claims` (`ClaimsReader | null`). The +lab host passes no `graph` option and still gets it. ### Nodes @@ -171,6 +175,10 @@ latest(stream) := that stream's newest COLLECTED evidence on THIS claim node claim's evidence (the ledger shows the predecessor's last status beside it), a retired check's evidence, a `planned` slot (a question, not evidence) + — plus everything else that stream observed in the SAME source + run: one run is one measurement (a `foreach` body yields one + Evidence per iteration, and the last iteration passing must not + paper over an earlier one failing) any latest is ABOUT the active version and refutes → refuted else any latest is ABOUT the active version, supports → supported @@ -197,7 +205,7 @@ the nodes. emptied to `[]`, re-homed from `Content` to the `Epistemic` domain (Thing-parented, beside `Evidence`), old Claim nodes deleted. Deploy note: every Claim writer must now send `id` (the podcast claim-extraction workflows included). -- **jarvis, migration 125 — PR open** (`ontology_125_check_node`, +- **jarvis, migration 125 — merged** (`ontology_125_check_node`, stakwork/jarvis-backend#3122)**:** seeds the `Check` node and the five new pairs in the table above — `Claim —ABOUT→ Thing`, `Evidence —ABOUT→ Thing`, `Check —TESTS→ Claim`, `Evidence —PRODUCED_BY→ Check`, `Check —SUPERSEDES→ @@ -227,8 +235,11 @@ the nodes. the `:Claim` nodes (none are expected on a strut DB — nothing wrote them) and SET the schema to the fixture's shape (`node_key`, `id`, `speaker_name: ?string`, `paid_properties: []`, `domain: Epistemic`, - `parent: Thing` + the `CHILD_OF` edge moved). A mirror of jarvis 124, and a - no-op on a jarvis-hosted graph, where 124 already ran. + `parent: Thing` + the `CHILD_OF` edge moved). A mirror of jarvis 124. It + runs ONLY when the ontology seed is on (`STRUT_GRAPH_SEED_ONTOLOGY` — the + flag that says "no jarvis here"): a jarvis-hosted graph is jarvis's to + migrate, and one on a pre-124 jarvis may hold real podcast claims this + pass must never delete. - **Strut code:** `src/graph/claims.ts` — attribute names, the subject-input contract, `claimStatus()`, and read helpers (`claimsFor(subject)`, `checksFor(claim)`, `evidenceFor(claim)`). Writes @@ -827,8 +838,12 @@ number exists). ## Step order -1. Schema: jarvis 124 (merged) + 125 (PR open; `Check` and the five pairs — - BLOCKS the live-graph tests below, not the unit work) + strut fixture +1. **Done (strut side)** — fixture re-dumped at 125 (153 schemas, 346 edge + schemas; also un-mangles `HiveInitiative`'s node_key, a casualty of the + vein→strut rename), `claim-schema-upgrade.ts`, the `STRUT_EDGES` row, + `claims.ts` (`claimStatus()`, `ClaimsReader`), the `strut.claims` gate. + Schema: jarvis 124 + 125 (both merged; `Check` and the five pairs) + + strut fixture re-dumped from a post-125 jarvis + `claim-schema-upgrade.ts` for already-seeded standalone DBs (§1) + the one `STRUT_EDGES` row; `claims.ts` with `claimStatus()` and read helpers; graph-backend gate in diff --git a/plans/jarvis-graph-compat.md b/plans/jarvis-graph-compat.md index 323aac1..360a226 100644 --- a/plans/jarvis-graph-compat.md +++ b/plans/jarvis-graph-compat.md @@ -48,7 +48,7 @@ ancestor walks + `*` wildcard, the `EDGE_TYPES` allowlist, `create_schema_if_missing`). The §6 closed registry still governs Strut types and Strut-sourced edges. For a STANDALONE Neo4j, the bundled `fixtures/jarvis-ontology.ts` (a read-only dump of jarvis's default -library: 151 schemas, 309 edge schemas) is seeded add-only by +library: 153 schemas, 346 edge schemas) is seeded add-only by `seedJarvisOntology` (`STRUT_GRAPH_SEED_ONTOLOGY=1`), including the per-domain fulltext/vector indexes. jarvis's kitchen-sink `Data_Bank` fallback (priority fields + every non-excluded property) IS ported after diff --git a/src/createStrut.ts b/src/createStrut.ts index a27d8ac..9c02f6c 100644 --- a/src/createStrut.ts +++ b/src/createStrut.ts @@ -39,6 +39,8 @@ import { buildAuthoringCapability } from "./authoring.js"; import type { CassetteMode } from "./cassette.js"; // Type-only: the graph backend stays a lazy, opt-in dependency. import type { GraphBackend } from "./graph/backend.js"; +// No runtime graph dependency in here either (type-only imports inside). +import { claimsReaderFor, type ClaimsReader } from "./graph/claims.js"; import { createStt, type SttService } from "./audio/stt.js"; import { audioRoutes } from "./audio/routes.js"; import { attachAudioWebSocket } from "./audio/ws.js"; @@ -226,6 +228,12 @@ export interface Strut { * strut.stt)` to get the dictation socket; `listen()` does it. */ stt: SttService | null; + /** Reads over the claims layer (plans/claims.md) — null unless the + * workspace is graph-backed: claims hang off the subjects' graph nodes, + * so on a filesystem workspace no claim tool is offered and the verify + * pass is a no-op. Every consumer gates on this. */ + claims: ClaimsReader | null; + /** Boot the Hono server with `@hono/node-server`. Resolves once the * socket is listening, to the *bound* port — so `listen(0)` (or * `STRUT_PORT=0`) lets the OS pick one, which a desktop host that spawns @@ -2004,6 +2012,7 @@ export async function createStrut( autoResumeStaleRuns, run, stt, + claims: claimsReaderFor(workspace), listen, close, }; diff --git a/src/graph/backend.ts b/src/graph/backend.ts index 5dd9635..9f27646 100644 --- a/src/graph/backend.ts +++ b/src/graph/backend.ts @@ -4,13 +4,16 @@ * config and cached, with the boot-time obligations run on open: * * 1. `migrateVeinToStrut` — one-shot rename of pre-#1664 `Vein*` names; - * 2. `seedStrutDomain` — schema meta-graph, constraints, indexes (§4); - * 3. `backfillEmbeddings` — heal any NULL vectors left by a crash (§2). + * 2. with `seedOntology`: `upgradeClaimSchema` (one-shot, the standalone + * mirror of jarvis migration 124), then `seedJarvisOntology`; + * 3. `seedStrutDomain` — schema meta-graph, constraints, indexes (§4); + * 4. `backfillEmbeddings` — heal any NULL vectors left by a crash (§2). * * Consumers (the `graph/*` lab steps, a future `Neo4jWorkspaceStore` and * run projector) call `openGraphBackend(cfg)` and share the instance. */ import { Bolt, graphConfigFromEnv, type GraphConfig } from "./bolt.js"; +import { upgradeClaimSchema, type ClaimSchemaUpgradeReport } from "./claim-schema-upgrade.js"; import { EdgeWriter } from "./edge-writer.js"; import { MiniLMEmbedder, backfillEmbeddings, type BackfillReport } from "./embeddings.js"; import { NodeWriter, type Embedder } from "./node-writer.js"; @@ -46,6 +49,7 @@ export interface GraphBackend { /** What the boot-time seed did (undefined when skipped). */ readonly seed: SeedReport | undefined; readonly veinMigration: VeinMigrationReport | undefined; + readonly claimSchemaUpgrade: ClaimSchemaUpgradeReport | undefined; readonly ontologySeed: OntologySeedReport | undefined; readonly backfill: BackfillReport | undefined; close(): Promise; @@ -108,6 +112,7 @@ async function open(cfg: GraphConfig, opts: GraphBackendOptions): Promise JARVIS_ONTOLOGY.schemas.find((s) => s["type"] === type)!; + +/** The `Claim` schema as the pre-119 fixture had it (what an already-seeded + * standalone database still holds). */ +const OLD_CLAIM = { + speaker_name: "string", + index: ["name", "claim_text", "speaker_name"], + triplicate_subject: "?string", + claim_text: "string", + parent: "Content", + node_key: "claim-claim_text-speaker_name", + triplicate: "?string", + type: "Claim", + source_role: "?string", + triplicate_predicate: "?string", + description_key: "claim_text", + title_key: "name", + paid_properties: ["claim_text"], + name: "string", + triplicate_object: "?string", + domain: "Content", + ref_id: "67fcc508-e296-4411-896b-6f431e8c0c9d", +}; + +const fixtureWith = (claim: Record): OntologyFixture => ({ + source: "test", + schemas: [schemaOf("Thing"), schemaOf("Content"), claim], + edge_schemas: [], + hidden_domains: null, +}); + +describe("bundled fixture (pure)", () => { + it("carries the post-125 epistemic layer", () => { + const claim = schemaOf("Claim"); + assert.deepEqual( + [claim["node_key"], claim["id"], claim["speaker_name"], claim["paid_properties"], claim["domain"], claim["parent"]], + ["claim-id", "string", "?string", [], "Epistemic", "Thing"], + ); + assert.deepEqual([schemaOf("Evidence")["node_key"], schemaOf("Check")["node_key"], schemaOf("Check")["created_at"]], ["evidence-id", "check-id", "datetime"]); + const pairs = new Set(JARVIS_ONTOLOGY.edge_schemas.map((e) => `${e.source}-${e.edge}->${e.target}`)); + for (const p of [ + "Claim-ABOUT->Thing", "Evidence-ABOUT->Thing", "Check-TESTS->Claim", "Evidence-PRODUCED_BY->Check", "Check-SUPERSEDES->Check", + "Claim-EVIDENCED_BY->Evidence", "Evidence-HAS_SOURCE->Thing", "Claim-SUPERSEDES->Claim", "Claim-PARENT_OF->Claim", "Claim-DERIVED_FROM->Claim", + ]) assert.ok(pairs.has(p), p); + }); +}); + +describe("upgradeClaimSchema (live Neo4j)", { skip: cfg ? false : "STRUT_TEST_NEO4J_URI not set" }, () => { + let bolt: Bolt; + before(async () => { + bolt = new Bolt(cfg!); + await bolt.verify(); + }); + after(async () => { + await bolt?.close(); + }); + beforeEach(async () => { + await wipeGraph(bolt); + }); + + it("old-shape Claim schema → upgraded once; the add-only seed alone would have left it", async () => { + await seedJarvisOntology(bolt, fixtureWith(OLD_CLAIM)); + await seedStrutDomain(bolt); + const nodes = new NodeWriter(bolt, { resolver: new SchemaResolver(bolt) }); + const old = await nodes.write({ type: "Claim", data: { name: "n", claim_text: "the sky is blue", speaker_name: "alice" } }); + assert.equal(old.node_key, "claim-theskyisblue-alice"); + + // The re-dumped fixture by itself changes nothing on an existing Claim schema… + const seeded = await seedJarvisOntology(bolt, fixtureWith(schemaOf("Claim"))); + assert.deepEqual(seeded.createdSchemas, []); + await assert.rejects( + new NodeWriter(bolt, { resolver: new SchemaResolver(bolt) }).write({ type: "Claim", data: { id: "c1", name: "n", claim_text: "t" } }), + (e: unknown) => e instanceof GraphValidationError && (e.code === "UNKNOWN_ATTRIBUTE" || e.code === "MISSING_REQUIRED"), + ); + + // …the upgrade does. + const r = await upgradeClaimSchema(bolt); + assert.deepEqual(r, { status: "upgraded", deletedClaims: 1, previousNodeKey: "claim-claim_text-speaker_name" }); + const rows = await bolt.run( + `MATCH (s:Schema {type: "Claim"}) RETURN properties(s) AS p, [(s)-[:CHILD_OF]->(p:Schema) | p.type] AS child_of`, + ); + assert.equal(rows.length, 1); + const live = rows[0]!["p"] as Record; + assert.equal(live["ref_id"], OLD_CLAIM.ref_id, "the Schema node keeps its identity"); + assert.deepEqual({ ...live, ref_id: undefined }, { ...schemaOf("Claim"), ref_id: undefined }); + assert.deepEqual(rows[0]!["child_of"], ["Thing"]); + assert.equal((await bolt.run(`MATCH (c:Claim) RETURN count(c) AS c`))[0]!["c"], 0); + + // A claim nobody "said", keyed on id, is now writable — and lands in Epistemic. + const fresh = new NodeWriter(bolt, { resolver: new SchemaResolver(bolt) }); + const c = await fresh.write({ type: "Claim", data: { id: "c1", name: "n", claim_text: "the clip contains the quote" } }); + assert.equal(c.node_key, "claim-c1"); + + // A second boot is a no-op and never deletes new-shape claims. + const snap = await graphSnapshot(bolt); + assert.deepEqual(await upgradeClaimSchema(bolt), { status: "already_done", deletedClaims: 0 }); + assert.deepEqual(await graphSnapshot(bolt), snap); + }); + + it("a graph already keyed on claim-id (jarvis 124 ran, or a fresh seed) is untouched", async () => { + await seedJarvisOntology(bolt, fixtureWith(schemaOf("Claim"))); + await seedStrutDomain(bolt); + const nodes = new NodeWriter(bolt, { resolver: new SchemaResolver(bolt) }); + await nodes.write({ type: "Claim", data: { id: "keep", name: "n", claim_text: "kept" } }); + const snap = await graphSnapshot(bolt); + + const r = await upgradeClaimSchema(bolt); + assert.deepEqual(r, { status: "nothing_to_do", deletedClaims: 0, previousNodeKey: "claim-id" }); + const after = await graphSnapshot(bolt); + const ledger = after.nodes.filter((n) => n.labels.includes("Migration") && n.properties["migration_id"] === CLAIM_SCHEMA_UPGRADE_ID); + assert.equal(ledger.length, 1); + assert.deepEqual(after.nodes.filter((n) => !ledger.includes(n)), snap.nodes); + assert.deepEqual(after.rels, snap.rels); + }); + + it("boot path: runs before the ontology seed, and only when that seed is on", async () => { + await seedJarvisOntology(bolt, fixtureWith(OLD_CLAIM)); + try { + // No seedOntology = a jarvis-hosted graph: jarvis's to migrate, never touched from here. + const hosted = await openGraphBackend(cfg!, { embeddings: false }); + assert.equal(hosted.claimSchemaUpgrade, undefined); + assert.equal((await bolt.run(`MATCH (s:Schema {type: "Claim"}) RETURN s.node_key AS k`))[0]!["k"], OLD_CLAIM.node_key); + await hosted.close(); + + const standalone = await openGraphBackend(cfg!, { embeddings: false, seedOntology: true }); + assert.equal(standalone.claimSchemaUpgrade?.status, "upgraded"); + assert.ok(standalone.ontologySeed!.createdSchemas.includes("Check") && standalone.ontologySeed!.createdSchemas.includes("Evidence")); + // The whole layer is writable on the upgraded, re-seeded database. + const c = await standalone.nodes.write({ type: "Claim", data: { id: "c1", name: "n", claim_text: "t" } }); + const k = await standalone.nodes.write({ type: "Check", data: { id: "k1", name: "k", created_at: 1 } }); + assert.ok((await standalone.edges.write({ edge: "TESTS", source_ref_id: k.ref_id, target_ref_id: c.ref_id })).created); + } finally { + await closeGraphBackends(); + } + }); + + it("no Claim schema at all → left for the seed to create; duplicates → left for a human, not stamped", async () => { + assert.deepEqual(await upgradeClaimSchema(bolt), { status: "nothing_to_do", deletedClaims: 0 }); + + await wipeGraph(bolt); + await seedJarvisOntology(bolt, fixtureWith(OLD_CLAIM)); + await bolt.run(`CREATE (:Schema {type: "claim", node_key: "claim-name", ref_id: "dup"})`); + assert.equal((await upgradeClaimSchema(bolt)).status, "skipped_duplicates"); + assert.equal((await bolt.run(`MATCH (m:Migration {migration_id: $id}) RETURN count(m) AS c`, { id: CLAIM_SCHEMA_UPGRADE_ID }))[0]!["c"], 0); + assert.equal((await bolt.run(`MATCH (s:Schema {type: "Claim"}) RETURN s.node_key AS k`))[0]!["k"], OLD_CLAIM.node_key); + }); +}); diff --git a/src/graph/claim-schema-upgrade.ts b/src/graph/claim-schema-upgrade.ts new file mode 100644 index 0000000..39c7d9a --- /dev/null +++ b/src/graph/claim-schema-upgrade.ts @@ -0,0 +1,144 @@ +/** + * One-shot upgrade of a STANDALONE strut Neo4j's `Claim` schema to the shape + * the truth layer writes (`plans/claims.md` §1) — a mirror of jarvis + * migration `124_claim_flexible_identity`. + * + * `seedJarvisOntology` is add-only: a database seeded from the pre-119 + * fixture keeps `Claim` at `claim-claim_text-speaker_name` with a required + * `speaker_name`, and strut's own `validateNode` then rejects every claim + * (`MISSING_REQUIRED`). Re-dumping the fixture adds `Evidence`, `Check` and + * the new pairs but never touches that existing node, so this pass does: + * + * 1. ledger check — a stamped database is never scanned again; + * 2. when the live `Claim` schema is still keyed on the old node_key: + * DETACH DELETE every `:Claim` node, batched (old-shape claims have no + * `id` and cannot be re-keyed; none are expected on a strut database — + * nothing wrote them). Gated on the OLD key, so a re-run can never + * delete new-shape claims; + * 3. SET the schema to the fixture's shape (`node_key: claim-id`, + * `id: string`, `speaker_name: ?string`, `paid_properties: []`, + * `domain: Epistemic`, `parent: Thing`, the epistemic attributes), + * keeping the live `ref_id` and `type`, and move `CHILD_OF` to `Thing`; + * 4. verify the shape, then stamp the `Migration` ledger. + * + * Runs at boot BEFORE `seedJarvisOntology`, and only when the ontology seed + * is on (`STRUT_GRAPH_SEED_ONTOLOGY`) — that flag is what says "no jarvis + * here". A jarvis-hosted graph is jarvis's to migrate (124 already ran + * there, and an older jarvis may hold real podcast claims this pass must + * not delete). An absent `Claim` schema is left for the seed to create. + */ +import { randomUUID } from "node:crypto"; +import { Bolt } from "./bolt.js"; +import { JARVIS_ONTOLOGY, type OntologyFixture } from "./fixtures/jarvis-ontology.js"; +import { schemaStatement } from "./schema-seed.js"; + +export const CLAIM_SCHEMA_UPGRADE_ID = "strut_claim_schema_upgrade_v1"; +const CLAIM_TYPE = "Claim"; +const NEW_NODE_KEY = "claim-id"; +const BATCH = 1000; + +export interface ClaimSchemaUpgradeReport { + /** `already_done` = ledger row present, nothing read; `nothing_to_do` = + * no Claim schema, or already the new shape; `skipped_duplicates` = more + * than one Claim schema node — left for a human, NOT stamped. */ + status: "upgraded" | "already_done" | "nothing_to_do" | "skipped_duplicates"; + /** Old-shape `:Claim` nodes removed. */ + deletedClaims: number; + /** The node_key spec found on the live schema, when it was read. */ + previousNodeKey?: string; +} + +/** The fixture's `Claim` schema minus its identity (`ref_id`, `type`). */ +function claimShape(fixture: OntologyFixture): Record { + const claim = fixture.schemas.find((s) => s["type"] === CLAIM_TYPE); + if (!claim) throw new Error("upgradeClaimSchema: the ontology fixture has no Claim schema"); + if (claim["node_key"] !== NEW_NODE_KEY) { + throw new Error(`upgradeClaimSchema: the fixture's Claim is keyed on ${String(claim["node_key"])}, expected ${NEW_NODE_KEY} — re-dump it from a post-124 jarvis`); + } + const { ref_id: _ref, type: _type, ...shape } = claim; + return shape; +} + +export async function upgradeClaimSchema(bolt: Bolt, fixture: OntologyFixture = JARVIS_ONTOLOGY): Promise { + const report: ClaimSchemaUpgradeReport = { status: "nothing_to_do", deletedClaims: 0 }; + const ledger = await bolt.run(`MATCH (m:Migration {migration_id: $id}) RETURN count(m) AS c`, { id: CLAIM_SCHEMA_UPGRADE_ID }); + if (Number(ledger[0]?.["c"] ?? 0) > 0) { + report.status = "already_done"; + return report; + } + + const shape = claimShape(fixture); + const live = await bolt.run( + `MATCH (s:Schema) WHERE toLower(s.type) = toLower($t) + RETURN s.ref_id AS ref_id, s.node_key AS node_key, s.id AS id_attr, s.speaker_name AS speaker_name, + s.paid_properties AS paid_properties, s.domain AS domain, s.parent AS parent`, + { t: CLAIM_TYPE }, + ); + // Two Schema nodes for one type would make the SET below fan out. + if (live.length > 1) { + report.status = "skipped_duplicates"; + return report; + } + + if (live.length === 1) { + const s = live[0]!; + report.previousNodeKey = typeof s["node_key"] === "string" ? (s["node_key"] as string) : undefined; + const needsRekey = s["node_key"] !== NEW_NODE_KEY; + const needsShape = + needsRekey || + s["id_attr"] !== "string" || + s["speaker_name"] !== "?string" || + s["domain"] !== shape["domain"] || + s["parent"] !== shape["parent"] || + !(Array.isArray(s["paid_properties"]) && (s["paid_properties"] as unknown[]).length === 0); + + if (needsRekey) { + for (;;) { + const rows = await bolt.run(`MATCH (n:\`${CLAIM_TYPE}\`) WITH n LIMIT ${BATCH} DETACH DELETE n RETURN count(*) AS c`); + const c = Number(rows[0]?.["c"] ?? 0); + report.deletedClaims += c; + if (c < BATCH) break; + } + } + + if (needsShape) { + const parent = String(shape["parent"]); + await bolt.write(async (tx) => { + await tx.run(`MATCH (s:Schema {ref_id: $ref_id}) SET s += $shape`, { ref_id: s["ref_id"], shape }); + await tx.run(`MATCH (s:Schema {ref_id: $ref_id})-[r:CHILD_OF]->(p:Schema) WHERE p.type <> $parent DELETE r`, { ref_id: s["ref_id"], parent }); + await tx.run( + `MATCH (s:Schema {ref_id: $ref_id}), (p:Schema {type: $parent}) + MERGE (s)-[r:CHILD_OF]->(p) ON CREATE SET r.ref_id = $edge_ref`, + { ref_id: s["ref_id"], parent, edge_ref: randomUUID() }, + ); + }); + const check = await bolt.run( + `MATCH (s:Schema {ref_id: $ref_id}) + RETURN s.node_key AS node_key, s.id AS id_attr, s.speaker_name AS speaker_name, s.domain AS domain, + [(s)-[:CHILD_OF]->(p:Schema) | p.type] AS child_of`, + { ref_id: s["ref_id"] }, + ); + const got = check[0]; + const ok = + got && + got["node_key"] === NEW_NODE_KEY && + got["id_attr"] === "string" && + got["speaker_name"] === "?string" && + got["domain"] === shape["domain"] && + JSON.stringify(got["child_of"]) === JSON.stringify([parent]); + // Not stamped: the next boot retries. + if (!ok) throw new Error(`upgradeClaimSchema: Claim schema did not reach the expected shape: ${JSON.stringify(got)}`); + report.status = "upgraded"; + } + } + + await schemaStatement( + bolt, + `CREATE CONSTRAINT migration_id_unique IF NOT EXISTS + FOR (m:Migration) REQUIRE m.migration_id IS UNIQUE`, + ); + await bolt.run(`MERGE (m:Migration {migration_id: $id}) ON CREATE SET m.executed_at = timestamp()`, { + id: CLAIM_SCHEMA_UPGRADE_ID, + }); + return report; +} diff --git a/src/graph/claims.test.ts b/src/graph/claims.test.ts new file mode 100644 index 0000000..4d5be04 --- /dev/null +++ b/src/graph/claims.test.ts @@ -0,0 +1,391 @@ +/** + * The claims layer: `claimStatus()` over every branch (pure), then the node + * contract + read helpers against a live Neo4j seeded from the bundled + * ontology fixture (Claim / Check / Evidence are jarvis types, resolved + * from the `:Schema` meta-graph). + */ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { Bolt } from "./bolt.js"; +import { seedStrutDomain } from "./schema-seed.js"; +import { seedJarvisOntology } from "./ontology-seed.js"; +import { SchemaResolver } from "./schema-resolver.js"; +import { GraphValidationError, NodeWriter } from "./node-writer.js"; +import { EdgeWriter } from "./edge-writer.js"; +import { testGraphConfig, wipeGraph } from "./test-util.js"; +import { + ClaimsReader, + claimStatus, + claimsReaderFor, + evidenceId, + isEpistemicId, + isExternalCheck, + newEpistemicId, + type EvidenceRow, + type SubjectRef, +} from "./claims.js"; +import type { GraphBackend } from "./backend.js"; + +const cfg = testGraphConfig(); + +// ── claimStatus (pure) ────────────────────────────────────────────────────── + +const STEP: SubjectRef = { kind: "step", type: "clip/compute-times" }; +const V1 = "hash-v1"; +const V2 = "hash-v2"; +let seq = 0; + +/** One collected, observed, supporting Evidence about V2 from check `k1` — + * override what the case is about. `at` is epoch seconds. */ +function ev(over: Partial & { at?: number; version?: string; run?: string } = {}): EvidenceRow { + const { at, version, run, ...rest } = over; + seq++; + return { + ref_id: `ref-${seq}`, + id: `e${String(seq).padStart(4, "0")}`, + name: "the claim", + evidence_status: "collected", + evidence_mode: "observed", + observed_at: at ?? 1000 + seq, + claim_id: "c1", + strength: 1, + check_id: "k1", + about: { kind: "step", name: "clip/compute-times", content_hash: version ?? V2 }, + ...(run ? { source: { ref_id: `run-ref-${run}`, node_type: "StrutRun", run_id: run } } : {}), + ...rest, + }; +} + +const status = (evidence: EvidenceRow[], checks: Array<{ id: string; retired_at?: number }> = [{ id: "k1" }], activeVersion: string | null = V2) => + claimStatus({ claim: { id: "c1" }, checks, subject: STEP, evidence, activeVersion }); + +describe("claimStatus (pure)", () => { + it("no evidence → unknown, every active check unverified, never assertedOnly", () => { + const s = status([], [{ id: "k1" }, { id: "k2" }]); + assert.deepEqual(s, { status: "unknown", assertedOnly: false, unverified: 2, openSlot: false, slots: [] }); + }); + + it("latest evidence on the active version decides: supported / refuted", () => { + assert.equal(status([ev()]).status, "supported"); + assert.equal(status([ev({ strength: -1 })]).status, "refuted"); + // Newest wins within one stream: a fix after a failure reads supported… + const fixed = status([ev({ strength: -1, at: 10, run: "r1" }), ev({ at: 20, run: "r2" })]); + assert.equal(fixed.status, "supported"); + assert.equal(fixed.unverified, 0); + // …and a regression after a pass reads refuted. + assert.equal(status([ev({ at: 10, run: "r1" }), ev({ strength: -1, at: 20, run: "r2" })]).status, "refuted"); + }); + + it("stale once the version moves on; unknown subjects' active version never matches", () => { + const s = status([ev({ version: V1 })]); + assert.equal(s.status, "stale"); + assert.equal(s.unverified, 1, "no evidence ABOUT the active version"); + assert.equal(s.latest?.about?.content_hash, V1); + assert.equal(status([ev()], [{ id: "k1" }], null).status, "stale"); + }); + + it("a current refutation beats a current support from another check", () => { + const s = status([ev({ check_id: "k1", at: 50 }), ev({ check_id: "k2", strength: -1, at: 10 })], [{ id: "k1" }, { id: "k2" }]); + assert.equal(s.status, "refuted"); + assert.equal(s.latest?.check_id, "k2", "latest is the evidence carrying the verdict"); + // An OLD-version refutation does not: only the active version's count. + const old = status([ev({ check_id: "k1" }), ev({ check_id: "k2", strength: -1, version: V1 })], [{ id: "k1" }, { id: "k2" }]); + assert.equal(old.status, "supported"); + assert.equal(old.unverified, 1, "k2 has nothing about the active version"); + }); + + it("one run is one measurement: a foreach's failing iteration is not papered over by a later passing one", () => { + const s = status([ + ev({ at: 10, run: "r1", strength: -1, source: { ref_id: "x", run_id: "r1", context: { path: "wf/each[0]/clip" } } }), + ev({ at: 11, run: "r1", source: { ref_id: "x", run_id: "r1", context: { path: "wf/each[1]/clip" } } }), + ]); + assert.equal(s.status, "refuted"); + // A NEWER run that passes everywhere supersedes the whole older run. + assert.equal(status([ev({ at: 10, run: "r1", strength: -1 }), ev({ at: 11, run: "r1" }), ev({ at: 20, run: "r2" })]).status, "supported"); + }); + + it("a retired check's evidence is ignored — a changed instrument has measured nothing yet", () => { + const s = status([ev({ check_id: "k-old" })], [{ id: "k-old", retired_at: 5 }, { id: "k-new" }]); + assert.equal(s.status, "unknown"); + assert.equal(s.unverified, 1); + // A check that does not TEST this claim at all is no different. + assert.equal(status([ev({ check_id: "stranger" })]).status, "unknown"); + }); + + it("evidence no check produced is its own stream", () => { + const s = status([ev({ check_id: undefined, evidence_mode: "asserted" })]); + assert.equal(s.status, "supported"); + assert.equal(s.assertedOnly, true); + assert.equal(s.unverified, 1, "k1 itself still has nothing"); + }); + + it("assertedOnly reads the evidence carrying the verdict", () => { + assert.equal(status([ev()]).assertedOnly, false); + assert.equal(status([ev({ evidence_mode: "asserted" })]).assertedOnly, true); + const mixed = status([ev({ check_id: "k1", evidence_mode: "asserted" }), ev({ check_id: "k2" })], [{ id: "k1" }, { id: "k2" }]); + assert.equal(mixed.assertedOnly, false, "one observed support is enough"); + // An observed support on an OLD version does not vouch for an asserted current one. + const old = status([ev({ check_id: "k1", evidence_mode: "asserted" }), ev({ check_id: "k2", version: V1 })], [{ id: "k1" }, { id: "k2" }]); + assert.equal(old.status, "supported"); + assert.equal(old.assertedOnly, true); + }); + + it("planned slots are questions, not evidence — reported as openSlot, never counted", () => { + const slot = ev({ check_id: "k-ext", evidence_status: "planned", strength: undefined, evidence_mode: undefined, observed_at: undefined, content: undefined }); + const s = status([slot], [{ id: "k-ext" }]); + assert.equal(s.status, "unknown"); + assert.equal(s.openSlot, true); + assert.deepEqual(s.slots, [{ evidence_id: slot.id, check_id: "k-ext" }]); + assert.equal(s.unverified, 1); + // Filled: the SAME node, now collected. + const filled = status([{ ...slot, evidence_status: "collected", evidence_mode: "asserted", strength: 1, observed_at: 99 }], [{ id: "k-ext" }]); + assert.deepEqual([filled.status, filled.assertedOnly, filled.openSlot, filled.unverified], ["supported", true, false, 0]); + // Evidence with no recognised evidence_status is not counted either. + assert.equal(status([ev({ evidence_status: undefined })]).status, "unknown"); + assert.equal(status([ev({ strength: 0 })]).status, "unknown"); + }); + + it("is per (claim, subject): other subjects', other claims' and un-attributed evidence are ignored", () => { + const elsewhere = ev({ about: { kind: "step", name: "clip/other", content_hash: V2 }, strength: -1 }); + const workflowSameName = ev({ about: { kind: "workflow", name: "clip/compute-times", content_hash: V2 }, strength: -1 }); + const predecessor = ev({ claim_id: "c0", strength: -1 }); + const unattributed = ev({ about: undefined, strength: -1 }); + assert.equal(status([elsewhere, workflowSameName, predecessor, unattributed]).status, "unknown"); + assert.equal(status([elsewhere, workflowSameName, predecessor, unattributed, ev()]).status, "supported"); + }); + + it("orders by observed_at, then date_added_to_graph, then id — deterministically", () => { + const a = ev({ observed_at: undefined, date_added_to_graph: 5_000, strength: -1, run: "r1" }); + const b = ev({ observed_at: 6, run: "r2" }); // 6s = 6000ms, newer than a + assert.equal(status([a, b]).status, "supported"); + assert.equal(status([b, a]).status, "supported"); + }); +}); + +describe("claim ids (pure)", () => { + it("claim/check ids are lowercase alphanumeric, so node_key sanitizing cannot collide two", () => { + const ids = new Set(Array.from({ length: 50 }, () => newEpistemicId())); + assert.equal(ids.size, 50); + for (const id of ids) assert.match(id, /^[a-z0-9]{32}$/); + assert.ok(isEpistemicId("ab1") && !isEpistemicId("aB-1") && !isEpistemicId("") && !isEpistemicId(7)); + }); + + it("Evidence.id is deterministic over (check, run, path)", () => { + const id = evidenceId("k1", "run-1", "wf/clip"); + assert.match(id, /^[a-f0-9]{32}$/); + assert.equal(evidenceId("k1", "run-1", "wf/clip"), id); + assert.notEqual(evidenceId("k1", "run-1", "wf/each[1]/clip"), id); + assert.notEqual(evidenceId("k2", "run-1", "wf/clip"), id); + assert.notEqual(evidenceId("k1", "run-2", "wf/clip"), id); + }); + + it("the gate: no graph behind the workspace → no claims layer", () => { + assert.equal(claimsReaderFor({}), null); + assert.ok(claimsReaderFor({ graph: { bolt: {} } as unknown as GraphBackend }) instanceof ClaimsReader); + assert.ok(isExternalCheck({}) && !isExternalCheck({ step_type: "exec" })); + }); +}); + +// ── Live graph ────────────────────────────────────────────────────────────── + +describe("claims graph: node contract + reads (live Neo4j)", { skip: cfg ? false : "STRUT_TEST_NEO4J_URI not set" }, () => { + let bolt: Bolt; + let nodes: NodeWriter; + let edges: EdgeWriter; + let reader: ClaimsReader; + const now = Math.trunc(Date.now() / 1000); + const ref: Record = {}; + + before(async () => { + bolt = new Bolt(cfg!); + await bolt.verify(); + await wipeGraph(bolt); + await seedJarvisOntology(bolt); + await seedStrutDomain(bolt); + const resolver = new SchemaResolver(bolt); + nodes = new NodeWriter(bolt, { resolver }); + edges = new EdgeWriter(bolt, { resolver }); + reader = new ClaimsReader({ bolt }); + + // A step with two versions (v2 active), a workflow, and a run of the step. + const made = await nodes.writeMany([ + { type: "StrutStep", data: { step_type: "clip/compute-times", active_version: V2 } }, + { type: "StrutStepVersion", data: { step_type: "clip/compute-times", content_hash: V1, created_at: now - 100 } }, + { type: "StrutStepVersion", data: { step_type: "clip/compute-times", content_hash: V2, created_at: now - 50 } }, + { type: "StrutWorkflow", data: { name: "youtube-clip", active_version: "wf-hash" } }, + { type: "StrutRun", data: { run_id: "run-1", workflow_name: "step:clip/compute-times", status: "success", started_at: now - 10 } }, + ]); + [ref["step"], ref["v1"], ref["v2"], ref["wf"], ref["run"]] = made.map((m) => m.ref_id); + }); + after(async () => { + await bolt?.close(); + }); + + it("writes Claim / Check / Evidence through the ordinary writers (migration 124 + 125 shapes)", async () => { + // No speaker_name; two claims with the same text and different ids. + const text = "computes start/end inside the video's duration"; + const [c1, c2] = await nodes.writeMany( + [ + { type: "Claim", data: { id: "c1", name: text, claim_text: text, belief_valid_from: now - 40 } }, + { type: "Claim", data: { id: "c2", name: text, claim_text: text, speaker_name: "ai", belief_valid_from: now - 30 } }, + ], + "create", + ); + assert.deepEqual([c1!.outcome, c2!.outcome, c1!.node_key, c2!.node_key], ["created", "created", "claim-c1", "claim-c2"]); + ref["c1"] = c1!.ref_id; + ref["c2"] = c2!.ref_id; + const labels = await bolt.run(`MATCH (c:Claim {id: "c1"}) RETURN labels(c) AS l`); + assert.deepEqual([...(labels[0]!["l"] as string[])].sort(), ["Claim", "Data_Bank", "Domain_epistemic", "Node"]); + await assert.rejects( + nodes.write({ type: "Claim", data: { name: text, claim_text: text } }), + (e: unknown) => e instanceof GraphValidationError && e.code === "MISSING_REQUIRED", + "id is the identity", + ); + + // Check.created_at is REQUIRED (a create without it is a 400 in jarvis too). + await assert.rejects( + nodes.write({ type: "Check", data: { id: "k0", name: "no stamp", step_type: "exec" } }), + (e: unknown) => e instanceof GraphValidationError && e.code === "MISSING_REQUIRED" && e.attribute === "created_at", + ); + const [k1, kOld, kExt] = await nodes.writeMany([ + { type: "Check", data: { id: "k1", name: "bounds", step_type: "exec", step_config: '{"command":"true"}', run_when: "run", policy: "always", publisher: "ai", created_at: now - 40 } }, + { type: "Check", data: { id: "kold", name: "bounds (old)", step_type: "exec", created_at: now - 45, retired_at: now - 41 } }, + { type: "Check", data: { id: "kext", name: "listen", description: "does the cut sound natural — code cannot hear", policy: "on_change", created_at: now - 39 } }, + ]); + ref["k1"] = k1!.ref_id; + ref["kold"] = kOld!.ref_id; + ref["kext"] = kExt!.ref_id; + + const written = await edges.writeMany([ + // One claim ABOUT two subjects (many-to-many); both claims about the step. + { edge: "ABOUT", source_ref_id: ref["c1"]!, target_ref_id: ref["step"]! }, + { edge: "ABOUT", source_ref_id: ref["c1"]!, target_ref_id: ref["wf"]! }, + { edge: "ABOUT", source_ref_id: ref["c2"]!, target_ref_id: ref["step"]! }, + { edge: "TESTS", source_ref_id: ref["k1"]!, target_ref_id: ref["c1"]! }, + { edge: "TESTS", source_ref_id: ref["kold"]!, target_ref_id: ref["c1"]! }, + { edge: "TESTS", source_ref_id: ref["kext"]!, target_ref_id: ref["c1"]! }, + { edge: "SUPERSEDES", source_ref_id: ref["k1"]!, target_ref_id: ref["kold"]! }, + ]); + assert.ok(written.every((w) => w.created)); + // ABOUT is schema-validated (it is NOT a generic token): only Claim and Evidence may point with it. + await assert.rejects( + edges.write({ edge: "ABOUT", source_ref_id: ref["k1"]!, target_ref_id: ref["step"]! }), + (e: unknown) => e instanceof GraphValidationError && e.code === "WRONG_TYPE", + ); + }); + + it("claimsFor / checksFor read active nodes; retired ones only on request", async () => { + assert.deepEqual((await reader.claimsFor(STEP)).map((c) => c.id), ["c1", "c2"]); + assert.deepEqual((await reader.claimsFor({ kind: "workflow", name: "youtube-clip" })).map((c) => c.id), ["c1"]); + assert.deepEqual(await reader.claimsFor({ kind: "step", type: "nope" }), []); + const c1 = (await reader.claimsFor(STEP))[0]!; + assert.equal(c1.speaker_name, undefined); + assert.equal(c1.belief_valid_from, now - 40); + assert.equal(c1.ref_id, ref["c1"]); + + assert.deepEqual((await reader.checksFor("c1")).map((k) => k.id), ["k1", "kext"]); + assert.deepEqual((await reader.checksFor("c1", { includeRetired: true })).map((k) => k.id), ["kold", "k1", "kext"]); + const [k1, kext] = await reader.checksFor("c1"); + assert.deepEqual([k1!.step_type, k1!.policy, k1!.publisher, isExternalCheck(k1!)], ["exec", "always", "ai", false]); + assert.ok(isExternalCheck(kext!) && kext!.description); + + assert.equal(await reader.activeVersion(STEP), V2); + assert.equal(await reader.activeVersion({ kind: "step", type: "nope" }), null); + + // Retiring a claim (belief_valid_to) drops it from the default read; history stays. + await nodes.update(ref["c2"]!, { set: { belief_valid_to: now } }); + assert.deepEqual((await reader.claimsFor(STEP)).map((c) => c.id), ["c1"]); + assert.deepEqual((await reader.claimsFor(STEP, { includeRetired: true })).map((c) => c.id), ["c1", "c2"]); + }); + + it("evidence → status: unknown → supported → stale on a new version → refuted; muted edges are invisible", async () => { + const before = await reader.statusFor(STEP); + assert.deepEqual(before.map((r) => [r.claim.id, r.status.status, r.status.unverified]), [["c1", "unknown", 2]]); + + // What the verify pass will write: Evidence + EVIDENCED_BY{strength} + PRODUCED_BY + ABOUT + HAS_SOURCE. + const id = evidenceId("k1", "run-1", "step"); + const e1 = await nodes.write({ type: "Evidence", data: { id, name: "computes start/end…", content: "start=12 end=31 duration=95", evidence_mode: "observed", evidence_status: "collected", observed_at: now - 5 } }, "create"); + assert.equal(e1.node_key, `evidence-${id}`); + const context = JSON.stringify({ path: "step", cassette: "replay", checkVersion: "exec" }); + await edges.writeMany([ + { edge: "EVIDENCED_BY", source_ref_id: ref["c1"]!, target_ref_id: e1.ref_id, properties: { strength: 1 } }, + { edge: "PRODUCED_BY", source_ref_id: e1.ref_id, target_ref_id: ref["k1"]! }, + { edge: "ABOUT", source_ref_id: e1.ref_id, target_ref_id: ref["v2"]! }, + { edge: "HAS_SOURCE", source_ref_id: e1.ref_id, target_ref_id: ref["run"]!, properties: { context } }, + ]); + // A second pass over the same (check, run, path) is a no-op by construction. + const again = await nodes.write({ type: "Evidence", data: { id, name: "x", content: "DIFFERENT", evidence_status: "collected" } }, "create"); + assert.deepEqual([again.outcome, again.ref_id], ["existing", e1.ref_id]); + + const [row] = await reader.evidenceFor("c1", STEP); + assert.deepEqual( + { ...row, date_added_to_graph: undefined }, + { + ref_id: e1.ref_id, id, name: "computes start/end…", content: "start=12 end=31 duration=95", + evidence_mode: "observed", evidence_status: "collected", observed_at: now - 5, date_added_to_graph: undefined, + claim_id: "c1", strength: 1, check_id: "k1", + about: { kind: "step", name: "clip/compute-times", content_hash: V2 }, + source: { ref_id: ref["run"], node_type: "StrutRun", run_id: "run-1", context: { path: "step", cassette: "replay", checkVersion: "exec" } }, + }, + ); + assert.deepEqual(await reader.evidenceFor("c1", { kind: "workflow", name: "youtube-clip" }), [], "same claim, other subject: its own status"); + + let s = (await reader.statusFor(STEP))[0]!.status; + assert.deepEqual([s.status, s.assertedOnly, s.unverified, s.openSlot], ["supported", false, 1, false]); + assert.equal(s.latest?.source?.context?.checkVersion, "exec"); + assert.equal((await reader.statusFor({ kind: "workflow", name: "youtube-clip" }))[0]!.status.status, "unknown"); + + // Publish v3 → the evidence is about an older version. + await nodes.update(ref["step"]!, { set: { active_version: "hash-v3" } }); + s = (await reader.statusFor(STEP))[0]!.status; + assert.deepEqual([s.status, s.unverified], ["stale", 2]); + await nodes.update(ref["step"]!, { set: { active_version: V2 } }); + + // The retired check's refutation on the active version never counts. + const eOld = await nodes.write({ type: "Evidence", data: { id: evidenceId("kold", "run-1", "step"), name: "old instrument", content: "nope", evidence_mode: "observed", evidence_status: "collected", observed_at: now - 1 } }); + await edges.writeMany([ + { edge: "EVIDENCED_BY", source_ref_id: ref["c1"]!, target_ref_id: eOld.ref_id, properties: { strength: -1 } }, + { edge: "PRODUCED_BY", source_ref_id: eOld.ref_id, target_ref_id: ref["kold"]! }, + { edge: "ABOUT", source_ref_id: eOld.ref_id, target_ref_id: ref["v2"]! }, + ]); + assert.equal((await reader.statusFor(STEP))[0]!.status.status, "supported"); + + // An asserted refutation with no check (add_evidence) on the active version wins. + const eSay = await nodes.write({ type: "Evidence", data: { id: newEpistemicId(), name: "assistant looked", content: "end > duration on a 20s video", evidence_mode: "asserted", evidence_status: "collected", observed_at: now } }); + const said = await edges.writeMany([ + { edge: "EVIDENCED_BY", source_ref_id: ref["c1"]!, target_ref_id: eSay.ref_id, properties: { strength: -1 } }, + { edge: "ABOUT", source_ref_id: eSay.ref_id, target_ref_id: ref["v2"]! }, + ]); + s = (await reader.statusFor(STEP))[0]!.status; + assert.deepEqual([s.status, s.assertedOnly, s.latest?.check_id], ["refuted", true, undefined]); + + // Mute its EVIDENCED_BY edge (jarvis's soft delete) → invisible to every read. + assert.ok(await edges.mute(said[0]!.ref_id)); + assert.equal((await reader.statusFor(STEP))[0]!.status.status, "supported"); + assert.ok(!(await reader.evidenceFor("c1")).some((e) => e.id === eSay.id)); + }); + + it("an external check's planned slot: EVIDENCED_BY with no strength, openSlot, filled in place", async () => { + const id = evidenceId("kext", "run-1", "step"); + const slot = await nodes.write({ type: "Evidence", data: { id, name: "computes start/end…", description: "does the cut sound natural — run run-1, step", evidence_status: "planned" } }, "create"); + const [eb] = await edges.writeMany([ + { edge: "EVIDENCED_BY", source_ref_id: ref["c1"]!, target_ref_id: slot.ref_id }, + { edge: "PRODUCED_BY", source_ref_id: slot.ref_id, target_ref_id: ref["kext"]! }, + { edge: "ABOUT", source_ref_id: slot.ref_id, target_ref_id: ref["v2"]! }, + { edge: "HAS_SOURCE", source_ref_id: slot.ref_id, target_ref_id: ref["run"]!, properties: { context: JSON.stringify({ path: "step" }) } }, + ]); + const edge = await bolt.run(`MATCH ()-[r {ref_id: $r}]->() RETURN r.strength AS s`, { r: eb!.ref_id }); + assert.equal(edge[0]!["s"], null, "an unanswered question has no strength"); + + let s = (await reader.statusFor(STEP))[0]!.status; + assert.deepEqual([s.status, s.openSlot, s.unverified], ["supported", true, 1]); + assert.deepEqual(s.slots, [{ evidence_id: id, check_id: "kext" }]); + + // Fill: the SAME node patched to collected, the edge patched to ±1. + await nodes.update(slot.ref_id, { set: { content: "sounds clean", evidence_status: "collected", evidence_mode: "asserted", observed_at: now + 1 } }); + await edges.update({ ref_id: eb!.ref_id }, { set: { strength: 1 } }); + s = (await reader.statusFor(STEP))[0]!.status; + assert.deepEqual([s.status, s.openSlot, s.unverified, s.assertedOnly], ["supported", false, 0, false]); + assert.equal((await bolt.run(`MATCH (e:Evidence {id: $id}) RETURN count(e) AS c`, { id }))[0]!["c"], 1); + }); +}); diff --git a/src/graph/claims.ts b/src/graph/claims.ts new file mode 100644 index 0000000..cb85b0e --- /dev/null +++ b/src/graph/claims.ts @@ -0,0 +1,504 @@ +/** + * The truth layer's node contract and its read side (`plans/claims.md`). + * + * Three jarvis node types — a `Claim` is the statement, a `Check` is an + * instrument that can test it, `Evidence` is what one test observed — and + * the edges between them: + * + * Claim —ABOUT→ StrutStep | StrutWorkflow (stable identity) + * Check —TESTS→ Claim + * Claim —EVIDENCED_BY {strength ±1}→ Evidence + * Evidence —PRODUCED_BY→ Check (absent: no check produced it) + * Evidence —ABOUT→ StrutStepVersion | StrutWorkflowVersion + * Evidence —HAS_SOURCE {context, …}→ StrutRun + * + * None of the three is a Strut type: their schemas live in the database + * (jarvis migrations 119/120/124/125, or the bundled ontology fixture) and + * writes go through the ordinary `NodeWriter` / `EdgeWriter`, which resolve + * them with `SchemaResolver`. This module owns what is strut's: the ids, + * the shapes a check reads and returns, the status rule, and the reads. + * + * Status is COMPUTED ON READ, per (claim, subject) — `claimStatus` is one + * pure function and no verdict is ever stored on a node. Every read here + * skips muted edges: a muted slot is neither evidence nor an open slot. + * + * Type-only imports: this module never loads neo4j-driver, so `createStrut` + * can import it on a filesystem workspace. + */ +import { createHash, randomUUID } from "node:crypto"; +import type { CassetteMode } from "../cassette.js"; +import type { GraphBackend } from "./backend.js"; + +// ── Vocabulary ────────────────────────────────────────────────────────────── + +export const CLAIM_TYPE = "Claim"; +export const CHECK_TYPE = "Check"; +export const EVIDENCE_TYPE = "Evidence"; + +export const CLAIM_EDGES = { + ABOUT: "ABOUT", + TESTS: "TESTS", + EVIDENCED_BY: "EVIDENCED_BY", + PRODUCED_BY: "PRODUCED_BY", + HAS_SOURCE: "HAS_SOURCE", + SUPERSEDES: "SUPERSEDES", +} as const; + +/** When a check fires: on an execution of the subject, or when a new + * version of it is published (Hive's `evaluates` split). */ +export type RunWhen = "run" | "publish"; +/** How often a `run` check fires (plans/claims.md §4.1). */ +export type CheckPolicy = "always" | "on_change" | "sample" | "manual"; +export type EvidenceMode = "observed" | "asserted"; +export type EvidenceStatus = "planned" | "collected"; + +export const DEFAULT_FRESHNESS_DAYS = 7; + +// ── Ids ───────────────────────────────────────────────────────────────────── + +const EPISTEMIC_ID = /^[a-z0-9]+$/; + +/** + * Identity for a `Claim` / `Check` — never derived from the text. Lowercase + * alphanumerics ONLY: `node_key` is `claim-` after jarvis's sanitizer + * lowercases and drops every non-alphanumeric, so `aB-1` and `ab1` would + * collide on one node. + */ +export function newEpistemicId(): string { + return randomUUID().replace(/-/g, ""); +} + +export function isEpistemicId(id: unknown): id is string { + return typeof id === "string" && EPISTEMIC_ID.test(id); +} + +/** + * `Evidence.id` for what ONE check observed at ONE path of ONE run. With + * the node writer's `create` mode (a no-op on an existing node) this is + * what makes a second verify pass over the same run write nothing. Planned + * slots use the same scheme. + */ +export function evidenceId(checkId: string, runId: string, path: string): string { + return createHash("sha256").update(`${checkId}|${runId}|${path}`, "utf8").digest("hex").slice(0, 32); +} + +// ── Subjects ──────────────────────────────────────────────────────────────── + +/** What a claim is about: the STABLE identity, never a version. */ +export type SubjectRef = { kind: "workflow"; name: string } | { kind: "step"; type: string }; + +/** The exact version an observation was made on (`Evidence —ABOUT→`). */ +export interface VersionRef { + kind: SubjectRef["kind"]; + /** Workflow name or step type. */ + name: string; + content_hash: string; +} + +export function subjectName(subject: SubjectRef): string { + return subject.kind === "workflow" ? subject.name : subject.type; +} + +function isVersionOf(version: VersionRef | undefined, subject: SubjectRef): boolean { + return !!version && version.kind === subject.kind && version.name === subjectName(subject); +} + +const SUBJECT_NODE = { + workflow: { label: "StrutWorkflow", version: "StrutWorkflowVersion", key: "name" }, + step: { label: "StrutStep", version: "StrutStepVersion", key: "step_type" }, +} as const; + +// ── The check contract ────────────────────────────────────────────────────── + +/** + * What a `run` check reads: the subject IS the check step's run input, so a + * check's config templates say `{{ input.output.quote }}` — no new template + * root. `input` is the step's RESOLVED CONFIG (what the runner records on + * `step.start`), or params + run input for a workflow. A step that errored + * has `error` and no `output`, so "fails loudly on a private video" is + * checkable. + */ +export interface RunCheckSubject { + input: unknown; + output?: unknown; + error?: { message: string; stack?: string }; + runId: string; + /** Event path of the observed step (`wf/compute_times`, with the + * iteration for a loop body); the workflow's own path for a workflow. */ + path: string; + artifactsDir?: string; + cassette?: CassetteMode; +} + +/** What a `publish` check reads: the new version's source. */ +export type PublishCheckSubject = { source: string } | { yaml: string }; + +/** + * What a check step returns. A check never throws on a failed assertion — + * it returns `supports: false`. (A bare `exec` with no JSON on stdout maps + * from its exit code instead; a check that cannot run yields NO evidence.) + */ +export interface CheckResult { + supports: boolean; + /** What was observed — one bounded string. */ + content: string; + locator?: { path?: string; start_time?: number; end_time?: number; url?: string }; +} + +/** `HAS_SOURCE.context` is ONE `?string` in jarvis's schema; strut writes + * this object into it as JSON. */ +export interface SourceContext { + /** The observed step's event path. */ + path?: string; + cassette?: CassetteMode; + /** The code that actually ran under the check (a custom step's version; + * for `subflow`, the child's name and resolved version). */ + checkVersion?: string; + /** Names the model on a judged (`asserted`) result. */ + model?: string; + /** Who filled a slot or vouched: `person`, `ai`, a chat/session id. */ + by?: string; + [key: string]: unknown; +} + +// ── Rows ──────────────────────────────────────────────────────────────────── +// Attribute names as stored (datetimes are epoch SECONDS, like every +// jarvis `datetime`). + +export interface ClaimRow { + ref_id: string; + id: string; + /** The sentence, bounded (jarvis's required title). */ + name: string; + claim_text: string; + /** Who asserts it — strut's `publisher` stamp (`ai`, a person, a seeder). */ + speaker_name?: string; + belief_valid_from?: number; + /** Set = retired or superseded. ACTIVE = unset. */ + belief_valid_to?: number; +} + +export interface CheckRow { + ref_id: string; + id: string; + name: string; + /** Required on an external check: what to look at, and why code cannot. */ + description?: string; + /** A registry step type. ABSENT = an external check. */ + step_type?: string; + /** JSON config for that step. */ + step_config?: string; + run_when?: RunWhen | string; + policy?: CheckPolicy | string; + freshness_days?: number; + sample_rate?: number; + publisher?: string; + created_at: number; + /** Set = retired or superseded. ACTIVE = unset. */ + retired_at?: number; +} + +export function isExternalCheck(check: Pick): boolean { + return !check.step_type; +} + +/** One `Evidence` node with what its edges say about it. */ +export interface EvidenceRow { + ref_id: string; + id: string; + name: string; + description?: string; + content?: string; + evidence_mode?: EvidenceMode | string; + evidence_status?: EvidenceStatus | string; + observed_at?: number; + /** Epoch MILLISECONDS (jarvis's node stamp) — the ordering fallback for + * evidence an outside system wrote without `observed_at`. */ + date_added_to_graph?: number; + /** The claim whose `EVIDENCED_BY` edge reached this node. */ + claim_id: string; + /** `EVIDENCED_BY.strength`: > 0 supports, < 0 refutes; absent on a slot. */ + strength?: number; + /** `PRODUCED_BY` target; absent when no check produced it. */ + check_id?: string; + /** `ABOUT` target, when it is a strut version node. */ + about?: VersionRef; + /** `HAS_SOURCE` target + edge properties. */ + source?: { + ref_id: string; + node_type?: string; + run_id?: string; + context?: SourceContext; + start_time?: number; + end_time?: number; + post_url?: string; + }; +} + +// ── Status ────────────────────────────────────────────────────────────────── + +export type ClaimStatusValue = "supported" | "refuted" | "stale" | "unknown"; + +export interface ClaimStatus { + status: ClaimStatusValue; + /** The verdict rests on no `observed` evidence — only a model's or a + * person's word. False while `unknown`. */ + assertedOnly: boolean; + /** Active checks with no counted evidence ABOUT the active version. */ + unverified: number; + /** A planned slot is waiting on someone for this (claim, subject). */ + openSlot: boolean; + slots: Array<{ evidence_id: string; check_id?: string }>; + /** The newest evidence carrying the verdict (any counted evidence when + * `stale`); absent while `unknown`. */ + latest?: EvidenceRow; +} + +export interface ClaimStatusInput { + claim: Pick; + /** Checks that TEST this claim; retired ones are ignored. */ + checks: ReadonlyArray>; + subject: SubjectRef; + evidence: readonly EvidenceRow[]; + /** Content hash of the subject's active version, or null when it has none. */ + activeVersion: string | null; +} + +const NO_CHECK = ""; + +function orderKey(e: EvidenceRow): number { + if (typeof e.observed_at === "number") return e.observed_at * 1000; + return typeof e.date_added_to_graph === "number" ? e.date_added_to_graph : 0; +} + +/** Newest first; id as the deterministic tie-break. */ +function newestFirst(a: EvidenceRow, b: EvidenceRow): number { + return orderKey(b) - orderKey(a) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); +} + +/** + * The status rule (plans/claims.md, "Status is computed on read"). + * + * Streams: one per ACTIVE check, plus one for evidence no check produced. + * Counted evidence is `collected`, carries a non-zero strength, hangs off + * THIS claim node (a superseded claim's evidence stays on the old node), + * is ABOUT a version of THIS subject, and was not produced by a retired + * check — a changed instrument has measured nothing yet. A `planned` slot + * is a question, never evidence. + * + * A stream's latest MEASUREMENT is its newest counted evidence together + * with everything else that stream observed in the same source run: a + * `foreach` body yields one Evidence per iteration, and the last iteration + * passing must not paper over an earlier one failing. + * + * any latest is ABOUT the active version and refutes → refuted + * else any latest is ABOUT the active version, supports → supported + * else any latest exists (all about older versions) → stale + * else → unknown + * + * A refutation on the current version always wins, so a second check can + * never paper over a failing one. + */ +export function claimStatus(input: ClaimStatusInput): ClaimStatus { + const { claim, subject, activeVersion } = input; + const active = new Set(input.checks.filter((k) => k.retired_at === undefined || k.retired_at === null).map((k) => k.id)); + const mine = input.evidence.filter( + (e) => e.claim_id === claim.id && isVersionOf(e.about, subject) && (e.check_id === undefined || active.has(e.check_id)), + ); + + const slots = mine + .filter((e) => e.evidence_status === "planned") + .sort(newestFirst) + .map((e) => ({ evidence_id: e.id, ...(e.check_id ? { check_id: e.check_id } : {}) })); + const counted = mine.filter((e) => e.evidence_status === "collected" && typeof e.strength === "number" && e.strength !== 0); + + const streams = new Map(); + for (const e of counted) { + const k = e.check_id ?? NO_CHECK; + streams.set(k, [...(streams.get(k) ?? []), e]); + } + const latest: EvidenceRow[] = []; + for (const stream of streams.values()) { + stream.sort(newestFirst); + const run = stream[0]!.source?.run_id; + latest.push(...(run ? stream.filter((e) => e.source?.run_id === run) : [stream[0]!])); + } + + const onActive = (e: EvidenceRow) => activeVersion !== null && e.about?.content_hash === activeVersion; + const current = latest.filter(onActive); + const refuting = current.filter((e) => e.strength! < 0); + const supporting = current.filter((e) => e.strength! > 0); + const status: ClaimStatusValue = refuting.length ? "refuted" : supporting.length ? "supported" : latest.length ? "stale" : "unknown"; + const deciding = (status === "refuted" ? refuting : status === "supported" ? supporting : latest).sort(newestFirst); + + const verifiedChecks = new Set(counted.filter(onActive).map((e) => e.check_id)); + return { + status, + assertedOnly: deciding.length > 0 && !deciding.some((e) => e.evidence_mode === "observed"), + unverified: [...active].filter((id) => !verifiedChecks.has(id)).length, + openSlot: slots.length > 0, + slots, + ...(deciding[0] ? { latest: deciding[0] } : {}), + }; +} + +// ── Reads ─────────────────────────────────────────────────────────────────── + +const LIVE = (r: string) => `(${r}.is_muted IS NULL OR ${r}.is_muted = false)`; +const NOT_DELETED = (n: string) => `(${n}.is_deleted IS NULL OR ${n}.is_deleted = false)`; + +const CLAIM_FIELDS = ["ref_id", "id", "name", "claim_text", "speaker_name", "belief_valid_from", "belief_valid_to"] as const; +const CHECK_FIELDS = [ + "ref_id", "id", "name", "description", "step_type", "step_config", "run_when", "policy", "freshness_days", + "sample_rate", "publisher", "created_at", "retired_at", +] as const; +const EVIDENCE_FIELDS = [ + "ref_id", "id", "name", "description", "content", "evidence_mode", "evidence_status", "observed_at", "date_added_to_graph", +] as const; + +/** `n {.a, .b}` — never `properties(n)`: nodes carry `Data_Bank` and a + * 384-float embedding. */ +const project = (v: string, fields: readonly string[]) => `${v} {${fields.map((f) => `.${f}`).join(", ")}}`; + +function compact(row: Record): T { + const out: Record = {}; + for (const [k, v] of Object.entries(row)) if (v !== null && v !== undefined) out[k] = v; + return out as T; +} + +function parseContext(raw: unknown): SourceContext | undefined { + if (typeof raw !== "string" || !raw) return undefined; + try { + const v: unknown = JSON.parse(raw); + if (v && typeof v === "object" && !Array.isArray(v)) return v as SourceContext; + } catch { + // An outside system's free-text context. + } + return { path: raw }; +} + +export interface SubjectLedgerRow { + claim: ClaimRow; + checks: CheckRow[]; + status: ClaimStatus; +} + +/** + * Reads over the claim graph, scoped to the backend's namespace. Exists + * only on a graph-backed workspace — see `claimsReaderFor`. + */ +export class ClaimsReader { + constructor(private readonly graph: Pick) {} + + private get ns(): string { + return this.graph.bolt.namespace; + } + + /** Content hash of the subject's active version; null when the subject + * does not exist or has none. */ + async activeVersion(subject: SubjectRef): Promise { + const n = SUBJECT_NODE[subject.kind]; + const rows = await this.graph.bolt.run( + `MATCH (s:\`${n.label}\` {namespace: $ns, \`${n.key}\`: $name}) WHERE ${NOT_DELETED("s")} + RETURN s.active_version AS v LIMIT 1`, + { ns: this.ns, name: subjectName(subject) }, + ); + const v = rows[0]?.["v"]; + return typeof v === "string" && v ? v : null; + } + + /** Claims `ABOUT` a subject — active ones unless `includeRetired`. */ + async claimsFor(subject: SubjectRef, opts: { includeRetired?: boolean } = {}): Promise { + const n = SUBJECT_NODE[subject.kind]; + const rows = await this.graph.bolt.run( + `MATCH (c:\`${CLAIM_TYPE}\` {namespace: $ns})-[a:\`${CLAIM_EDGES.ABOUT}\`]->(s:\`${n.label}\` {namespace: $ns, \`${n.key}\`: $name}) + WHERE ${LIVE("a")} AND ${NOT_DELETED("c")} AND ${NOT_DELETED("s")} + AND ($retired OR c.belief_valid_to IS NULL) + RETURN ${project("c", CLAIM_FIELDS)} AS claim + ORDER BY c.belief_valid_from, c.id`, + { ns: this.ns, name: subjectName(subject), retired: opts.includeRetired === true }, + ); + return rows.map((r) => compact(r["claim"] as Record)); + } + + /** Checks that `TEST` a claim — active ones unless `includeRetired`. */ + async checksFor(claimId: string, opts: { includeRetired?: boolean } = {}): Promise { + const rows = await this.graph.bolt.run( + `MATCH (k:\`${CHECK_TYPE}\`)-[t:\`${CLAIM_EDGES.TESTS}\`]->(c:\`${CLAIM_TYPE}\` {namespace: $ns, id: $id}) + WHERE ${LIVE("t")} AND ${NOT_DELETED("k")} AND ($retired OR k.retired_at IS NULL) + RETURN ${project("k", CHECK_FIELDS)} AS chk + ORDER BY k.created_at, k.id`, + { ns: this.ns, id: claimId, retired: opts.includeRetired === true }, + ); + return rows.map((r) => compact(r["chk"] as Record)); + } + + /** + * Every `Evidence` a claim's live `EVIDENCED_BY` edges reach — planned + * slots included — with its check, the version it is about, and its + * source. Pass `subject` to keep only evidence about that subject's + * versions. Newest first. + */ + async evidenceFor(claimId: string, subject?: SubjectRef): Promise { + const rows = await this.graph.bolt.run( + `MATCH (c:\`${CLAIM_TYPE}\` {namespace: $ns, id: $id})-[eb:\`${CLAIM_EDGES.EVIDENCED_BY}\`]->(e:\`${EVIDENCE_TYPE}\`) + WHERE ${LIVE("eb")} AND ${NOT_DELETED("e")} + OPTIONAL MATCH (e)-[pb:\`${CLAIM_EDGES.PRODUCED_BY}\`]->(k:\`${CHECK_TYPE}\`) WHERE ${LIVE("pb")} + OPTIONAL MATCH (e)-[ab:\`${CLAIM_EDGES.ABOUT}\`]->(v) WHERE ${LIVE("ab")} AND (v:StrutStepVersion OR v:StrutWorkflowVersion) + OPTIONAL MATCH (e)-[hs:\`${CLAIM_EDGES.HAS_SOURCE}\`]->(src) WHERE ${LIVE("hs")} + RETURN ${project("e", EVIDENCE_FIELDS)} AS ev, eb.strength AS strength, k.id AS check_id, + v:StrutStepVersion AS v_is_step, v.name AS v_name, v.step_type AS v_step_type, v.content_hash AS v_hash, + src.ref_id AS src_ref, labels(src) AS src_labels, src.run_id AS src_run_id, + hs.context AS hs_context, hs.start_time AS hs_start, hs.end_time AS hs_end, hs.post_url AS hs_url`, + { ns: this.ns, id: claimId }, + ); + const byId = new Map(); + for (const r of rows) { + const e = compact>(r["ev"] as Record); + const row: EvidenceRow = byId.get(e.id) ?? { ...e, claim_id: claimId }; + if (row.strength === undefined && typeof r["strength"] === "number") row.strength = r["strength"] as number; + if (row.check_id === undefined && typeof r["check_id"] === "string") row.check_id = r["check_id"] as string; + if (!row.about && typeof r["v_hash"] === "string") { + const isStep = r["v_is_step"] === true; + row.about = { kind: isStep ? "step" : "workflow", name: String(isStep ? r["v_step_type"] : r["v_name"]), content_hash: r["v_hash"] as string }; + } + if (!row.source && typeof r["src_ref"] === "string") { + const labels = (r["src_labels"] as string[] | null) ?? []; + row.source = compact({ + ref_id: r["src_ref"], + node_type: labels.find((l) => !/^(Node|Data_Bank|Domain_.*)$/.test(l)), + run_id: r["src_run_id"], + context: parseContext(r["hs_context"]), + start_time: r["hs_start"], + end_time: r["hs_end"], + post_url: r["hs_url"], + }); + } + byId.set(e.id, row); + } + const out = [...byId.values()].sort(newestFirst); + return subject ? out.filter((e) => isVersionOf(e.about, subject)) : out; + } + + /** Every active claim on a subject with its active checks and computed + * status — the rows the ledger is built from. */ + async statusFor(subject: SubjectRef): Promise { + const [claims, activeVersion] = await Promise.all([this.claimsFor(subject), this.activeVersion(subject)]); + return Promise.all( + claims.map(async (claim) => { + const [checks, evidence] = await Promise.all([this.checksFor(claim.id), this.evidenceFor(claim.id, subject)]); + return { claim, checks, status: claimStatus({ claim, checks, subject, evidence, activeVersion }) }; + }), + ); + } +} + +/** + * The gate (plans/claims.md, Design): claims hang off the subjects' graph + * nodes (`StrutStep` / `StrutWorkflow`), so the layer exists only when the + * WORKSPACE keeps them in a graph. On a filesystem or in-memory workspace + * this is null — no claim tool is offered and the verify pass is a no-op. + */ +export function claimsReaderFor(workspace: { graph?: GraphBackend }): ClaimsReader | null { + return workspace.graph ? new ClaimsReader(workspace.graph) : null; +} diff --git a/src/graph/fixtures/jarvis-ontology.ts b/src/graph/fixtures/jarvis-ontology.ts index dea427c..1973e14 100644 --- a/src/graph/fixtures/jarvis-ontology.ts +++ b/src/graph/fixtures/jarvis-ontology.ts @@ -14,4 +14,4 @@ export interface OntologyFixture { hidden_domains: string[] | null; } -export const JARVIS_ONTOLOGY: OntologyFixture = {"source":"jarvis-backend local default seed (sphinxlightning/sphinx-neo4j), read-only dump","schemas":[{"ref_id":"83bb6c45-40c5-45de-b76f-8aeb5ee75f33","type":"*","is_system":true},{"next_review":"?datetime","node_key":"aisystem-name","secondary_color":"#D7CCC8","type":"AISystem","obligations_assessed":"?boolean","workflow_position":"?string","review_trigger":"?string","risk_tier":"?string","description":"?string","name":"string","governance_tier":"?string","type_description":"An AI or automated decision-making system subject to governance and regulatory obligations.","domain":"Legal","role":"?string","icon":"ChipIcon","primary_color":"#6D4C41","index":["ref_id","name","risk_tier","regulatory_regime"],"regulatory_regime":"?string","status":"?string","parent":"Thing","shape":"sphere","eu_nexus":"?boolean","human_in_loop":"?boolean","description_key":"description","title_key":"name","classification":"?string","ref_id":"1c93f74c-b212-4493-98b9-833decb96315","obligations_note":"?string","affected_population":"?string","output_type":"?string","tier_basis":"?string"},{"icon":"BookIcon","primary_color":"#222B48","index":["text","level"],"text":"string","abstraction_id":"string","parent":"Workflow","node_key":"abstraction-abstraction_id","secondary_color":"#5E84F8","shape":"sphere","type":"Abstraction","level":"string","description_key":"text","title_key":"level","domain":"Workflow","type_description":"A named level of abstraction for an agent session — title, narrative, or decision — with optional embedding for retrieval.","ref_id":"31ba479a-5928-4ad1-b074-1dedb265036e","embedding":"?string"},{"action_id":"string","icon":"GeneratedStepIcon","primary_color":"#38243C","index":["name","description"],"status":"?string","parent":"Workflow","node_key":"action-action_id","secondary_color":"#F468D4","shape":"sphere","type":"Action","description_key":"description","title_key":"name","description":"?string","name":"?string","domain":"Workflow","type_description":"Represents a single action or procedural step within a broader strategy.","ref_id":"28159b06-14d2-4df3-b0e1-bace578f0320"},{"icon":"GeneratedStepIcon","primary_color":"#38243C","index":["agent_id","role"],"parent":"Workflow","node_key":"agent-agent_id","secondary_color":"#F468D4","shape":"sphere","agent_id":"int","type":"Agent","description_key":"role","title_key":"agent_id","domain":"Workflow","type_description":"An autonomous entity that can perform actions and make decisions.","ref_id":"e54fef17-be5f-4893-8bee-9f1da0bab9b2","role":"?string"},{"icon":"PersonIcon","primary_color":"#222B48","index":["source"],"parent":"Workflow","node_key":"agentrole-source","secondary_color":"#5E84F8","shape":"sphere","type":"AgentRole","description_key":"source","title_key":"source","source":"string","domain":"CodeArtifact","type_description":"A named agent type (e.g. plan-agent, coding-agent) that groups its execution sessions.","ref_id":"cd4025c7-575c-4fd2-8f18-e162bafd0eea"},{"model":"?string","session_id":"string","output_tokens":"?int","total_tokens":"?int","task_id":"?string","duration_ms":"?int","node_key":"agentsession-session_id","log_url":"?string","secondary_color":"#C25AF3","type":"AgentSession","repo":"?string","feature_id":"?string","error_message":"?string","type_description":"A single agent execution run, keyed by per-run UUID.","domain":"CodeArtifact","created_at":"?datetime","icon":"GeneratedStepIcon","primary_color":"#302342","index":["source","status","repo"],"status":"?string","cache_read_tokens":"?int","parent":"Workflow","provider":"?string","input_tokens":"?int","shape":"sphere","description_key":"status","title_key":"source","source":"string","end_time":"?int","ref_id":"e0eace93-0e5c-4f27-bd28-ec94a0f1b17a","start_time":"?int","cache_write_tokens":"?int"},{"agreement_type":"?string","icon":"DocumentIcon","primary_color":"#6D4C41","index":["name","agreement_type","effective_date","governing_law"],"status":"?string","governing_law":"?string","parent":"Legal","node_key":"agreement-id","secondary_color":"#D7CCC8","shape":"sphere","type":"Agreement","id":"string","description_key":"agreement_type","title_key":"name","execution_date":"?string","expiration_date":"?string","name":"?string","domain":"Legal","type_description":"A legally binding contract between two or more parties. Matches the Agreement label produced by the graphrag-contract-review ingestion pipeline.","ref_id":"718a2c7e-fe85-44ae-90ea-847974f6fa78","effective_date":"?string"},{"icon":"PaperClipIcon","primary_color":"#1A3A52","size_bytes":"?int","index":["filename","content_type"],"parent":"Content","node_key":"attachment-filename","secondary_color":"#5BA4CF","shape":"sphere","type":"Attachment","description_key":"content_type","title_key":"filename","domain":"Content","type_description":"A file attached to an email message.","ref_id":"4bbd7399-656d-48b3-9718-8e47ed63c49c","filename":"string","source_link":"?string","content_type":"?string"},{"icon":"NodesIcon","primary_color":"#2A3229","index":"user_reference","update_type":"string","parent":"KnowledgeArtifact","node_key":"belief-user_reference-referenced_id","refutes_supports":"float","date_created":"datetime","secondary_color":"#96BD3F","shape":"sphere","type":"Belief","decay_curve":"?string","confidence_score":"float","user_reference":"string","description_key":"user_reference","title_key":"user_reference","update_trigger":"?string","graph_url":"?string","domain":"KnowledgeArtifact","type_description":"A user epistemic position on a referenced graph node, with confidence scoring, support/refutation polarity, and configurable decay or trigger-based update logic","ref_id":"980dd66a-e226-4bab-9768-0c64be03d14c","referenced_id":"string"},{"cause_detail":"string","icon":"ExclamationCircleIcon","primary_color":"#6D4C41","index":["cause_type","task_slug_prefix","dedup_hash"],"first_seen_run_id":"?string","cause_summary":"string","parent":"Legal","suggested_fix":"?string","node_key":"benchmarkfailurecause-dedup_hash","secondary_color":"#D7CCC8","issue_group":"?string","shape":"sphere","type":"BenchmarkFailureCause","cause_type":"string","dedup_hash":"string","log_evidence":"?string","description_key":"cause_detail","title_key":"cause_summary","last_seen_run_id":"?string","domain":"Legal","type_description":"A classified root cause of failure for one or more Harvey LAB benchmark criteria. One node per unique cause_type + task_slug_prefix + issue_group combination, deduped via SHA-256 hash. Linked to EvalTriggerOutput nodes of all runs that exhibited this cause via ATTRIBUTED_TO, and to the EvalRequirement it affects via AFFECTS. occurrence_count is incremented on each association. Cause types: CALCULATION_ERROR, THRESHOLD_NOT_APPLIED, MISSING_DOWNSTREAM_CONCLUSION, JURISDICTION_RULE_MISSING, MULTI_JURISDICTION_INCOMPLETE, SCOPE_GAP_NOT_IDENTIFIED, CROSS_DOCUMENT_CONFLICT_MISSED, HEADCOUNT_ATTRIBUTION_ERROR, MISSING_STATUTORY_CITATION, PRIVILEGE_MISCLASSIFICATION, AGENT_REASONING_GAP, RETRIEVAL_FAILURE.","ref_id":"4edc2855-3f17-4d1a-bb36-207c07a9b0e5","occurrence_count":"?int","task_slug_prefix":"?string"},{"one_sentence_summary":"?string","phase_priority":"?int","org_uuid":"?string","bounty_type":"?string","node_key":"bounty-bounty_id","show":"?boolean","secondary_color":"#54AC52","estimated_completion_date":"?string","type":"Bounty","bounty_id":"int","estimated_session_length":"?string","title":"?string","deliverables":"?string","paid":"?boolean","created":"?int","updated":"?string","description":"?string","type_description":"A reward offered for completing a task or challenge","domain":"Entity","phase_uuid":"?string","github_description":"?boolean","bounty_expires":"?string","completed":"?boolean","primary_color":"#22362A","workspace_uuid":"?string","index":["description","title"],"ticket_url":"?string","tribe":"?string","assigned_hours":"?int","commitment_fee":"?int","parent":"Thing","assignee":"?string","shape":"sphere","wanted_type":"?string","price":"?int","description_key":"description","title_key":"title","owner_id":"?string","ref_id":"704f1683-7a0d-4792-b772-7c317f4514cb","coding_languages":"?string"},{"icon":"EpisodeIcon","primary_color":"#222B48","index":["episode_title","description"],"status":"?string","episode_title":"?string","media_url":"?string","parent":"Show","node_key":"call-source_link","show_title":"?string","secondary_color":"#5E84F8","shape":"sphere","date":"?datetime","type":"Call","pubkey":"?string","description_key":"description","title_key":"episode_title","image_url":"?string","description":"?string","project_id":"?string","domain":"Content","type_description":"A single installment in a series of related productions e.g Show","ref_id":"1d02e951-e415-4e3f-beb4-b777778501c1","source_link":"string"},{"icon":"ExclamationCircleIcon","primary_color":"#362429","index":["cause_type","severity"],"parent":"Thing","node_key":"cause-id","severity":"?string","secondary_color":"#D25353","shape":"sphere","type":"Cause","cause_type":"?string","id":"string","title":"string","description_key":"description","title_key":"title","resolved":"?boolean","description":"?string","domain":"CodeArtifact","type_description":"A structured root cause attributed to one or more CriterionResult failures. Supports hierarchical grouping via CAUSE_CHILD_OF. cause_type values: prompt_gap | reasoning_error | missing_context | format_violation. severity values: critical | high | medium | low.","ref_id":"a5d69c3a-5208-4cae-8063-5a80493503d0","created_at":"?datetime"},{"transcript":"?string","index":["name","description"],"parent":"Content","node_key":"chapter-name-timestamp-source_link","type":"Chapter","is_favorite":"?boolean","timestamp":"string","description_key":"description","title_key":"name","paid_properties":["transcript"],"is_ad":"?boolean","description":"?string","name":"string","domain":"Content","ref_id":"babd19b8-9d80-46ed-b4b6-0d8472b5fa5a","source_link":"string"},{"speaker_name":"string","index":["name","claim_text","speaker_name"],"triplicate_subject":"?string","claim_text":"string","parent":"Content","node_key":"claim-claim_text-speaker_name","triplicate":"?string","type":"Claim","source_role":"?string","triplicate_predicate":"?string","description_key":"claim_text","title_key":"name","paid_properties":["claim_text"],"name":"string","triplicate_object":"?string","domain":"Content","ref_id":"67fcc508-e296-4411-896b-6f431e8c0c9d"},{"icon":"NodesIcon","body":"?string","index":["name","body"],"parent":"CodeArtifact","node_key":"class-name-file","shape":"sphere","type":"Class","description_key":"body","title_key":"name","start":"?int","file":"string","name":"string","domain":"CodeArtifact","type_description":"A class definition in source code, representing an object-oriented structure with attributes and methods.","ref_id":"1738aaf1-b30a-4f7d-9a76-4cc88c4c367f","end":"?int"},{"icon":"TagIcon","primary_color":"#6D4C41","index":["name"],"parent":"Thing","node_key":"clausetype-name","secondary_color":"#D7CCC8","shape":"sphere","type":"ClauseType","description_key":"description","title_key":"name","description":"?string","name":"string","domain":"Legal","type_description":"A classification label for a ContractClause (e.g. indemnification, termination, confidentiality).","ref_id":"924bb19a-b6f4-4aa2-92a6-832f9dc96a46"},{"transcript":"?string","link":"?string","node_key":"clip-episode_title-timestamp","secondary_color":"#4FA7D9","sentiment_score":"?float","date":"?datetime","type":"Clip","timestamp":"string","image_url":"?string","description":"?string","type_description":"A short audio or video segment extracted from a longer episode","domain":"Content","source_link":"?string","pub_key":"?string","icon":"AudioIcon","primary_color":"#1D3140","text":"?string","index":["episode_title","text","description"],"episode_title":"string","media_url":"string","parent":"Episode","show_title":"?string","shape":"sphere","num_boost":"?int","description_key":"description","title_key":"episode_title","english_translation":"?string","ref_id":"788d5a44-1a5e-4533-8de4-ceaf887ede41","boost":"?int","language":"?string"},{"icon":"ConstructionIcon","index":"name","domain":"CodeArtifact","type_description":"An abstract parent for all source code constructs within a repository.","ref_id":"b9d1bbef-bf58-4877-9181-9e058af71078","parent":"Thing","type":"CodeArtifact"},{"summary":"?string","icon":"ChatAltIcon","primary_color":"#6D4C41","index":["regulation_name","comment_deadline","decision"],"link":"?string","parent":"Legal","node_key":"commentperiod-regulation_name-comment_deadline","regulation_name":"string","secondary_color":"#D7CCC8","shape":"sphere","type":"CommentPeriod","decision":"string","description_key":"decision","title_key":"regulation_name","owner":"?string","domain":"Legal","type_description":"A public comment period for a proposed regulation or rule, with tracking of the organization's response.","ref_id":"9e9c1a31-cf54-4162-8a63-4bff2bdeb952","filed_at":"?datetime","comment_deadline":"datetime","rationale":"?string","notes":"?string"},{"summary":"?string","icon":"NodesIcon","index":["name","message","summary"],"committed_at":"?string","sha":"?string","parent":"Repository","node_key":"commit-sha","shape":"sphere","type":"Commit","url":"?string","namespace":"?string","message":"?string","author":"?string","description_key":"summary","title_key":"name","name":"string","domain":"CodeArtifact","type_description":"An individual git commit, including its message, author, and a summary of the change.","ref_id":"6b564e70-e0c7-4111-8fc1-47a56b689153","source_link":"?string"},{"icon":"NodesIcon","index":"name","count":"?int","parent":"Repository","node_key":"commits-name","shape":"sphere","type":"Commits","namespace":"?string","description_key":"name","title_key":"name","name":"string","domain":"CodeArtifact","type_description":"A collection of individual commit nodes belonging to a repository.","ref_id":"dfe13962-8ead-400c-9145-444acd917242"},{"icon":"CalculatorIcon","primary_color":"#6D4C41","result":"?float","index":["label","matter_slug","discrepancy_flag"],"computed_by":"?string","parent":"Legal","node_key":"computedfigure-label","label":"string","secondary_color":"#D7CCC8","shape":"sphere","type":"ComputedFigure","discrepancy_note":"?string","discrepancy_flag":"boolean","description_key":"formula","title_key":"label","inputs":"?string","verified":"boolean","domain":"Legal","type_description":"An independently-derived numerical calculation with its formula, inputs (Excerpt refs), and result — used by the computation verifier agent to cross-check figures extracted by research agents and flag discrepancies.","ref_id":"434f593f-c719-4010-a5e5-4537e1abce6f","result_string":"?string","statutory_basis":"?string","formula":"string"},{"icon":"NodesIcon","index":["name","description","docs"],"docs":"?string","pr_numbers":"?string","parent":"Thing","node_key":"concept-name","commit_shas":"?string","shape":"sphere","type":"Concept","id":"?string","description_key":"description","title_key":"name","repo":"?string","description":"?string","file":"?string","name":"string","documentation":"?string","domain":"General","type_description":"A reusable unit of knowledge or capability — a codebase capability, a legal methodology, a domain practice. Cross-domain by design, which is why it is homed in General rather than CodeArtifact. BODY FIELD — `docs` is the canonical long-form body (migration 106). stakgraph's lab/concepts pipeline writes `docs` directly over a bolt session (bypassing this API and its schema validation) and its nodeToConcept() reads only `docs`, so `docs` is the field that actually carries nearly every live Concept's content. DEPRECATED — `documentation` is the pre-106 body field. It is still declared so existing API writers do not hard-fail, but it is NO LONGER INDEXED: content written there is invisible to both the composite Data_Bank fulltext index and text_embeddings, and invisible to stakgraph. Write `docs`, not `documentation`. Migration 106 backfilled existing `documentation` bodies into `docs`; removing the attribute outright requires a coordinated writer migration first (a removal would 400 any payload still sending it).","ref_id":"a530f22b-b0aa-46d6-bf6c-3e7e1afa1111","created_at":"?string","last_updated":"?string"},{"conflicts":"?string","icon":"ShieldCheckIcon","primary_color":"#6D4C41","result":"string","index":["matter_slug","checked_date","result"],"parent":"Legal","node_key":"conflictcheck-matter_slug-checked_date","checked_by":"string","secondary_color":"#D7CCC8","shape":"sphere","type":"ConflictCheck","description_key":"result","title_key":"matter_slug","domain":"Legal","type_description":"A conflict-of-interest check performed before or during a legal matter.","ref_id":"6e875c60-3757-4011-8b05-a4f749f97dfe","checked_date":"datetime","notes":"?string","matter_slug":"string"},{"icon":"InterestsIcon","index":"name","domain":"Content","type_description":"Any form of media, including text, images, or videos","ref_id":"60883d1a-6b68-44d5-b0c5-d06bbf9c84f5","parent":"Thing","type":"Content"},{"summary":"?string","icon":"ClipboardIcon","primary_color":"#6D4C41","index":["clause_type","risk_level"],"parent":"Thing","recommendation":"?string","node_key":"contractclause-id","secondary_color":"#D7CCC8","shape":"sphere","confidence":"?float","type":"ContractClause","clause_type":"?string","id":"string","description_key":"risk_level","title_key":"clause_type","risk_level":"?string","priority":"?string","domain":"Legal","type_description":"A discrete clause or provision within a legal agreement, categorised by type and risk level.","ref_id":"8e304fe3-8cd5-43ba-bccf-3aa47af2652e"},{"location":"?string","node_key":"contributor-username","type":"Contributor","namespace":"?string","followers":"?int","following":"?int","username":"string","image_url":"?string","description":"?string","public_repos":"?int","name":"string","type_description":"A person who contributes to a project or repository, including authors and maintainers.","domain":"Entity","icon":"PersonIcon","index":["username","description","bio"],"parent":"Person","shape":"sphere","account_created_at":"?datetime","description_key":"description","title_key":"username","bio":"?string","email":"?string","company":"?string","ref_id":"858f9367-a600-486e-99e4-120a55cc90cf","role_name":"?string"},{"icon":"CorporationIcon","primary_color":"#222B48","index":["name","description"],"parent":"Organization","node_key":"corporation-name","secondary_color":"#5E84F8","shape":"sphere","type":"Corporation","description_key":"description","title_key":"name","image_url":"?string","description":"?string","name":"string","domain":"Entity","type_description":"A legally recognized business entity or company","ref_id":"ccc1a080-92f0-473b-82c1-331cd670346e"},{"icon":"PlaceIcon","primary_color":"#2A2545","index":["name","iso_code"],"iso_code":"?string","parent":"Thing","node_key":"country-name","secondary_color":"#9368FB","shape":"sphere","type":"Country","description_key":"name","title_key":"name","image_url":"?string","name":"string","domain":"Entity","type_description":"A sovereign nation or country, used e.g. as a place of incorporation or governing-law jurisdiction","ref_id":"3a892221-8210-4031-ad89-182677d3c035"},{"icon":"CheckCircleIcon","primary_color":"#36292D","index":["criterion_id","verdict"],"flagged":"?boolean","parent":"Thing","node_key":"criterionresult-id","secondary_color":"#A96755","shape":"sphere","type":"CriterionResult","contested":"?boolean","document_excerpt":"?string","id":"string","llm_flag_reason":"?string","title":"?string","reasoning":"?string","description_key":"reasoning","title_key":"title","verdict":"?string","domain":"CodeArtifact","type_description":"A generic per-criterion, per-run verdict node — the domain-neutral form of RubricCriterion. Records a judge's verdict, reasoning, and flag metadata for a single criterion within one EvalTriggerOutput scored attempt. Used by both Hive prompt/agent evals and the Harvey benchmark unified model.","ref_id":"13959bfa-6d96-4106-b490-34316bb311e5","criterion_id":"string","agent_thinking_excerpt":"?string"},{"icon":"ClipboardListIcon","primary_color":"#6D4C41","index":["request_id","status","category"],"status":"?string","request_text":"?string","parent":"Legal","node_key":"ddrequestitem-request_id","secondary_color":"#D7CCC8","shape":"sphere","type":"DDRequestItem","category":"?string","description_key":"category","request_id":"string","title_key":"request_text","fulfilled_date":"?datetime","responding_party":"?string","domain":"Legal","type_description":"A discrete line item within a due diligence request list, tracking the request, its status, and the responding party.","ref_id":"ca6bd1b2-4177-4a09-bc1f-a3841383c5f6","due_date":"?datetime","notes":"?string"},{"exemptions":"?string","deadline_statutory":"?datetime","node_key":"dsarrequest-id","secondary_color":"#D7CCC8","type":"DSARRequest","date_responded":"?datetime","what_produced":"?string","id":"string","deadline_internal":"?datetime","type_description":"A Data Subject Access Request (DSAR) submitted under a privacy regulation.","domain":"Legal","regime":"?string","icon":"IdentificationIcon","primary_color":"#6D4C41","index":["id","right_invoked","regime","status"],"status":"?string","date_received":"?datetime","parent":"Thing","date_identity_verified":"?datetime","shape":"sphere","identity_verified":"?boolean","description_key":"right_invoked","title_key":"id","handled_by":"?string","ref_id":"adffcca1-968d-46c3-b7aa-066899b9c5dd","right_invoked":"?string"},{"field_name":"string","observed_value":"?string","icon":"ExclamationIcon","primary_color":"#6D4C41","index":["anomaly_type","source_doc_ref","resolved"],"parent":"Legal","node_key":"dataanomalyrecord-source_doc_ref-field_name","severity":"?string","secondary_color":"#D7CCC8","shape":"sphere","type":"DataAnomalyRecord","source_doc_ref":"string","description_key":"anomaly_type","title_key":"field_name","detected_at":"datetime","resolved":"boolean","expected_value_or_rule":"?string","domain":"Legal","type_description":"A data quality anomaly detected at document ingest time — impossible dates, missing required fields, stale denominators, or cross-document value mismatches. Also covers one-sided findings (observed_value and expected_value_or_rule are both optional): unexpected-location facts (anomaly_type='unexpected_location' — a substantive fact appearing in a document where it is not expected, or a controlling primary record differing from the citing document; observed_value=actual location, expected_value_or_rule=expected location), absent clause types (anomaly_type='missing_clause_type'), figures asserted without derivation (anomaly_type='asserted_without_derivation'), and intra-section self-contradictions (anomaly_type='self_contradiction'). Anchor to the relevant node via FLAGS (TimelineEntry, ContractClause, Figure, Document, Excerpt, or a clause-type Concept for absence findings). Seeded before research rounds begin so all agents are aware of adversarial data traps.","ref_id":"921bc872-85f1-45aa-98b9-4ded0b027362","anomaly_type":"string"},{"icon":"NodesIcon","body":"?string","index":["name","body"],"parent":"CodeArtifact","node_key":"datamodel-name-file","shape":"sphere","type":"Datamodel","description_key":"body","title_key":"name","start":"?int","file":"string","name":"string","domain":"CodeArtifact","type_description":"A structured representation of data within a system, typically defining entities, relationships, attribute types, and corresponding SQL table definitions.","ref_id":"a76bdcfb-652e-4568-ab51-949e97f4543d","end":"?int"},{"icon":"CalendarIcon","primary_color":"#6D4C41","index":["deadline_type","matter_slug","due_date","status"],"status":"?string","parent":"Thing","node_key":"deadline-deadline_type-matter_slug-due_date","secondary_color":"#D7CCC8","shape":"sphere","type":"Deadline","description_key":"due_date","title_key":"deadline_type","deadline_type":"string","description":"?string","warning_days":"?int","owner":"?string","domain":"Legal","type_description":"A legal or regulatory deadline associated with a matter, agreement, IP asset, or AI system.","ref_id":"45edc8b5-a569-40f2-9470-152940e688e3","due_date":"string","matter_slug":"string"},{"icon":"BookOpenIcon","primary_color":"#6D4C41","index":["term","matter_slug","source_ref","scope"],"scope":"?string","definition_text":"string","parent":"Legal","node_key":"definedterm-matter_slug-source_ref-term","source_ref":"string","secondary_color":"#D7CCC8","shape":"sphere","first_used_section":"?string","type":"DefinedTerm","description_key":"definition_text","title_key":"term","term":"string","domain":"Legal","type_description":"A term as defined by one specific document — the per-document definition record, distinct from Lingo (the global vocabulary entry). One node per (matter_slug, source_ref, term), so two documents defining the same term differently yield two nodes; definitional divergence is asserted via DefinedTerm-[CONFLICTS_WITH]->DefinedTerm, and cross-document borrowing (a document using a term it does not itself define) via DefinedTerm-[USED_IN]->the borrowing document.","ref_id":"b7017069-4ffa-4851-847b-115aead96392","matter_slug":"string"},{"icon":"ClipboardCheckIcon","primary_color":"#6D4C41","index":["deliverable_type"],"parent":"Legal","node_key":"deliverableschema-deliverable_type","required_sections":"?string","deliverable_type":"string","secondary_color":"#D7CCC8","shape":"sphere","type":"DeliverableSchema","required_tabs":"?string","description_key":"deliverable_type","title_key":"deliverable_type","placeholder_patterns":"?string","domain":"Legal","type_description":"Specifies the required structure of a task output deliverable — tabs, columns, required sections, and placeholder patterns. Used by the deliverable schema enforcer to validate synthesized outputs before scoring.","required_columns":"?string","ref_id":"f66c86cd-a615-42d7-afad-da26efa1a355","formatting_rules":"?string"},{"icon":"ExclamationCircleIcon","primary_color":"#6D4C41","index":["category","severity","status"],"status":"string","parent":"Legal","node_key":"diligenceissue-category-description","severity":"string","secondary_color":"#D7CCC8","shape":"sphere","resolution":"?string","type":"DiligenceIssue","category":"string","description_key":"category","title_key":"description","description":"string","domain":"Legal","type_description":"A due-diligence issue identified during a deal or transaction review.","ref_id":"bcce77bc-1cc4-409f-bb93-64bd977da832","source_doc":"?string","materiality":"?string"},{"body":"?string","index":["name","description"],"parent":"Repository","node_key":"directory-name-file","shape":"sphere","type":"Directory","description_key":"description","title_key":"name","start":"?int","description":"?string","file":"string","name":"string","domain":"CodeArtifact","type_description":"A folder within a repository that organizes files and subdirectories.","ref_id":"350dee1e-e4be-4366-b878-2dcda901d3f1","source_link":"?string","end":"?int"},{"icon":"BookOpenIcon","primary_color":"#6D4C41","practice_area":"?string","index":["name","jurisdiction_ref","authority_level","practice_area"],"superseded_by":"?string","parent":"Legal","node_key":"doctrine-jurisdiction_ref-name","secondary_color":"#D7CCC8","shape":"sphere","type":"Doctrine","authority_level":"?string","jurisdiction_ref":"string","description_key":"rule_text","title_key":"name","source_citation":"?string","rule_text":"string","name":"string","domain":"Legal","type_description":"A standing legal rule or doctrine scoped to a jurisdiction — e.g. Third Circuit dominant-purpose test, post-TCJA 80% NOL cap. Distinct from LegalArgument (per-matter finding) and LegalDocument (case citation). Allows agents to retrieve jurisdiction-specific legal rules directly from the graph.","ref_id":"56f872ad-b980-4aea-8cac-79cd0ed583cc","effective_date":"?string"},{"summary":"?string","icon":"DocumentIcon","primary_color":"#302342","index":["source_link","title"],"status":"?string","parent":"Content","node_key":"document-source_link","secondary_color":"#C25AF3","shape":"sphere","type":"Document","pubkey":"?string","author":"?string","title":"?string","description_key":"summary","title_key":"title","domain":"Content","type_description":"A written, printed, or digital record of information","ref_id":"cde66016-4d46-4d55-b5b6-285ddcc5cfff","source_link":"string","content_type":"?string"},{"icon":"DocumentSearchIcon","primary_color":"#6D4C41","index":["document_subtype"],"parent":"Legal","example_values":"?string","node_key":"documenttypetemplate-document_subtype","secondary_color":"#D7CCC8","shape":"sphere","type":"DocumentTypeTemplate","description_key":"document_subtype","title_key":"document_subtype","mandatory_fields":"string","domain":"Legal","type_description":"Maps a legal document subtype to its mandatory quantitative fields — used by specificsSweep to generate targeted extraction queries per document type at task start, ensuring policy parameters (e.g. RWI limit + retention) are always seeded as findings even if no research agent explicitly queries for them.","ref_id":"631316a5-10e8-4ed8-83e3-75f7b91647df","document_subtype":"string"},{"body":"?string","index":["name","body"],"parent":"Test","node_key":"e2etest-name-file","type":"E2etest","description_key":"body","title_key":"name","start":"?int","file":"string","name":"string","domain":"CodeArtifact","type_description":"An end-to-end test that validates the entire system functionality by simulating real-world interactions.","ref_id":"15fed8c3-c844-407d-bd3a-e0e9049d2c5f","end":"?int"},{"icon":"MailIcon","primary_color":"#1A3A52","index":["message_id","subject","thread_id"],"subject":"string","parent":"Message","message_id":"string","node_key":"email-message_id","secondary_color":"#5BA4CF","shape":"sphere","type":"Email","in_reply_to":"?string","references":"?string","description_key":"message_id","title_key":"subject","domain":"Content","type_description":"An email message in the knowledge graph.","ref_id":"56eacec5-cd7c-4cd7-a581-4a5c4a6c75f3","thread_id":"?string"},{"body":"?string","subject":"string","node_key":"emailinvite-message_id","secondary_color":"#5BA4CF","conference_platform":"?string","type":"EmailInvite","timezone":"?string","type_description":"A calendar invitation delivered via email (iCalendar / .ics attachment).","domain":"Content","conference_url":"?string","icon":"CalendarIcon","primary_color":"#1A3A52","index":["message_id","subject","start_datetime"],"location_type":"?string","end_datetime":"?datetime","status":"?string","organizer":"?string","parent":"Message","message_id":"string","shape":"sphere","start_datetime":"?datetime","description_key":"start_datetime","recurrence":"?string","title_key":"subject","ref_id":"4d3f887d-6268-4683-8811-b9b2ca5cbb9f"},{"summary":"?string","body":"?string","node_key":"endpoint-name-file-verb","type":"Endpoint","namespace":"?string","verb":"string","description":"?string","file":"string","name":"string","type_description":"A defined entry point for accessing functionality within an application or service, typically through an API.","domain":"CodeArtifact","source_link":"?string","end":"?int","icon":"ConstructionIcon","text":"?string","index":["name","summary","description"],"hash":"?string","parent":"CodeArtifact","shape":"sphere","description_key":"summary","title_key":"name","start":"?int","ref_id":"87dcc921-01cb-4552-a710-ab06dc994260","method":"?string","handler":"?string"},{"icon":"NodesIcon","primary_color":"#1B3134","index":"entity","trusted":"?boolean","entity":"string","parent":"Thing","node_key":"entity-entity","secondary_color":"#21B38A","shape":"sphere","type":"Entity","entity_lower":"?string","description_key":"entity","title_key":"entity","domain":"Entity","type_description":"A general entity that can represent any identified concept or object","ref_id":"5b8aea7c-1ffd-433d-989f-6be32a0d6788","metaphone3":"?string"},{"summary":"?string","transcript":"?string","node_key":"episode-source_link","secondary_color":"#5E84F8","date":"?datetime","type":"Episode","image_url":"?string","description":"?string","type_description":"A single installment in a series of related productions e.g Show","domain":"Content","source_link":"string","icon":"EpisodeIcon","primary_color":"#222B48","index":["episode_title","description","summary"],"status":"?string","episode_title":"?string","media_url":"?string","parent":"Show","show_title":"?string","shape":"sphere","pubkey":"?string","duration":"?string","description_key":"description","title_key":"episode_title","paid_properties":["media_url","transcript"],"project_id":"?string","ref_id":"79c0a059-e819-42ec-bc69-d274076e1e62"},{"repo_key":"?string","icon":"ConstructionIcon","primary_color":"#2A2545","exceptionType":"?string","index":["title","exceptionType","fingerprint","status"],"first_seen_at":"?string","status":"?string","workspace_id":"?string","parent":"CodeArtifact","node_key":"errorissue-fingerprint","secondary_color":"#C25AF3","shape":"sphere","type":"ErrorIssue","repository_id":"?string","occurrenceCount":"?int","fingerprint":"string","title":"?string","description_key":"exceptionType","title_key":"title","last_seen_at":"?string","domain":"CodeArtifact","type_description":"A tracked error or exception in a codebase, identified by a unique fingerprint and associated with files and functions in the call stack.","ref_id":"afd6193e-e613-4ea0-a7cc-3cfa3d681145"},{"icon":"ExclamationCircleIcon","primary_color":"#6D4C41","index":["matter_slug","escalation_type","status"],"status":"string","trigger_finding_ref":"string","parent":"Legal","node_key":"escalationrecord-matter_slug-trigger_finding_ref","secondary_color":"#D7CCC8","shape":"sphere","type":"EscalationRecord","rpc_rule_ref":"?string","escalation_type":"string","description_key":"matter_slug","title_key":"escalation_type","resolved_by":"?string","domain":"Legal","type_description":"A structured record of a legal ethics or crime-fraud escalation triggered by a finding — capturing the escalation type, recommended actions (senior counsel / in camera / ethics consult), and the RPC rule that mandates it.","ref_id":"02e1b372-1816-4381-b54e-c5c1f927d640","recommended_actions":"?string","notes":"?string","matter_slug":"string"},{"icon":"StepIcon","primary_color":"#1B3134","index":["name","description"],"positive_cases":"?list","negative_cases":"?list","parent":"Thing","node_key":"evalrequirement-id","secondary_color":"#21B38A","shape":"sphere","type":"EvalRequirement","contested":"?boolean","id":"string","deliverables":"?list","updated_at":"?datetime","description_key":"description","title_key":"name","prompt_snippet":"?string","description":"?string","name":"?string","domain":"CodeArtifact","type_description":"A success criterion (test case) with positive and negative examples, captured from a real agent interaction.","ref_id":"3c1385ec-7602-4a68-9605-780ab678a97a","created_at":"?datetime"},{"icon":"TaskIcon","primary_color":"#1D3140","index":["name","description"],"parent":"Thing","node_key":"evalset-id","secondary_color":"#4FA7D9","shape":"sphere","type":"EvalSet","id":"string","updated_at":"?datetime","description_key":"description","title_key":"name","description":"?string","project_id":"?int","name":"?string","domain":"CodeArtifact","type_description":"A dataset (suite) grouping related test cases (EvalRequirements) for validating agent behaviour.","ref_id":"3e5e8c3d-0819-4659-8cff-ff7ad6f9a7ee","created_at":"?datetime","recursion":"?boolean"},{"body":"?string","start_point":"?string","workflow_input":"?string","prompts":"?list","node_key":"evaltrigger-id","secondary_color":"#BA9D39","type":"EvalTrigger","id":"string","prompt_id":"?string","workflow_id":"?string","type_description":"A captured, replayable input configuration — the source event that gave rise to an EvalRequirement.","domain":"CodeArtifact","icon":"GeneratedStepIcon","primary_color":"#353124","positive_cases":"?list","index":["agent","environment","change_type"],"end_point":"?string","negative_cases":"?list","workflow_version_id":"?string","parent":"Thing","shape":"sphere","change_type":"?string","agent":"?string","endpoint_url":"?string","prompt_version_id":"?string","run_count":"?int","description_key":"change_type","environment":"?string","title_key":"agent","source":"?string","paid_properties":["workflow_input"],"project_id":"?string","ref_id":"0b3c193c-6b9a-4020-96eb-c798b645cccd"},{"result":"string","judge_notes":"?string","judge_model":"?string","score":"?float","node_key":"evaltriggeroutput-id","secondary_color":"#E09242","type":"EvalTriggerOutput","id":"string","n_passed":"?int","n_total":"?int","type_description":"The result of a single evaluation run attempt: score, verdict, and judge notes. report_url, when present, is the URL of the run report bundle for this scored attempt — an https:// Stakwork-hosted bundle URL; optional and null on historical nodes. Consumers must treat it as untrusted input and must validate scheme and host before fetching (SSRF caution). The concept-analysis consumer must read report_url via an authenticated/proxied route, as it is a paid property and is silently stripped to null for unauthenticated readers.","verdict":"?string","domain":"CodeArtifact","max_score":"?float","icon":"StepIcon","primary_color":"#392828","index":"evaltriggeroutput-id","attempt_number":"?int","parent":"Thing","shape":"sphere","endpoint_url":"?string","description_key":"judge_notes","title_key":"result","report_url":"?string","paid_properties":["report_url"],"ref_id":"1826ff32-dfe6-409e-857d-187142aff915"},{"icon":"EventIcon","primary_color":"#38243C","index":["name","description"],"parent":"Thing","node_key":"event-name","secondary_color":"#F468D4","shape":"sphere","type":"Event","description_key":"description","title_key":"name","image_url":"?string","description":"?string","name":"string","domain":"Entity","type_description":"A thing that happens or takes place, especially one of importance","ref_id":"431687d4-a420-46fb-93b2-0acc412559e1"},{"icon":"QuoteIcon","primary_color":"#6D4C41","index":["text"],"text":"string","is_verbatim":"?boolean","page_number":"?int","parent":"Thing","node_key":"excerpt-text","secondary_color":"#D7CCC8","shape":"sphere","confidence":"?float","type":"Excerpt","bbox_y1":"?float","section":"?string","bbox_y0":"?float","bbox_x1":"?float","bbox_x0":"?float","description_key":"text","title_key":"text","domain":"Legal","type_description":"A verbatim text excerpt from a legal document. May carry a dual embedding: jarvis 384-dim text_embeddings and a graphrag-contract-review 1536-dim embedding property.","ref_id":"4a8dda06-3855-4be4-83c6-1b0de2c222ef"},{"icon":"NodesIcon","index":["name","description","documentation"],"pr_numbers":"?string","parent":"CodeArtifact","node_key":"feature-name","commit_shas":"?string","shape":"sphere","type":"Feature","description_key":"description","title_key":"name","repo":"?string","description":"?string","file":"?string","name":"string","documentation":"?string","domain":"CodeArtifact","type_description":"A specific capability or functionality within the application or codebase.","ref_id":"0fb370dc-4a4b-4caf-b3d7-a56149cdc366","created_at":"?string","last_updated":"?string"},{"page_number":"?int","node_key":"figure-matter_slug-source_ref-locator","secondary_color":"#D7CCC8","source_ref":"string","type":"Figure","currency":"?string","value_as_stated":"string","value_normalized":"?float","cell_ref":"?string","sheet_name":"?string","type_description":"The primary, as-stated datum captured verbatim from one specific source location — PRE-COMPUTATION and PRE-DERIVATION. NOT a ComputedFigure (a derived calculation) and NOT a FormulaComponent (a named formula input). ComputedFigure and FormulaComponent should link here via DERIVED_FROM edges to trace every calculated result back to its primary source.\n\nSOURCE_REF / LOCATOR GRAMMAR (agents MUST emit this exact form — punctuation and case are stripped before keying, so an inconsistent rendering creates a duplicate node for the same cell):\n source_ref = workbook or document filename exactly as ingested (e.g. 'RateCard_2024.xlsx').\n locator = ! for spreadsheets (e.g. 'RateCard!C2') OR p\\u00a7
for prose (e.g. 'p12\\u00a74.2').\n\nRE-INGEST SEMANTICS: re-ingest updates the node only when the caller passes reprocess=True (per create_or_merge_node in api/helper/schema_node_helper.py); non-key fields are last-write-wins (ON MATCH SET).","domain":"Legal","icon":"DocumentIcon","primary_color":"#6D4C41","locator":"string","index":["label","matter_slug","source_ref","locator","sheet_name","cell_ref","page_number","section","value_as_stated"],"parent":"Legal","label":"string","shape":"sphere","section":"?string","unit":"?string","extracted_by":"?string","description_key":"value_as_stated","title_key":"label","ref_id":"35aa277c-080f-41ba-8964-8d1d882ea91c","matter_slug":"string"},{"summary":"?string","icon":"ConstructionIcon","body":"?string","index":["name","summary"],"text":"?string","hash":"?string","parent":"Directory","node_key":"file-name-file","code":"?string","shape":"sphere","type":"File","description_key":"summary","title_key":"name","start":"?int","file":"string","name":"string","domain":"CodeArtifact","type_description":"A file within a repository, containing source code, configuration, or other project-related content.","ref_id":"1edd4f6d-b07c-4309-b40b-c4997cf35830","source_link":"?string","end":"?int"},{"current_value":"?string","index":["fluent_of","fluent_property"],"fluent_of":"string","domain":"entity","ref_id":"4c3db758-aec4-411e-bd5f-5b4fd78f6ccd","parent":"Thing","node_key":"fluent-fluent_of-fluent_property","type":"Fluent","fluent_property":"string"},{"value_ref_id":"string","asserted_by":"?string","index":["fluent_ref_id","valid_from"],"valid_from":"datetime","domain":"entity","ref_id":"3da3f8ca-7826-4137-ab3e-fe1a1f40fdf1","parent":"Thing","node_key":"fluentevent-fluent_ref_id-valid_from","confidence":"?float","fluent_ref_id":"string","type":"FluentEvent","valid_to":"?datetime"},{"icon":"CalculatorIcon","primary_color":"#6D4C41","index":["label"],"parent":"Legal","node_key":"formulacomponent-label","label":"string","secondary_color":"#D7CCC8","shape":"sphere","type":"FormulaComponent","unit":"?string","description_key":"description","title_key":"label","source":"?string","description":"?string","domain":"Legal","value":"?float","type_description":"A named, typed numeric input to a formula — carries its raw value, unit, source attribution, and plain-language description.","ref_id":"66362f5a-19d8-46cc-8f5b-faf53d10cc03"},{"summary":"?string","icon":"ConstructionIcon","body":"?string","index":["name","summary","description"],"text":"?string","docs":"?string","parent":"CodeArtifact","node_key":"function-name-file","code":"?string","shape":"sphere","type":"Function","operand":"?string","description_key":"summary","title_key":"name","output_data_type":"?string","start":"?int","description":"?string","file":"string","name":"string","domain":"CodeArtifact","type_description":"A function or method definition in source code, representing executable logic within a program, including backend logic and frontend components.","ref_id":"2c2b38fb-3bc9-415e-ac9d-af2d7326b153","source_link":"?string","end":"?int"},{"gap_type":"?string","remediation_plan":"?string","node_key":"gapitem-id","secondary_color":"#D7CCC8","resolution":"?string","type":"GapItem","id":"string","significance":"?string","description":"?string","priority":"?string","type_description":"A compliance gap — a requirement not yet met by the organization, with remediation tracking.","domain":"Legal","notified":"?boolean","opened":"?datetime","comment_tracker_id":"?string","icon":"ExclamationIcon","primary_color":"#6D4C41","policy_affected":"?string","index":["id","gap_type","owner","status"],"requirement":"?string","status":"?string","parent":"Thing","status_verified":"?boolean","shape":"sphere","change_needed":"?string","description_key":"gap_type","title_key":"requirement","owner":"?string","regulation":"?string","ref_id":"54743019-f3eb-4c15-b9ca-0ab5cece8753","due":"?string"},{"output_json":"?string","node_key":"generated_step-step_unique_id","secondary_color":"#F468D4","type":"Generated_step","workflow_position":"?int","id":"string","description":"?string","name":"string","type_description":"A dynamically generated step in a workflow with specific configuration and parameters","domain":"Workflow","skill_name":"?string","skill_id":"?int","icon":"GeneratedStepIcon","primary_color":"#38243C","index":["name","description"],"parent":"Workflow","input_json":"?string","shape":"sphere","params":"?string","step_unique_id":"string","description_key":"description","title_key":"name","step_json":"?string","ref_id":"72935346-72fa-41e8-b1d2-b05392010461","attributes":"?string"},{"icon":"NodesIcon","index":["name","description"],"parent":"Repository","node_key":"githubrepo-name","shape":"sphere","type":"GitHubRepo","namespace":"?string","forks":"?int","description_key":"description","title_key":"name","description":"?string","stars":"?int","age_years":"?float","name":"string","domain":"CodeArtifact","type_description":"A GitHub repository containing metadata such as stars, forks, description, and language information.","ref_id":"ca6c9c0b-3bad-4421-993e-aedb7dbcd783","language":"?string"},{"index":["name","description"],"twitter_handle":"?string","parent":"Person","node_key":"guest-name","type":"Guest","is_favorite":"?boolean","description_key":"description","title_key":"name","image_url":"?string","description":"?string","name":"string","domain":"Entity","ref_id":"dde71be2-df05-4fa4-a47e-8ba8a4575a0b"},{"icon":"NodesIcon","index":["ref_id","question","answer"],"answer":"?string","parent":"KnowledgeArtifact","node_key":"hint-ref_id","shape":"sphere","type":"Hint","description_key":"answer","title_key":"ref_id","domain":"KnowledgeArtifact","type_description":"A question and answer pair generated from exploring the codebase to capture contextual understanding.","ref_id":"db4b0f4c-d1df-4188-813c-ba2b928085e3","question":"?string"},{"icon":"NodesIcon","primary_color":"#1B3134","index":["name","display_name","description"],"default_model":"?string","parent":"Thing","node_key":"hiveagent-name","secondary_color":"#21B38A","shape":"sphere","type":"HiveAgent","display_name":"?string","updated_at":"?string","description_key":"description","title_key":"display_name","description":"?string","name":"string","domain":"Hive","type_description":"An AI agent in the Hive agent catalog, mirrored from the gateway registry.","ref_id":"6704bd70-20b1-4891-8fb7-2689751f9e97"},{"icon":"MessageIcon","primary_color":"#1D3140","index":["name","message"],"status":"?string","task_id":"?string","parent":"Thing","message_id":"string","node_key":"hivechatmessage-message_id","secondary_color":"#4FA7D9","shape":"sphere","type":"HiveChatMessage","message":"string","updated_at":"?string","description_key":"message","title_key":"name","feature_id":"?string","name":"string","domain":"Hive","type_description":"A chat message in a Hive task/feature conversation, mirrored from Postgres.","ref_id":"4ca85386-86da-49fc-b85d-53452dac07e9","role":"?string","created_at":"?string","user_id":"?string"},{"decision_id":"string","icon":"MessageIcon","primary_color":"#302342","index":["name","text"],"text":"?string","canvas_ref":"?string","parent":"Thing","node_key":"hivedecision-decision_id","secondary_color":"#C25AF3","shape":"sphere","type":"HiveDecision","description_key":"text","title_key":"name","name":"string","domain":"Hive","type_description":"A decision extracted from a Hive canvas, mirrored from Postgres.","ref_id":"8694bce9-a70b-4922-8bed-4e86733cbfa5","y":"?float","x":"?float"},{"icon":"NodesIcon","primary_color":"#2A2545","index":["name","brief","requirements","architecture"],"status":"?string","workspace_id":"?string","parent":"Thing","node_key":"hivefeature-feature_id","secondary_color":"#9368FB","shape":"sphere","architecture":"?string","type":"HiveFeature","requirements":"?string","updated_at":"?string","description_key":"brief","title_key":"name","assignee_id":"?string","priority":"?string","feature_id":"string","name":"string","domain":"Hive","type_description":"A roadmap feature in the Hive PM app, mirrored from Postgres.","ref_id":"b9c9f00c-40dd-48a8-8c38-656fdfa325d7","created_at":"?string","brief":"?string"},{"icon":"NodesIcon","primary_color":"#22362A","index":["name","description"],"completed_at":"?string","status":"?string","parent":"Thing","node_key":"__HISTRUTIT_KEEP__-initiative_id","secondary_color":"#54AC52","shape":"sphere","type":"HiveInitiative","org_id":"?string","updated_at":"?string","description_key":"description","title_key":"name","assignee_id":"?string","description":"?string","name":"string","initiative_id":"string","domain":"Hive","type_description":"An org-level initiative in the Hive PM app, mirrored from Postgres.","ref_id":"abebfe52-8b71-487c-84ce-6c7048e49f04","target_date":"?string","created_at":"?string","start_date":"?string"},{"milestone_id":"string","icon":"NodesIcon","primary_color":"#2A3229","index":["name","description"],"completed_at":"?string","status":"?string","parent":"Thing","node_key":"hivemilestone-milestone_id","secondary_color":"#96BD3F","shape":"sphere","type":"HiveMilestone","updated_at":"?string","description_key":"description","title_key":"name","assignee_id":"?string","description":"?string","sequence":"?int","name":"string","initiative_id":"?string","domain":"Hive","type_description":"A milestone within a Hive initiative, mirrored from Postgres.","ref_id":"8b0a7cf1-4d62-4470-a360-768874a9686b","due_date":"?string","created_at":"?string"},{"icon":"MessageIcon","primary_color":"#38243C","index":["name","text"],"text":"?string","canvas_ref":"?string","parent":"Thing","node_key":"hivenote-note_id","secondary_color":"#F468D4","shape":"sphere","type":"HiveNote","description_key":"text","title_key":"name","note_id":"string","name":"string","domain":"Hive","type_description":"A note extracted from a Hive canvas, mirrored from Postgres.","ref_id":"28f166cc-6b8e-411b-b6fe-fd2f8e432a18","y":"?float","x":"?float"},{"summary":"?string","topic":"?string","icon":"NodesIcon","primary_color":"#36292D","index":["name","topic","summary","content"],"parent":"Thing","node_key":"hiveresearch-research_id","secondary_color":"#A96755","shape":"sphere","type":"HiveResearch","content":"?string","org_id":"?string","updated_at":"?string","description_key":"summary","title_key":"name","name":"string","research_id":"string","initiative_id":"?string","domain":"Hive","type_description":"A research document in the Hive PM app, mirrored from Postgres.","ref_id":"486de1e8-9fe2-4509-bbf7-cfdc15c3cecd","created_at":"?string","slug":"?string"},{"summary":"?string","task_id":"string","node_key":"hivetask-task_id","source_type":"?string","secondary_color":"#5E84F8","type":"HiveTask","repository_id":"?string","assignee_id":"?string","description":"?string","priority":"?string","feature_id":"?string","name":"string","type_description":"A task / work item in the Hive PM app, mirrored from Postgres.","domain":"Hive","created_at":"?string","phase_id":"?string","icon":"TaskIcon","primary_color":"#222B48","index":["name","description","summary"],"status":"?string","workspace_id":"?string","parent":"Thing","shape":"sphere","updated_at":"?string","description_key":"description","title_key":"name","ref_id":"4c420f55-a788-443a-af4c-75fbe926513e","branch":"?string"},{"icon":"BriefcaseIcon","primary_color":"#362429","index":["name","description","mission"],"workspace_id":"string","parent":"Thing","node_key":"hiveworkspace-workspace_id","secondary_color":"#D25353","shape":"sphere","type":"HiveWorkspace","updated_at":"?string","description_key":"description","title_key":"name","mission":"?string","description":"?string","name":"string","domain":"Hive","type_description":"A Hive workspace, mirrored from Postgres. The single anchor node for workspace-level knowledge: general Concepts (preferences, best practices, gotchas, processes) link here rather than to any repository.","ref_id":"d7f784a6-faa5-4721-99ef-76f1d628c13a","created_at":"?string","slug":"?string"},{"icon":"PersonIcon","primary_color":"#38243C","index":["name","description"],"parent":"Thing","node_key":"hiveworkspacemember-member_id","secondary_color":"#F468D4","shape":"sphere","type":"HiveWorkspaceMember","member_id":"string","description_key":"description","title_key":"name","description":"?string","name":"string","joined_at":"?string","domain":"Hive","type_description":"A member of a Hive workspace, mirrored from Postgres. Anchor node for per-person knowledge: a member's preference Concepts link here via PREFERENCE edges.","ref_id":"88e5a7c7-2b22-471e-a7d4-11cea2a57130","role":"?string","github_username":"?string","user_id":"?string"},{"index":["name","description"],"description_key":"description","title_key":"name","image_url":"?string","description":"?string","name":"string","domain":"Entity","ref_id":"b1c5f9b6-0f92-4613-9145-9a2798624fed","parent":"Person","node_key":"host-name","type":"Host","is_favorite":"?boolean"},{"registration_id":"string","use_in_commerce":"?boolean","owner_name":"?string","node_key":"ipasset-registration_id","secondary_color":"#D7CCC8","type":"IPAsset","docket_id":"?string","asset_type":"?string","name":"?string","type_description":"An intellectual property asset such as a patent, trademark, or copyright.","domain":"Legal","agent_managed":"?boolean","business_owner":"?string","classes":"?string","icon":"LightBulbIcon","primary_color":"#6D4C41","index":["registration_id","asset_type","name","status"],"priority_date":"?string","status":"?string","deployment_risk":"?string","local_agent":"?string","parent":"Thing","shape":"sphere","filing_date":"?string","description_key":"asset_type","title_key":"name","renewal_deadline":"?string","license_bucket":"?string","expiration_date":"?string","grant_date":"?string","ref_id":"911e578d-4dcf-4117-bf8d-e31162a451ac"},{"body":"?string","index":["name","body"],"parent":"CodeArtifact","node_key":"import-name-file","type":"Import","description_key":"body","title_key":"name","start":"?int","file":"string","name":"string","domain":"CodeArtifact","type_description":"A section at the top of a file that contains all imported modules, libraries, or dependencies used within the file.","ref_id":"370d619f-e4fb-42ca-8ed4-25034c178aab","end":"?int"},{"icon":"NodesIcon","body":"?string","index":["name","body"],"parent":"CodeArtifact","node_key":"instance-name-file","shape":"sphere","type":"Instance","description_key":"body","title_key":"name","start":"?int","file":"string","name":"string","domain":"CodeArtifact","type_description":"An instantiation of a class in source code, representing a specific object created from a class definition.","ref_id":"7aa1521f-4804-476f-9aca-066976165a5d","end":"?int"},{"icon":"NodesIcon","body":"?string","index":["name","body"],"parent":"Test","node_key":"integrationtest-name-file","shape":"sphere","type":"IntegrationTest","description_key":"body","title_key":"name","start":"?int","file":"string","name":"string","domain":"CodeArtifact","type_description":"An integration test verifying multiple components or an API boundary working together.","ref_id":"d29c7643-41e0-4d28-b334-d234e96be5d2","end":"?int"},{"icon":"SearchIcon","primary_color":"#6D4C41","index":["matter_slug","status","investigation_type"],"status":"string","parent":"Legal","node_key":"investigationlog-allegation","secondary_color":"#D7CCC8","shape":"sphere","type":"InvestigationLog","conduct_timeframe":"?string","attorney_directed":"boolean","investigation_type":"?string","allegation":"string","description_key":"investigation_type","title_key":"allegation","domain":"Legal","type_description":"An attorney-directed or HR investigation log for a workplace or regulatory matter.","ref_id":"40876b28-17da-4fbd-aef0-305af9809be9","last_updated":"?datetime"},{"icon":"NodesIcon","index":"name","count":"?int","parent":"Repository","node_key":"issues-name","shape":"sphere","type":"Issues","namespace":"?string","description_key":"name","title_key":"name","name":"string","domain":"CodeArtifact","type_description":"A collection of individual issue nodes belonging to a repository.","ref_id":"57061e5d-760d-4765-87da-e91cf6420b66"},{"icon":"NodesIcon","index":["name","description"],"parent":"KnowledgeArtifact","node_key":"jargon-name","shape":"sphere","type":"Jargon","description_key":"description","title_key":"name","description":"?string","name":"string","domain":"KnowledgeArtifact","type_description":"An internally-used term and its meaning, optionally linked to the graph node it names.","ref_id":"9bde9004-f412-48c2-ad52-3809ce2fbdf6"},{"forum":"?string","state":"?string","node_key":"jurisdiction-name","secondary_color":"#D7CCC8","type":"Jurisdiction","court_system":"?string","level":"?string","name":"string","type_description":"A legal jurisdiction, such as a country, state, or regulatory body forum.","domain":"Legal","icon":"GlobeIcon","primary_color":"#6D4C41","index":["name","forum"],"sali_locale":"?string","parent":"Thing","shape":"sphere","country":"?string","sali_court_code":"?string","description_key":"forum","parent_ref_id":"?string","appeal_path_ref_id":"?string","title_key":"name","ref_id":"f08db6ef-c471-400f-b418-41a5b7a7ad2e","circuit":"?string","jurisdiction_kind":"?string"},{"icon":"BookIcon","index":"name","domain":"KnowledgeArtifact","type_description":"An abstract parent for AI-generated and user-facing knowledge nodes.","ref_id":"715266b4-4e95-47c1-800c-5591608fdd69","parent":"Thing","type":"KnowledgeArtifact"},{"icon":"NodesIcon","index":"name","description_key":"name","title_key":"name","name":"string","domain":"CodeArtifact","type_description":"A programming language used in the repository.","ref_id":"aef66983-e555-4fbd-8d18-bf1675656503","parent":"CodeArtifact","node_key":"language-name","shape":"sphere","type":"Language"},{"icon":"BookIcon","index":["name","description"],"parent":"CodeArtifact","node_key":"learning-name","shape":"sphere","type":"Learning","description_key":"description","title_key":"name","description":"?string","name":"string","domain":"CodeArtifact","type_description":"A learning resource or educational content item associated with a codebase.","ref_id":"2942e22b-8c6c-4189-bfc6-a2f769a69f51"},{"icon":"HomeIcon","lessee":"?string","primary_color":"#6D4C41","index":["name","lessor","lessee","property_address","lease_term"],"lessor":"?string","parent":"Agreement","node_key":"leaseagreement-id","property_address":"?string","secondary_color":"#D7CCC8","shape":"sphere","type":"LeaseAgreement","security_deposit":"?float","id":"string","description_key":"property_address","title_key":"name","monthly_rent":"?float","name":"?string","domain":"Legal","type_description":"A lease agreement specifying terms between a lessor and lessee for a property.","ref_id":"151c0569-88b7-4780-a822-e67832506dd4","lease_term":"?string"},{"icon":"ScalesIcon","primary_color":"#6D4C41","index":["name"],"parent":"Thing","secondary_color":"#D7CCC8","shape":"sphere","type":"Legal","description_key":"description","title_key":"name","description":"?string","name":"?string","domain":"Legal","type_description":"Domain anchor for all Legal entity types in the knowledge graph.","ref_id":"a08a10c6-d169-4924-8565-28533ffa9985"},{"node_key":"legalargument-matter_slug-argument_id","secondary_color":"#D7CCC8","resolution":"?string","type":"LegalArgument","source_document":"?string","priority":"?string","type_description":"A legal argument, statutory position, citation, or evidentiary issue identified in a litigation document.","domain":"Legal","raised_by":"?string","argument_id":"string","icon":"ScaleIcon","primary_color":"#6D4C41","statute_or_case":"?string","index":["matter_slug","argument_kind","statute_or_case"],"parent":"Legal","weakness_flag":"?boolean","severity":"?string","shape":"sphere","assertion":"string","description_key":"argument_kind","title_key":"assertion","ref_id":"081b2c0d-4f76-44f4-ba9b-51b2b8ab336a","notes":"?string","argument_kind":"string","matter_slug":"string"},{"icon":"LockClosedIcon","primary_color":"#6D4C41","next_refresh":"?string","index":["matter_slug","status","issued_date"],"last_refresh":"?datetime","scope":"?string","status":"?string","parent":"Thing","node_key":"legalhold-matter_slug","secondary_color":"#D7CCC8","shape":"sphere","type":"LegalHold","issued_by":"?string","released":"?boolean","description_key":"scope","title_key":"matter_slug","issued_date":"?string","release_date":"?datetime","domain":"Legal","type_description":"A legal hold (litigation hold) preserving documents and data relevant to anticipated or ongoing litigation.","ref_id":"af04c6fd-58fd-416c-815c-c66fa7de8eb4","matter_slug":"string"},{"player_role":"string","node_key":"legalparty-matter_slug-entity_id-player_role","secondary_color":"#D7CCC8","type":"LegalParty","entity_id":"string","end_date":"?datetime","side":"?string","type_description":"A matter-scoped player (person or organization) with a specific role in a legal matter. Implements the SALI LMSS Player model with player_role and representation_role separation.","domain":"Legal","firm_name":"?string","icon":"UserCircleIcon","representation_role":"?string","primary_color":"#6D4C41","index":["player_role","representation_role","matter_slug"],"entity_kind":"string","parent":"Legal","shape":"sphere","entity_name":"string","description_key":"player_role","bar_number":"?string","title_key":"entity_name","ref_id":"ad1cd3b9-6230-42bb-9dc2-2b38cbac7022","is_primary":"?boolean","notes":"?string","start_date":"?datetime","matter_slug":"string"},{"body":"?string","index":["name","body"],"parent":"CodeArtifact","node_key":"library-name-file","type_descrioption":"A reusable collection of code or modules providing functionality that can be imported and used in other projects.","type":"Library","description_key":"body","title_key":"name","start":"?int","file":"string","name":"string","domain":"CodeArtifact","ref_id":"5714a98a-d44b-4b09-b3c4-841963535f10","end":"?int"},{"icon":"ClockIcon","primary_color":"#6D4C41","index":["jurisdiction","matter_type"],"parent":"Legal","node_key":"limitationstatute-jurisdiction-matter_type","limitation_years":"float","secondary_color":"#D7CCC8","shape":"sphere","jurisdiction":"string","type":"LimitationStatute","description_key":"jurisdiction","title_key":"matter_type","matter_type":"string","domain":"Legal","type_description":"A statute of limitations applicable to a given matter type within a jurisdiction.","ref_id":"8f8e1d3d-ddbe-484a-9998-0bdf4a4e5b6c","notes":"?string","discovery_rule":"boolean"},{"icon":"BookTextIcon","primary_color":"#1D3140","index":["name","definition"],"definition":"?string","parent":"Thing","node_key":"lingo-name","secondary_color":"#4FA7D9","shape":"sphere","type":"Lingo","lingo_type":"?string","description_key":"definition","title_key":"name","name":"string","domain":"Entity","type_description":"A domain-specific term or vocabulary entry","ref_id":"425b3de0-fb58-4144-a334-32e6c86497bb"},{"icon":"PlaceIcon","primary_color":"#2A2545","index":["name","description"],"parent":"Thing","node_key":"location-name","secondary_color":"#9368FB","shape":"sphere","type":"Location","description_key":"description","title_key":"name","description":"?string","name":"string","domain":"Entity","type_description":"A physical or virtual location","ref_id":"7420f347-3b7a-4b06-b024-a0a5630eeb75"},{"next_action":"?string","closed_date":"?datetime","limitation_deadline":"?datetime","risk_likelihood":"?string","node_key":"matter-slug","secondary_color":"#D7CCC8","jurisdiction":"?string","type":"Matter","instruction_ledger":"?string","damages_calculation":"?string","stage":"?string","initial_posture":"?string","termination_kind":"?string","our_role":"?string","file_no":"?string","name":"?string","next_action_date":"?datetime","type_description":"A legal matter or case, including litigation, regulatory investigation, or transactional matter.","domain":"Legal","outside_counsel":"?string","last_touched":"?datetime","conflict_check_done":"?boolean","client_name":"?string","opened_date":"?datetime","icon":"BriefcaseIcon","statutory_exceptions":"?string","primary_color":"#6D4C41","index":["slug","name","matter_type","status"],"status":"?string","parent":"Thing","shape":"sphere","outcome":"?string","exposure_range":"?string","description_key":"matter_type","title_key":"name","matter_type":"?string","confidentiality":"?string","risk_severity":"?string","ref_id":"c327a21e-6b39-4139-a361-cb3ead2eb56c","slug":"string","materiality":"?string","final_cost":"?string"},{"icon":"GeneratedStepIcon","primary_color":"#38243C","index":"content","parent":"Workflow","memory_id":"string","node_key":"memory-memory_id","secondary_color":"#F468D4","shape":"sphere","type":"Memory","content":"string","description_key":"content","title_key":"memory_id","domain":"Workflow","type_description":"A memory unit for storing information and context learned by an Agent.","ref_id":"35e2971d-468d-44b7-8c0d-085e5c3239ca"},{"media_type":"?string","chat_pubkey":"?string","node_key":"message-uuid","secondary_color":"#54AC52","media_token":"?string","date":"int","type":"Message","kind":"?int","amount":"?int","sender":"string","media_key":"?string","type_description":" A communication conveyed through text, speech, or signals","domain":"Content","reply":"?string","thread_uuid":"?string","icon":"MessageIcon","primary_color":"#22362A","index":"content","parent":"Content","shape":"sphere","content":"string","description_key":"content","title_key":"content","ref_id":"7df3f6c6-afc0-4e30-97f7-494fcab47632","uuid":"string"},{"body":"?string","index":["name","body"],"parent":"CodeArtifact","node_key":"mock-name","mocked":"boolean","type":"Mock","description_key":"body","title_key":"name","start":"?int","file":"?string","name":"string","domain":"CodeArtifact","type_description":"A mock object or function used for testing purposes, simulating the behavior of real components.","ref_id":"0fefd66a-92c5-4a82-b90b-31c36db85fa2","end":"?int"},{"icon":"DocumentIcon","primary_color":"#2A2545","index":"source_link","parent":"Content","node_key":"multimedia-source_link","secondary_color":"#9368FB","shape":"sphere","type":"Multimedia","description_key":"source_link","title_key":"source_link","domain":"Content","type_description":"A generic type of content","ref_id":"3a74c3eb-73ff-46a1-86e7-d3de66775eed","source_link":"string"},{"firm_size":"?string","node_key":"organization-name","secondary_color":"#C25AF3","type":"Organization","practice_areas":"?string","court_system":"?string","image_url":"?string","description":"?string","name":"string","type_description":"A structured group of people with a collective purpose","domain":"Entity","org_kind":"?string","enabling_statute":"?string","court_level":"?string","icon":"OrganizationIcon","sali_govt_code":"?string","primary_color":"#302342","index":["name","description"],"sali_industry_code":"?string","acronym":"?string","parent":"Thing","primary_jurisdiction":"?string","shape":"sphere","sali_court_code":"?string","description_key":"description","title_key":"name","ref_id":"f9cae1c9-aab6-4d7a-bcdd-4d867e1ebc6f","circuit":"?string"},{"icon":"NodesIcon","body":"?string","index":["name","body"],"parent":"CodeArtifact","node_key":"package-name-file","shape":"sphere","type":"Package","description_key":"body","title_key":"name","start":"?int","file":"string","name":"string","domain":"CodeArtifact","type_description":"A package or module grouping related source files and dependencies within a repository.","ref_id":"b58ce83c-582c-45c2-a3c7-3ee4b61a7a31","end":"?int"},{"body":"?string","index":["name","body"],"parent":"CodeArtifact","node_key":"page-name-file","type":"Page","description_key":"body","title_key":"name","start":"?int","file":"string","name":"string","domain":"CodeArtifact","type_description":"A webpage or route within an application, representing a specific view or section of the system. It can serve as the starting point for a codemap.","ref_id":"45ed4c69-359f-4a24-8edf-3df33c09f9ee","end":"?int"},{"certification_number":"?string","icon":"PersonIcon","primary_color":"#362429","bar_state":"?string","index":["name","description"],"alias":"?string","bar_admitted_date":"?datetime","twitter_handle":"?string","parent":"Thing","node_key":"person-name","secondary_color":"#D25353","shape":"sphere","type":"Person","title":"?string","bar_number":"?string","description_key":"description","title_key":"name","image_url":"?string","expertise_domain":"?string","description":"?string","name":"string","domain":"Entity","type_description":"A human being regarded as an individual","ref_id":"3b7acdb2-3238-4ce9-b595-01f68d2aaee0"},{"icon":"PlaceIcon","primary_color":"#2A2545","index":["name","description"],"parent":"Thing","node_key":"place-name","secondary_color":"#9368FB","shape":"sphere","type":"Place","description_key":"description","title_key":"name","image_url":"?string","description":"?string","name":"string","domain":"Entity","type_description":"A particular position, point, or area in space","ref_id":"392aa4ca-b1d5-4c66-8e36-3f41f602165d"},{"icon":"BookmarkIcon","primary_color":"#6D4C41","index":["scope","clause_type","severity"],"scope":"string","parent":"Legal","node_key":"playbookentry-scope-clause_type","severity":"string","suggested_text":"?string","secondary_color":"#D7CCC8","shape":"sphere","fallback_position":"?string","type":"PlaybookEntry","clause_type":"string","red_line":"boolean","description_key":"firm_position","title_key":"clause_type","firm_position":"string","domain":"Legal","type_description":"A negotiation playbook entry specifying firm positions and fallback positions for a contract clause type.","ref_id":"1829e31d-421d-49fa-8078-6685e113519d","notes":"?string"},{"link":"?string","node_key":"podcast-episode_title-timestamp","secondary_color":"#4FA7D9","sentiment_score":"?float","date":"?datetime","type":"Podcast","timestamp":"string","image_url":"?string","description":"?string","type_description":"A digital audio program made up of episodic recordings","domain":"Content","source_link":"?string","pub_key":"?string","icon":"AudioIcon","primary_color":"#1D3140","text":"?string","index":["episode_title","text","description"],"episode_title":"string","media_url":"string","parent":"Episode","show_title":"?string","shape":"sphere","num_boost":"?int","description_key":"description","title_key":"episode_title","english_translation":"?string","ref_id":"3ed41af8-6d8e-4d0b-a799-8e74712e6f8a","boost":"?int","language":"?string"},{"icon":"DocumentTextIcon","primary_color":"#6D4C41","index":["name"],"parent":"Thing","node_key":"policy-name","secondary_color":"#D7CCC8","shape":"sphere","type":"Policy","description_key":"description","title_key":"name","description":"?string","name":"string","domain":"Legal","type_description":"An internal or external policy document governing organizational conduct.","ref_id":"87ab2b1b-db8c-4083-86d9-c1a4b70c53c2"},{"icon":"DatabaseIcon","primary_color":"#6D4C41","index":["name","legal_basis","regime"],"parent":"Thing","node_key":"processingactivity-name","secondary_color":"#D7CCC8","shape":"sphere","purpose":"?string","type":"ProcessingActivity","description_key":"purpose","title_key":"name","legal_basis":"?string","name":"string","domain":"Legal","type_description":"A data processing activity subject to privacy regulations such as GDPR or CCPA.","ref_id":"6b70e0dd-d633-49df-9bd5-5563a021bd8d","triage_result":"?string","regime":"?string"},{"primary_color":"#353124","index":"name","description_key":"name","title_key":"name","name":"string","domain":"Entity","type_description":"A product or item","ref_id":"9fe7badd-c23a-42ca-9638-a22c47d1e15a","parent":"Thing","node_key":"product-name","secondary_color":"#BA9D39","type":"Product"},{"icon":"NodesIcon","body":"?string","index":["name","description","body"],"parent":"Workflow","node_key":"prompt-id","shape":"sphere","type":"Prompt","id":"string","description_key":"description","title_key":"name","description":"?string","name":"?string","domain":"Workflow","type_description":"A prompt template used within workflow definitions.","ref_id":"915b2c28-4905-4dc6-a9cd-9ed43cecbdfa","customer_id":"?int"},{"summary":"?string","fix_type":"string","node_key":"proposedfix-fix_id","secondary_color":"#E09242","before_score":"?string","type":"ProposedFix","criterion_title":"?string","stakwork_run_id":"?string","new_prompt_version_id":"?string","fix_id":"string","prompt_name":"?string","rerun_run_id":"?string","prompt_id":"?string","resolved_by":"?string","type_description":"A fix-scoped, fix-type-polymorphic node representing any eval-driven improvement. One node per fix, identified by a caller-generated, globally-unique fix_id (NOT one node per criterion). fix_type is an open string; conventional values: prompt | workflow | architecture | schema | other — new fix kinds require no migration. Criterion and prompt fields are optional; populate them only when fix_type=prompt. rerun_run_id references the upstream evaluation run id (e.g. EvalTriggerOutput.id) that produced this fix proposal — it is NOT an EvalRun.id reference (EvalRun is deprecated from the canonical model as of migration 084). Anchored to the unified eval chain: EvalSet → EvalRequirement → EvalTrigger → EvalTriggerOutput → CriterionResult → ProposedFix. Duplicate fix_id values are silently merged (not rejected) by the node_key migration helper; callers must ensure global uniqueness. OUTCOME FIELD — eval_status (canonical): the accept/reject outcome of this fix. Lifecycle: pending → accepted | rejected. Exact allowed set only — downstream readers and writers MUST validate against this fixed set before acting on it; a malformed or unexpected value would silently mis-branch accept/reject logic in the recursion loop (accepted = trunk node, rejected = dead-end leaf). LEGACY FIELD — status: retained for compatibility; no longer the outcome signal. Do not use status for accept/reject branching in new code; use eval_status instead. FIX LINEAGE — DERIVED_FROM edge (child → parent, append-only, git-commit-parent style): accepted fixes form a linear trunk (latest accepted = the tip, i.e. the accepted node with no accepted child pointing to it); a rejected fix is a dead-end leaf pointing at the accepted parent it was tried against. Only the accepted trunk is linearly ordered by topology; rejected siblings off one accepted parent are an unordered set by design — no timestamp or sequence field is added. PARENT LOOKUP — parent_fix_id: a denormalized, edge-free mirror of the DERIVED_FROM parent (analogous to rerun_run_id). The DERIVED_FROM edge is the source of truth; keeping parent_fix_id consistent with the edge and rejecting cycles is the responsibility of the sibling writer feature, not enforced by this ontology. TRAVERSAL SAFETY CONTRACT: no acyclicity or depth guarantee exists at the ontology layer. Downstream chain-walkers (recursion cron, eval-runner) MUST bound traversal depth and reject cycles — e.g. validate parent_fix_id lineage before writing a new DERIVED_FROM edge — otherwise a cycle written later could loop a walker unbounded. TARGET SNAPSHOT (generic before/after for any fix kind) — five optional fields added by migration 105: target_type, target_name, target_version, old_value, new_value. old_value/new_value each hold ONE JSON object serialized to a JSON string via json.dumps — never a raw object (both are typed ?string, so validate_by_schema rejects non-string values). Conventional envelope shapes by target_type: prompt -> {\"text\": ...}; concept -> {\"name\": ..., \"documentation\": ...} — readers MUST accept either \"documentation\" or \"docs\" as the concept body key (Concept's body field may be renamed from documentation to docs in a separate in-flight change; this convention is forward-compatible with either); workflow -> the workflow's JSON definition. These shapes are a documented convention only, NOT runtime-validated at the schema layer — readers must tolerate a missing or unparseable envelope gracefully (render the raw text and flag it, never throw). DISCRIMINATOR PRECEDENCE: target_type is authoritative for interpreting the snapshot envelope; fix_type remains the existing fix-kind label. The two are not schema-enforced to agree. When target_type is absent (true for every pre-existing ProposedFix node — no backfill is performed), readers fall back to fix_type for labelling and render an empty-snapshot state. RELATION TO EXISTING BEFORE/AFTER FIELDS (none deprecated by 105): failing_value/passing_value/delta are criterion-level observed values; before_score/after_score/score_delta are numeric eval scores; old_value/new_value are the full target-artifact snapshot — a diff renderer should read only old_value/new_value. DENORMALIZED IDENTITY: target_ref remains the source of truth for target identity; target_name/target_version are edge-free display mirrors (same spirit as parent_fix_id) that may go stale — keeping them consistent is the writer's responsibility, not enforced here. For fix_type=prompt, prompt_name/prompt_version_id/new_prompt_version_id remain canonical; target_name/target_version merely mirror them. WRITE PATH: attach a snapshot to an existing ProposedFix via POST /v2/nodes with reprocess=true (in-place update, preserves DERIVED_FROM lineage); never force_delete (DETACH DELETE destroys lineage) and never allow_scratchpad=true (a rejected write would be durably parked as a ScratchpadEntry under a different access-control model — undesirable for large snapshot payloads). SECRET HYGIENE: workflow-target JSON snapshots can embed credentials/secrets; old_value/new_value are deliberately publicly readable (no paid_properties gating), so producers MUST redact credentials/secret values before writing a workflow-targeted snapshot.","domain":"CodeArtifact","target_version":"?string","criterion_id":"?string","parent_fix_id":"?string","after_score":"?string","icon":"PencilAltIcon","rerun_status":"?string","primary_color":"#392828","index":["fix_id","fix_type","summary","status","task_slug"],"new_value":"?string","target_name":"?string","status":"?string","delta":"?string","parent":"Thing","shape":"sphere","target_ref":"?string","resolved_at":"?string","eval_status":"?string","task_slug":"?string","passing_value":"?string","reasoning":"string","prompt_version_id":"?string","title_key":"summary","old_value":"?string","rubric_criterion_ref":"?string","score_delta":"?string","ref_id":"9f787d04-0aa0-4131-b284-548245517792","target_type":"?string","failing_value":"?string"},{"icon":"NodesIcon","index":["name","description"],"state":"?string","parent":"Repository","node_key":"pullrequest-name","number":"?int","shape":"sphere","type":"PullRequest","namespace":"?string","description_key":"description","title_key":"name","description":"?string","name":"string","domain":"CodeArtifact","type_description":"A pull request created by a developer to merge changes into the main branch.","ref_id":"e9eed9da-2487-4ab3-beca-702c04e6c4a6","created_at":"?string","source_link":"?string","user":"?string"},{"icon":"BookOpenIcon","primary_color":"#6D4C41","index":["name","agency","regime"],"status":"?string","parent":"Thing","agency":"?string","status_verified":"?boolean","node_key":"regulation-name","secondary_color":"#D7CCC8","shape":"sphere","type":"Regulation","relevance_hook":"?string","description_key":"regime","title_key":"name","name":"string","domain":"Legal","type_description":"A law, regulation, or regulatory framework applicable to the organization (e.g. GDPR, CCPA, SOX).","ref_id":"a5e50fb4-1b08-4679-bd56-6927d469c39e","effective_date":"?string","regime":"?string","item_type":"?string"},{"link":"?string","node_key":"regulatoryitem-id","secondary_color":"#D7CCC8","type":"RegulatoryItem","decision":"?string","id":"string","title":"?string","type_description":"A discrete regulatory requirement, rule, or guidance item issued under a Regulation.","domain":"Legal","filed_at":"?datetime","detected":"?datetime","item_type":"?string","icon":"ClipboardListIcon","citation":"?string","primary_color":"#6D4C41","index":["id","item_type","agency","materiality_tier"],"materiality_tier":"?string","parent":"Thing","agency":"?string","shape":"sphere","description_key":"item_type","title_key":"title","ref_id":"c80b1515-af2d-41ea-8dba-d19bebf857ba","effective_date":"?datetime","owner_slack":"?string","comment_deadline":"?string"},{"business_owner":"?string","icon":"RefreshIcon","primary_color":"#6D4C41","renewal_mechanism":"?string","index":["counterparty","signed_date","status"],"cancel_by":"?datetime","signed_date":"string","status":"string","notice_period_days":"?int","initial_term_end":"?datetime","parent":"Legal","node_key":"renewalentry-counterparty-signed_date","secondary_color":"#D7CCC8","shape":"sphere","price_on_renewal":"?string","type":"RenewalEntry","counterparty":"string","description_key":"status","title_key":"counterparty","domain":"Legal","type_description":"A contract renewal record tracking key renewal dates, terms, and owners.","ref_id":"dda88769-ae75-41df-9cce-accd9d5cb4de","annual_value":"?string","clm_id":"?string"},{"icon":"HomeIcon","body":"?string","index":["name","body"],"hash":"?string","parent":"Thing","node_key":"repository-name-file-start","shape":"sphere","type":"Repository","description_key":"body","title_key":"name","start":"int","file":"string","name":"string","domain":"CodeArtifact","type_description":"A code repository that contains source files, directories, and version history.","ref_id":"35d0b681-fa57-4289-bc73-cceed785fcae","source_link":"?string","end":"?int"},{"summary":"?string","body":"?string","node_key":"request-name-file-verb","type":"Request","namespace":"?string","verb":"string","description":"?string","file":"string","name":"string","type_description":"An HTTP request representation, defining interactions with an API or web service.","domain":"CodeArtifact","source_link":"?string","end":"?int","icon":"ConstructionIcon","text":"?string","index":["name","summary","description"],"hash":"?string","parent":"CodeArtifact","shape":"sphere","description_key":"summary","title_key":"name","start":"?int","ref_id":"c71ceadd-e1d6-43b4-a23e-167e3366d6b4","method":"?string","handler":"?string"},{"icon":"RunIcon","primary_color":"#36292D","index":["project_id","workflow_state"],"parent":"Workflow","node_key":"run-project_id","secondary_color":"#A96755","shape":"sphere","type":"Run","evaluation":"?boolean","description_key":"workflow_state","title_key":"project_id","workflow_state":"?string","project_id":"int","domain":"Workflow","type_description":"An execution instance of a workflow or process","ref_id":"0127e76b-39f5-4e7f-b12c-4b29ab2eddbe"},{"icon":"StepIcon","primary_color":"#362429","index":["description","step_unique_id"],"output_json":"?string","parent":"Workflow","node_key":"run_step-step_unique_id","secondary_color":"#D25353","shape":"sphere","input_json":"?string","type":"Run_step","step_unique_id":"string","id":"string","description_key":"description","title_key":"step_unique_id","description":"?string","domain":"Workflow","type_description":"An individual step within a workflow run execution","ref_id":"6a640450-9511-45e7-8d42-95c7b8eae72f"},{"icon":"NodesIcon","index":["name","description"],"parent":"KnowledgeArtifact","node_key":"scope-name","shape":"sphere","type":"Scope","description_key":"description","title_key":"name","description":"?string","name":"string","domain":"KnowledgeArtifact","type_description":"A scope label that groups related learnings, such as a technology, pattern, or area of the codebase.","ref_id":"806755c4-2080-419e-9cd5-bd904bc8e3bb"},{"icon":"PencilAltIcon","index":["intended_type","name"],"entry_hash":"string","intended_type":"string","parent":"Thing","node_key":"scratchpadentry-intended_type-entry_hash","shape":"sphere","rejection_reason":"?string","type":"ScratchpadEntry","payload_json":"?string","title_key":"name","name":"?string","rejection_detail":"?string","domain":"Scratchpad","type_description":"A write that did not match the strict ontology, preserved verbatim instead of being rejected. intended_type records the node type the caller tried to create; payload_json holds the original node_data as a JSON string (the ontology validators reject undeclared attributes, so the payload cannot be spread into real properties). entry_hash is a sha256 of that payload, so a writer retrying the same rejected request repeatedly produces one node, not one per try. The Scratchpad domain is system-hidden: these nodes are excluded from search and are never offered to the extraction agent as a target type. INVARIANT: a ScratchpadEntry may be the SOURCE of an edge (so an entry keeps context by pointing at canonical nodes) but never the TARGET, so nothing traversing the canonical graph can walk into ungoverned data. Promotion of entries into real node types is deliberately out of scope; this tier is a staging area and a record of which node types the ontology is missing.","ref_id":"9b433ec6-e502-4cab-aabe-6b7ae0eadee3"},{"icon":"TaskIcon","primary_color":"#1B3134","body":"?string","index":["name","description","body"],"parent":"Workflow","node_key":"script-id","secondary_color":"#21B38A","shape":"sphere","type":"Script","id":"int","description_key":"description","title_key":"name","description":"?string","name":"?string","domain":"Workflow","type_description":"A script or code block used within workflow execution","ref_id":"7d605ac2-d97f-4c44-85b1-d1fbb3910c28","customer_id":"?int"},{"icon":"TaskIcon","primary_color":"#1B3134","usage_count":"?int","index":["name","description"],"parent":"Thing","node_key":"secret-id","secondary_color":"#21B38A","shape":"sphere","type":"Secret","id":"int","description_key":"description","title_key":"name","description":"string","name":"string","domain":"Workflow","type_description":"A named credential or sensitive value optionally scoped to a skill and customer.","usage_count_30d":"?int","ref_id":"9dfc3e87-4d02-454f-8c41-35d9cfb549d0","skill_id":"?int","customer_id":"?int"},{"summary":"?string","icon":"DocumentIcon","primary_color":"#2A2545","index":["text","source_link"],"text":"string","parent":"Document","node_key":"section-text","secondary_color":"#9368FB","shape":"sphere","type":"Section","description_key":"text","title_key":"text","domain":"Content","type_description":"A distinct part or subdivision of a document","ref_id":"7099575b-ee14-4e40-82e9-31668c8933c8","source_link":"?string"},{"icon":"VideoIcon","primary_color":"#38243C","index":"show_title","parent":"Content","node_key":"show-show_title","show_title":"string","secondary_color":"#F468D4","shape":"sphere","type":"Show","description_key":"show_title","title_key":"show_title","image_url":"?string","domain":"Content","type_description":" A podcast is a digital medium consisting of audio (or video) episodes that relate to a specific theme","ref_id":"6e6cd66e-9c96-412a-a0db-6330a55e98f1"},{"icon":"TaskIcon","primary_color":"#1D3140","usage_count":"?int","index":["name","description","input_schema","output_schema"],"graph_description":"?string","input_schema":"?string","output_schema":"?string","parent":"Workflow","node_key":"skill-name","secondary_color":"#4FA7D9","shape":"sphere","type":"Skill","id":"?int","description_key":"description","title_key":"name","description":"?string","name":"string","domain":"Workflow","type_description":"A reusable capability or function that can be composed into workflows","usage_count_30d":"?int","ref_id":"2af571af-d21a-412d-8177-3ce792e46317","skill_id":"?int","mockable":"?boolean"},{"icon":"StrategyIcon","primary_color":"#353124","index":["name","steps_list"],"LLM_response_id":"?int","parent":"Workflow","node_key":"strategy-strategy_id","secondary_color":"#BA9D39","shape":"sphere","type":"Strategy","description_key":"steps_list","title_key":"name","steps_list":"?string","name":"?string","domain":"Workflow","type_description":"A plan or approach for accomplishing a specific goal or set of objectives","ref_id":"b5c26d54-ad93-4685-aa22-dbc59d281bc2","strategy_id":"int","success":"?boolean"},{"icon":"TableIcon","primary_color":"#6D4C41","index":["column_name","state","column_type"],"column_type":"string","location":"?string","state":"string","parent":"Legal","node_key":"tabularreviewcell-column_name-column_type-state","secondary_color":"#D7CCC8","shape":"sphere","type":"TabularReviewCell","column_name":"string","description_key":"value","title_key":"column_name","quote":"?string","domain":"Legal","value":"?string","type_description":"A single cell in a tabular contract or document review, capturing a named column value for a specific document.","ref_id":"1459218f-3d70-4b21-8533-0d0cee7c1266"},{"taskId":"?string","icon":"TaskIcon","primary_color":"#2A3229","index":"task","status":"?string","task":"?string","task_id":"int","parent":"Workflow","node_key":"task-task_id","secondary_color":"#96BD3F","shape":"sphere","type":"Task","description_key":"task","title_key":"task","project_id":"?int","domain":"Workflow","type_description":"A specific work item or unit of work that needs to be completed","ref_id":"ff186ad2-b21e-427f-850e-8a6b346575c2","success":"?boolean"},{"icon":"ConstructionIcon","index":"name","domain":"CodeArtifact","type_description":"An abstract parent for all test types (unit, integration, e2e).","ref_id":"058238a1-f4bb-430f-a682-1ce9d8911b95","parent":"CodeArtifact","type":"Test"},{"unique_source_id":"?string","icon":"NodesIcon","primary_color":"#36292D","index":["name","description"],"weight":"?float","node_key":"thing-name","secondary_color":"#A96755","shape":"sphere","type":"Thing","description_key":"description","title_key":"name","image_url":"?string","description":"?string","name":"string","is_muted":"?boolean","type_description":"The highest-level node in the ontology hierarchy, representing an abstract concept with no direct individual instances","ref_id":"2f8b346c-74e3-43c2-9700-ce22c397c2ce"},{"icon":"CalendarIcon","primary_color":"#6D4C41","index":["matter_slug","entry_date","entry_type"],"parent":"Legal","node_key":"timelineentry-matter_slug-entry_date","entry_type":"string","secondary_color":"#D7CCC8","shape":"sphere","type":"TimelineEntry","author":"string","description_key":"entry_type","title_key":"description","source":"string","email_thread_id":"?string","description":"string","domain":"Legal","type_description":"A dated event or milestone on a legal matter timeline.","ref_id":"d52c3dfe-14c0-46c8-b31e-93a9aad85617","entry_date":"datetime","matter_slug":"string"},{"icon":"BookIcon","primary_color":"#1D3140","index":["name","description"],"parent":"Thing","node_key":"topic-name","secondary_color":"#4FA7D9","shape":"sphere","type":"Topic","description_key":"description","title_key":"name","image_url":"?string","description":"?string","name":"string","is_muted":"?boolean","domain":"Content","relevancy_score":"?float","type_description":"A subject or theme of discussion, study, or interest","ref_id":"83819187-c84f-492d-a112-f64e8ea62adc"},{"body":"?string","index":["name","body"],"parent":"CodeArtifact","node_key":"trait-name-file","type":"Trait","description_key":"body","title_key":"name","start":"?int","file":"string","name":"string","domain":"CodeArtifact","type_description":"A reusable set of properties or behaviors in source code, often used in object-oriented or functional programming.","ref_id":"594ae6bb-c5ac-42d4-ab8f-e4b31a5eafb8","end":"?int"},{"icon":"StepIcon","primary_color":"#2A2545","index":["content","tool"],"tool":"?string","parent":"Workflow","node_key":"turn-turn_id","secondary_color":"#9368FB","shape":"sphere","type":"Turn","turn_id":"string","outcome":"?string","content":"?string","turn_type":"?string","description_key":"content","title_key":"tool","order":"?int","domain":"Workflow","type_description":"A single turn in an agent session — a tool call, thought, response, user input, or error.","ref_id":"6888e270-a3fd-4aa0-b3cf-c447b747b84f","tokens":"?int"},{"quote_count":"?string","tweet_id":"string","twitter_handle":"?string","node_key":"tweet-tweet_id","secondary_color":"#D25353","date":"?datetime","type":"Tweet","followers":"?string","impression_count":"?string","image_url":"?string","description":"?string","verified":"?boolean","name":"?string","type_description":"A short message posted on a social media platform","domain":"Content","source_link":"?string","icon":"TwitterIcon","primary_color":"#362429","text":"?string","index":["text","twitter_handle"],"reply_count":"?string","status":"?string","media_url":"?string","parent":"Content","shape":"sphere","pubkey":"?string","description_key":"text","title_key":"twitter_handle","like_count":"?string","project_id":"?string","ref_id":"d7103144-1c0a-4ec9-91ee-713a84335514","retweet_count":"?string","bookmark_count":"?string"},{"icon":"TwitterIcon","primary_color":"#362429","index":["twitter_handle","name"],"verified_type":"?string","twitter_handle":"string","parent":"Thing","node_key":"twitteraccount-twitter_handle","secondary_color":"#D25353","shape":"sphere","type":"TwitterAccount","is_identity_verified":"?boolean","description_key":"name","title_key":"twitter_handle","image_url":"?string","verified":"?boolean","name":"?string","domain":"Content","type_description":"A Twitter/X account associated with a person or organization","ref_id":"96f8744c-554e-4f9b-9e10-01965d83e544","author_id":"?string"},{"icon":"NodesIcon","body":"?string","index":["name","body"],"parent":"Test","node_key":"unittest-name-file","shape":"sphere","type":"UnitTest","description_key":"body","title_key":"name","start":"?int","file":"string","name":"string","domain":"CodeArtifact","type_description":"A unit test verifying a single function or module in isolation.","ref_id":"1af06b0b-d6f1-4944-b330-89d6524f1842","end":"?int"},{"icon":"TargetIcon","index":["name","description"],"parent":"KnowledgeArtifact","node_key":"userobjective-name","shape":"sphere","type":"UserObjective","description_key":"description","title_key":"name","description":"?string","name":"string","is_muted":"?boolean","domain":"KnowledgeArtifact","type_description":"A user-defined objective or goal to track","ref_id":"2c22ea3f-a917-4116-9e01-08453ad691fd"},{"body":"?string","index":["name","body"],"parent":"CodeArtifact","node_key":"var-name-file","type":"Var","description_key":"body","title_key":"name","start":"?int","file":"string","name":"string","domain":"CodeArtifact","type_description":"A variable declaration or usage in source code, representing data storage and manipulation within a program.","ref_id":"a7f02c51-eb83-41e2-8c2c-cf8289ed8471","end":"?int"},{"link":"?string","node_key":"video-episode_title-timestamp","secondary_color":"#21B38A","sentiment_score":"?float","date":"?datetime","type":"Video","timestamp":"string","image_url":"?string","description":"?string","type_description":"A recording of moving visual images","domain":"Content","source_link":"?string","pub_key":"?string","icon":"VideoIcon","primary_color":"#1B3134","text":"?string","index":["episode_title","text","description"],"episode_title":"string","media_url":"string","parent":"Episode","show_title":"?string","shape":"sphere","num_boost":"?int","description_key":"description","title_key":"episode_title","english_translation":"?string","ref_id":"36dcd4bc-25c5-4c71-9dc8-243ca3dc7185","boost":"?int","language":"?string"},{"body":"?string","workflow_version":"?int","node_key":"workflow-workflow_id","secondary_color":"#54AC52","type":"Workflow","description":"?string","name":"?string","workflow_id":"?int","type_description":"An abstract parent for all workflow automation execution types.","domain":"Workflow","usage_count_30d":"?int","published_workflow_version_id":"?int","icon":"TaskIcon","usage_count":"?int","primary_color":"#22362A","index":["name","description","body","input_schema","output_schema"],"input_schema":"?string","output_schema":"?string","parent":"Thing","workflow_json":"?string","shape":"sphere","description_key":"description","title_key":"name","parent_version_id":"?int","ref_id":"82793c01-c13a-4175-a2fc-86df96dd421b","branch":"?string","customer_id":"?int"},{"body":"?string","node_key":"workflow_version-workflow_id-workflow_version_id","secondary_color":"#E09242","type":"Workflow_version","description":"?string","name":"?string","workflow_id":"int","type_description":"A specific version of a workflow configuration with defined steps and parameters","domain":"Workflow","published":"?boolean","icon":"WorkflowIcon","primary_color":"#392828","index":["name","description","body","input_schema","output_schema"],"workflow_version_id":"int","input_schema":"?string","output_schema":"?string","parent":"Workflow","workflow_json":"?string","shape":"sphere","published_at":"?string","description_key":"description","title_key":"name","ref_id":"cb32dd65-19cb-4e15-8af1-f2d9a9c7d013","branch":"?string","success":"?boolean","customer_id":"?int"}],"edge_schemas":[{"source":"AISystem","edge":"HAS_DEADLINE","target":"Deadline","props":{"ref_id":"b318b6e4-74d9-4c15-9dfd-2408202f7775"}},{"source":"AISystem","edge":"IMPACT_ASSESSED_BY","target":"ProcessingActivity","props":{"ref_id":"f037564a-6d51-4b6e-b9fb-f3653fca9ed0"}},{"source":"AISystem","edge":"SUBJECT_TO","target":"Regulation","props":{"ref_id":"b5e38818-bc2a-4ece-a239-d5ea5040f135"}},{"source":"AISystem","edge":"VENDOR_COVERED_BY","target":"Agreement","props":{"ref_id":"40226154-c95c-4f81-b9ea-d051d78c69cb"}},{"source":"AgentRole","edge":"HAS_SESSION","target":"AgentSession","props":{"ref_id":"bd1f873d-c181-4858-9040-06a6264b6c1f"}},{"source":"AgentSession","edge":"HAS_ABSTRACTION","target":"Abstraction","props":{"ref_id":"7fd9bb48-099d-4352-9cad-4a8a73e7ebee"}},{"source":"AgentSession","edge":"HAS_TURN","target":"Turn","props":{"ref_id":"af64b86c-d9e4-4734-ac19-37043e6bfc23"}},{"source":"AgentSession","edge":"NEXT","target":"AgentSession","props":{"ref_id":"07494bbf-ede3-47d8-baef-3f50f334acbe"}},{"source":"Agreement","edge":"AMENDS","target":"Agreement","props":{"ref_id":"d536e67e-628a-41d2-ba25-32c900d5c041"}},{"source":"Agreement","edge":"CONTAINS_REQUEST","target":"DDRequestItem","props":{"ref_id":"0435e70f-6556-46b1-9e46-a28c3aa45951"}},{"source":"Agreement","edge":"DEFINES","target":"DefinedTerm","props":{"ref_id":"860047a1-7530-4680-908e-198db7ab7b20"}},{"source":"Agreement","edge":"GOVERNED_BY_LAW","target":"Country","props":{"state":"?string","ref_id":"4cda91a4-8674-4fd4-8170-e29ae021ca2e"}},{"source":"Agreement","edge":"GOVERNED_BY_LAW","target":"Location","props":{"state":"?string","ref_id":"1d0b1db2-ec42-4405-b293-7790b6be6061"}},{"source":"Agreement","edge":"GOVERNS","target":"IPAsset","props":{"ref_id":"457b6231-e5c1-4789-a2a2-1cc8df812d33"}},{"source":"Agreement","edge":"HAS_CLAUSE","target":"ContractClause","props":{"ref_id":"3a758239-c1eb-4c5f-8b97-068d3b6aad7a"}},{"source":"Agreement","edge":"HAS_DEADLINE","target":"Deadline","props":{"ref_id":"d09bd618-d963-4b9a-82fa-2c25bfd1330a"}},{"source":"Agreement","edge":"HAS_RENEWAL_ENTRY","target":"RenewalEntry","props":{"ref_id":"cb85295b-75e5-47f7-ae73-a4d0828da653"}},{"source":"Agreement","edge":"REVIEWED_IN","target":"TabularReviewCell","props":{"ref_id":"c5c16f1f-b20c-4a7f-99eb-6f145911c534"}},{"source":"Belief","edge":"REFERS_TO","target":"Thing","props":{"ref_id":"8c013d58-0923-481e-bf61-c03b274d2673"}},{"source":"BenchmarkFailureCause","edge":"AFFECTS","target":"EvalRequirement","props":{"ref_id":"ca68613f-445e-4a71-8a16-81a3b2600925"}},{"source":"Cause","edge":"CAUSE_CHILD_OF","target":"Cause","props":{"ref_id":"812206e1-20c0-4944-9559-caa280e5c96d","cardinality":"many"}},{"source":"Claim","edge":"CONTRADICTS","target":"Claim","props":{"ref_id":"312ccca5-4f85-4c9a-bf89-854a2434c86b"}},{"source":"Claim","edge":"SOURCE","target":"Chapter","props":{"ref_id":"7ae2edf9-16ea-445d-89ee-4fb0e7d620be"}},{"source":"Claim","edge":"SOURCE","target":"Section","props":{"ref_id":"27b3d414-a2cc-4e65-a90c-6715ca7b1c7f"}},{"source":"Claim","edge":"SUPERSEDES","target":"Claim","props":{"ref_id":"cc0bc807-ab11-4c44-8070-0b238945d394"}},{"source":"Claim","edge":"SUPPORTS","target":"Claim","props":{"ref_id":"f7630c20-3407-469c-9b87-8da078b727f3"}},{"source":"Class","edge":"CALLS","target":"Class","props":{"ref_id":"baefaafb-6dbb-40d4-b466-06d15849ba05","call_start":"?int","call_end":"?int","operand":"?string"}},{"source":"Class","edge":"CONTAINS","target":"Datamodel","props":{"ref_id":"ab5c2a3f-6080-407b-b419-60756273da3e"}},{"source":"Class","edge":"IMPORTS","target":"Class","props":{"ref_id":"caa1ac25-761f-4624-93aa-44e7311ef52b"}},{"source":"Class","edge":"OPERAND","target":"Function","props":{"ref_id":"577eb596-8e8c-4fe8-8a68-0d21febe4caa"}},{"source":"Class","edge":"PARENT_OF","target":"Class","props":{"ref_id":"af738a4e-d17e-4ccc-a31b-896329294a71"}},{"source":"Clip","edge":"RELATED_TO","target":"Topic","props":{"ref_id":"7690b980-c747-481e-8909-baa59335cce4"}},{"source":"Commit","edge":"TOUCHES","target":"Concept","props":{"ref_id":"d65d3149-d356-463a-8300-1862cd968466"}},{"source":"Commit","edge":"TOUCHES","target":"Feature","props":{"ref_id":"46891af7-acaf-4f7b-9ec8-81316d641537"}},{"source":"ComputedFigure","edge":"APPLIED_TO","target":"Matter","props":{"ref_id":"589374ec-21cc-420e-a57b-eaf777cdb179"}},{"source":"ComputedFigure","edge":"CONTRADICTS","target":"Deadline","props":{"contradiction_type":"?string","ref_id":"2011f9c2-c4ce-4689-9da7-550b2db80054","contradiction_reason":"?string"}},{"source":"ComputedFigure","edge":"DERIVED_FROM","target":"Excerpt","props":{"ref_id":"d26dfed5-9153-4393-a59d-b9656af79418"}},{"source":"ComputedFigure","edge":"DERIVED_FROM","target":"Figure","props":{"ref_id":"27143877-54b9-48d4-9ab5-27538b7adec7"}},{"source":"ComputedFigure","edge":"GOVERNED_BY","target":"RegulatoryItem","props":{"ref_id":"ffbd3029-4d2a-4005-bbb5-112606ba87ae"}},{"source":"ComputedFigure","edge":"HAS_COMPONENT","target":"FormulaComponent","props":{"ref_id":"b3c7078b-3b1e-4440-b11b-4e877b28bfcc"}},{"source":"ComputedFigure","edge":"QUANTIFIES","target":"DefinedTerm","props":{"field_name":"?string","value_as_stated":"?string","ref_id":"f8b55702-fe5d-446d-b1b0-b263f447f055","notes":"?string"}},{"source":"ComputedFigure","edge":"QUANTIFIES","target":"PlaybookEntry","props":{"field_name":"?string","value_as_stated":"?string","ref_id":"a576229d-5606-4b91-90f6-a81a0efe0425","notes":"?string"}},{"source":"ComputedFigure","edge":"VALIDATES","target":"Figure","props":{"ref_id":"4cc8af22-dbe7-4670-97ae-181d4e51fb67"}},{"source":"ComputedFigure","edge":"VALIDATES","target":"TabularReviewCell","props":{"ref_id":"71330bcd-43a3-4747-941c-b5bdbb564a7f"}},{"source":"Concept","edge":"IN_REPO","target":"Repository","props":{"ref_id":"19a4110e-3406-4e8c-a0bd-fbfee9831d16"}},{"source":"Concept","edge":"MODIFIES","target":"File","props":{"importance":"?float","ref_id":"a2292988-cc5e-47e4-b63f-ed282abec46f"}},{"source":"Concept","edge":"PARENT_OF","target":"Concept","props":{"ref_id":"b60385a5-9c72-490c-b872-dbc9bb9e52af","cardinality":"many"}},{"source":"ContractClause","edge":"CONFLICTS_WITH","target":"Regulation","props":{"ref_id":"ad8a5ae2-333a-4d7f-92a7-f32a994e4a4b"}},{"source":"ContractClause","edge":"CONTRADICTS","target":"ContractClause","props":{"ref_id":"6b9c8694-25e3-4c0d-857b-295dd342fe52"}},{"source":"ContractClause","edge":"DEFINES","target":"DefinedTerm","props":{"ref_id":"6d682cc6-67e4-4463-bca1-3ab77fc15e35"}},{"source":"ContractClause","edge":"DEVIATES_FROM","target":"PlaybookEntry","props":{"ref_id":"63611640-a710-446d-a283-d72b13bddfe7"}},{"source":"ContractClause","edge":"HAS_EXCERPT","target":"Excerpt","props":{"ref_id":"ae9683da-2d0f-4e59-9970-541ec1f9d0d5"}},{"source":"ContractClause","edge":"HAS_GAP","target":"GapItem","props":{"ref_id":"fe9aa8a7-2883-4f0e-89ae-18eb5e7b1b05"}},{"source":"ContractClause","edge":"HAS_TYPE","target":"ClauseType","props":{"ref_id":"b9b41256-61e7-426e-808c-e0c2115c88c0"}},{"source":"ContractClause","edge":"SUPERSEDES","target":"ContractClause","props":{"ref_id":"6214d924-5d78-4b5e-9ba2-2f6e509a6e8e"}},{"source":"Country","edge":"HAS_CAPITAL","target":"Location","props":{"volatility":"STABLE","confidence_score":"?float","fluent":true,"invalid_at":"?datetime","temporal":true,"valid_at":"?datetime","ref_id":"0c360bc7-84f7-4424-9bb6-54d98ef319d5","last_confirmed_at":"?datetime","invalidated_by":"?string","expired_at":"?datetime","cardinality":"single"}},{"source":"CriterionResult","edge":"HAS","target":"Cause","props":{"ref_id":"27f8b236-2531-42cb-95c5-55f0705d8024","cardinality":"many"}},{"source":"CriterionResult","edge":"HAS_PROPOSED_FIX","target":"ProposedFix","props":{"volatility":"EVOLVING","ref_id":"d6182eaa-3ee5-44f7-9160-8207fb42dd37"}},{"source":"DSARRequest","edge":"SUBMITTED_BY","target":"Person","props":{"ref_id":"87e967c7-54e9-4fe7-82e0-10bdd410a455"}},{"source":"DSARRequest","edge":"SUBMITTED_TO","target":"Organization","props":{"ref_id":"a740561a-7122-4796-975f-32fad3a8a3b2"}},{"source":"DataAnomalyRecord","edge":"FLAGS","target":"Concept","props":{"anomaly_reason":"?string","ref_id":"73452aa1-96cf-4653-bea8-359f3e5dddfb","severity":"?string","flag_category":"?string"}},{"source":"DataAnomalyRecord","edge":"FLAGS","target":"ContractClause","props":{"anomaly_reason":"?string","ref_id":"45efaa21-0c7c-4c6d-a163-7b6cb54129fb","severity":"?string","flag_category":"?string"}},{"source":"DataAnomalyRecord","edge":"FLAGS","target":"Document","props":{"anomaly_reason":"?string","ref_id":"809a186d-a806-4a91-8060-29766ce59722","severity":"?string","flag_category":"?string"}},{"source":"DataAnomalyRecord","edge":"FLAGS","target":"Excerpt","props":{"anomaly_reason":"?string","ref_id":"459b9900-f0f7-4f6f-a586-bb95bf65e760","severity":"?string","flag_category":"?string"}},{"source":"DataAnomalyRecord","edge":"FLAGS","target":"Figure","props":{"anomaly_reason":"?string","ref_id":"5399008e-f0a6-46be-844e-d048f22581c7","severity":"?string","flag_category":"?string"}},{"source":"DataAnomalyRecord","edge":"FLAGS","target":"TimelineEntry","props":{"anomaly_reason":"?string","ref_id":"2081f65a-d836-464c-8df6-e656e4e485e9","severity":"?string","flag_category":"?string"}},{"source":"Deadline","edge":"COINCIDES_WITH","target":"Deadline","props":{"ref_id":"a6f07276-f629-4249-a4b4-2dff4d98d649","coincidence_date":"?string","note":"?string"}},{"source":"Deadline","edge":"CONTRADICTS","target":"Deadline","props":{"contradiction_type":"?string","ref_id":"35577892-8b0f-4c2f-9544-dd677f1d02e9","contradiction_reason":"?string"}},{"source":"DefinedTerm","edge":"CONFLICTS_WITH","target":"DefinedTerm","props":{"field_name":"?string","delta":"?string","ref_id":"26d3f5ce-8fdc-4a21-8c6c-412a9f2835a4","doc_a_value":"?string","doc_b_value":"?string"}},{"source":"DefinedTerm","edge":"RELATED_TO","target":"Lingo","props":{"ref_id":"dfcbc335-c266-4009-a66b-11261dd6beae"}},{"source":"DefinedTerm","edge":"USED_IN","target":"Agreement","props":{"ref_id":"36655546-37d1-41dc-9512-23cad6eb9d54"}},{"source":"DefinedTerm","edge":"USED_IN","target":"Document","props":{"ref_id":"233616f5-1e32-4186-814e-4ee9bf3c4fcf"}},{"source":"DeliverableSchema","edge":"APPLIES_TO","target":"Matter","props":{"ref_id":"5ead6c6a-8b11-4cbb-bb1a-1dc0e8c0cb8c"}},{"source":"DiligenceIssue","edge":"BLOCKS","target":"Deadline","props":{"ref_id":"ba26ba28-a1d6-4cce-ab2f-577b1731d486"}},{"source":"DiligenceIssue","edge":"EVIDENCED_BY","target":"Figure","props":{"ref_id":"9ac507d4-d616-4a90-987f-b3b802b6f4d7"}},{"source":"DiligenceIssue","edge":"TRIGGERED_BY","target":"RegulatoryItem","props":{"ref_id":"995575eb-8bda-49d5-87ef-b4bad2bb08d3"}},{"source":"Directory","edge":"CONTAINS","target":"File","props":{"ref_id":"4a79cd1a-348a-44fb-a01a-e6c2e2edb942"}},{"source":"Doctrine","edge":"APPLIES_IN","target":"Jurisdiction","props":{"ref_id":"2424a33e-e61f-4a72-91de-8c66ba6053c0"}},{"source":"Doctrine","edge":"GOVERNS","target":"ClauseType","props":{"ref_id":"f14588fa-549d-4681-8c89-1c9a6353db8d"}},{"source":"Document","edge":"AMENDS","target":"Document","props":{"ref_id":"140f8ca6-edfd-4534-8717-c68a58c5aa04"}},{"source":"Document","edge":"DEFINES","target":"DefinedTerm","props":{"ref_id":"e3b7b0a9-cdef-4f63-97a0-1de02fd5feb2"}},{"source":"Document","edge":"HAS","target":"Section","props":{"ref_id":"1ef93eaa-d9c4-4a53-880e-a9bbb218b269"}},{"source":"Document","edge":"HAS_COMPONENT","target":"FormulaComponent","props":{"ref_id":"aa90eba4-aec3-49d5-b165-58970aa7de0b"}},{"source":"Document","edge":"SUPPLEMENTS","target":"Document","props":{"ref_id":"063d599f-b790-461d-bcbb-0bd288964f55"}},{"source":"E2etest","edge":"CALLS","target":"Function","props":{"ref_id":"e4513290-e62b-4d2a-954f-47b07d6a6206","call_start":"?int","call_end":"?int","operand":"?string"}},{"source":"Email","edge":"HAS","target":"Section","props":{"ref_id":"d5a06210-8312-4f73-8290-bcb4227e4f80"}},{"source":"Email","edge":"HAS_ATTACHMENT","target":"Attachment","props":{"ref_id":"f7e50a96-5620-45e5-8ac2-3697b5270b18"}},{"source":"Email","edge":"HAS_INVITE","target":"EmailInvite","props":{"ref_id":"43903ba7-1aa7-4a37-80bf-e8286bfcaae4"}},{"source":"Email","edge":"PART_OF_THREAD","target":"Email","props":{"ref_id":"2da2743f-474c-4e67-91ad-d4dfbc13e9e8"}},{"source":"Email","edge":"REPLY_TO","target":"Email","props":{"ref_id":"08bccd8a-3ce4-42a6-ac8c-c28ba1013356"}},{"source":"EmailInvite","edge":"LOCATED_AT","target":"Location","props":{"ref_id":"994354ea-bf60-4273-ad7e-3c72dd3a2dfd"}},{"source":"EmailInvite","edge":"RELATED_TO","target":"Event","props":{"ref_id":"8b4707ec-852d-440e-baa1-b44cdbc1e6e0"}},{"source":"Endpoint","edge":"CALLS","target":"Function","props":{"ref_id":"84b19b42-05ed-4ad3-b950-fa4edffa89ab"}},{"source":"Endpoint","edge":"HANDLER","target":"Function","props":{"ref_id":"a99a62bf-206d-4b44-80ec-2f1fa1895871"}},{"source":"Episode","edge":"HAS","target":"Chapter","props":{"ref_id":"e45d2b5f-9457-4ea1-832c-2569f4835236"}},{"source":"Episode","edge":"HAS","target":"Clip","props":{"ref_id":"ff905df3-0795-4b61-b54b-51db47209f64"}},{"source":"Episode","edge":"HAS","target":"Podcast","props":{"ref_id":"193394be-4db6-4bd5-8562-03a769c7f977"}},{"source":"Episode","edge":"HAS","target":"Video","props":{"ref_id":"1d7150c9-911e-4f83-9c72-210c4b460e62"}},{"source":"Episode","edge":"MENTIONS","target":"Organization","props":{"ref_id":"fe728b88-bfdf-489b-beda-2f0baf369be1"}},{"source":"Episode","edge":"MENTIONS","target":"Place","props":{"ref_id":"97435958-9a74-4744-a40c-6b854e4e6a08"}},{"source":"Episode","edge":"MENTIONS","target":"Product","props":{"ref_id":"8cac3f17-0647-46d9-80dc-030fec48c21c"}},{"source":"Episode","edge":"MENTIONS","target":"Topic","props":{"ref_id":"171f37b5-b1cf-4266-b5ba-82e9b0f7f6e0"}},{"source":"ErrorIssue","edge":"REFERENCES","target":"File","props":{"ref_id":"0a8c9b7a-c6f7-4994-8085-e70454edfffe"}},{"source":"ErrorIssue","edge":"REFERENCES","target":"Function","props":{"ref_id":"4d758b1e-622b-44ac-b1aa-b371ea6aae22"}},{"source":"EscalationRecord","edge":"GOVERNED_BY","target":"Doctrine","props":{"ref_id":"e28a5244-977d-4b5a-9817-2d67b1f00711"}},{"source":"EscalationRecord","edge":"TRIGGERED_BY","target":"LegalArgument","props":{"ref_id":"f28367ff-8a37-4991-a7bf-3c9a4f452524"}},{"source":"EvalRequirement","edge":"HAS_CRITERION_RESULT","target":"CriterionResult","props":{"ref_id":"9b699961-ca6d-4a8d-8676-f5b3a5a05ba6"}},{"source":"EvalRequirement","edge":"HAS_TRIGGER","target":"EvalTrigger","props":{"ref_id":"9079395d-ce0e-4ae8-ac1d-53d36b7a58f4"}},{"source":"EvalSet","edge":"HAS_BASELINE_TRIGGER","target":"EvalTrigger","props":{"ref_id":"37318282-3f41-4dd6-b87a-886c5ddea941"}},{"source":"EvalSet","edge":"HAS_REQUIREMENT","target":"EvalRequirement","props":{"order":"?int","ref_id":"5bf7a2f6-a25e-447b-9a5f-3c771d1f6ee8"}},{"source":"EvalSet","edge":"HAS_SUBSET","target":"EvalSet","props":{"ref_id":"b5787eee-ff3b-4017-947a-11041fd603fb"}},{"source":"EvalSet","edge":"HAS_TRIGGER","target":"EvalTrigger","props":{"ref_id":"743a46bb-7e09-44b3-b103-890066bee3c5"}},{"source":"EvalTrigger","edge":"ATTRIBUTED_TO","target":"HiveAgent","props":{"ref_id":"e61d8bc0-4b10-4a88-ab34-5851ab2c9e11"}},{"source":"EvalTrigger","edge":"EVALUATED","target":"AgentSession","props":{"ref_id":"e13aa06b-860e-409b-bb69-7e248943f2f2"}},{"source":"EvalTrigger","edge":"HAS_OUTPUT","target":"EvalTriggerOutput","props":{"ref_id":"8a062569-b792-4cb1-9ad2-0ef91564faee"}},{"source":"EvalTrigger","edge":"HAS_PROPOSED_FIX","target":"ProposedFix","props":{"volatility":"EVOLVING","ref_id":"11df6ac7-a1dd-4b74-96c7-4a5baf8bf3d7"}},{"source":"EvalTriggerOutput","edge":"ATTRIBUTED_TO","target":"BenchmarkFailureCause","props":{"ref_id":"d925aa6f-3da8-4185-a004-0ae145ed7a7b"}},{"source":"EvalTriggerOutput","edge":"HAS_CRITERION_RESULT","target":"CriterionResult","props":{"ref_id":"a5b15730-2bb0-46b8-bbe4-aec4915c42e9"}},{"source":"Excerpt","edge":"CONFLICTS_WITH","target":"Excerpt","props":{"ref_id":"73a52594-e88c-486a-84c7-c69cda578a1d"}},{"source":"Excerpt","edge":"EVIDENCED_BY","target":"LegalArgument","props":{"ref_id":"426bf1e8-e1be-48ad-bf47-723ca34f908b"}},{"source":"Feature","edge":"MODIFIES","target":"File","props":{"importance":"?float","ref_id":"92cca218-3167-4647-a5cb-9a8a12991a34"}},{"source":"Figure","edge":"APPLIED_TO","target":"Matter","props":{"ref_id":"daaa5b30-ead3-4a03-ad02-6bf1e2a957fa"}},{"source":"Figure","edge":"CONFLICTS_WITH","target":"Figure","props":{"field_name":"?string","delta":"?string","ref_id":"0d1e7722-af80-4973-a7e2-c31d20a31d53","doc_a_value":"?string","doc_b_value":"?string"}},{"source":"Figure","edge":"DERIVED_FROM","target":"Figure","props":{"ref_id":"5430e998-2fcc-491d-b92c-ec35f5d8f660"}},{"source":"Figure","edge":"EXTRACTED_FROM","target":"Document","props":{"ref_id":"60fd82c1-9068-4f93-a020-9cd23b431b95"}},{"source":"Figure","edge":"EXTRACTED_FROM","target":"Excerpt","props":{"ref_id":"9b45687e-f54f-4dc0-ab3c-d8e6b2923a21"}},{"source":"Figure","edge":"EXTRACTED_FROM","target":"Section","props":{"ref_id":"c6aa3476-c6d0-4764-ba17-bd54c7dcab99"}},{"source":"Figure","edge":"GOVERNED_BY","target":"RegulatoryItem","props":{"ref_id":"4a0df3cf-5526-4ba4-95a3-b778c5765c07"}},{"source":"Figure","edge":"QUANTIFIES","target":"PlaybookEntry","props":{"field_name":"?string","value_as_stated":"?string","ref_id":"4d19abc9-1ee2-40df-8043-005eb43d03ed","notes":"?string"}},{"source":"Figure","edge":"QUANTIFIES","target":"Product","props":{"field_name":"?string","value_as_stated":"?string","ref_id":"193cdf1e-ae38-46f1-8d3a-06fbf73f7aab","notes":"?string"}},{"source":"File","edge":"CONTAINS","target":"Function","props":{"ref_id":"7aec8f3f-8aac-4a6e-b582-9e3f2bcc0a56"}},{"source":"File","edge":"CONTAINS","target":"Trait","props":{"ref_id":"24a73d9a-bc61-4b42-b70d-ddac018b312b"}},{"source":"File","edge":"CONTAINS","target":"Var","props":{"ref_id":"e5a26c2e-3c84-476a-8642-34a7fcdbacb4"}},{"source":"File","edge":"IMPORTS","target":"File","props":{"ref_id":"d4a4765a-576b-422e-83be-afc7840d6d55"}},{"source":"File","edge":"IMPORTS","target":"Library","props":{"ref_id":"9cb30be8-dbc2-4932-80cc-0623c1fb0df0"}},{"source":"Fluent","edge":"CURRENT_VALUE","target":"Thing","props":{"volatility":"EVOLVING","ref_id":"a2cb228c-64fa-4612-b252-72a511227473","cardinality":"single"}},{"source":"Fluent","edge":"HAS_EVENT","target":"FluentEvent","props":{"volatility":"STATIC","ref_id":"0bf0ca13-7f67-4e50-9577-d59b0106663a","cardinality":"many"}},{"source":"FormulaComponent","edge":"DERIVED_FROM","target":"Figure","props":{"ref_id":"dab20746-68dc-49d6-aa24-81fec7e3a26b"}},{"source":"Function","edge":"CALLS","target":"Function","props":{"ref_id":"6a273677-8513-4100-99ad-dfde61079b84","call_start":"?int","call_end":"?int","operand":"?string"}},{"source":"Function","edge":"CONTAINS","target":"Datamodel","props":{"ref_id":"e38c1682-58c1-490d-905b-4cda999f6440"}},{"source":"Function","edge":"CONTAINS","target":"Var","props":{"ref_id":"32065232-1a38-4cd8-a842-edcb7ac322e7"}},{"source":"Function","edge":"USES","target":"Function","props":{"ref_id":"ed629a5a-8c68-43fe-904a-346a9e89e288","call_start":"?int","call_end":"?int","operand":"?string"}},{"source":"GapItem","edge":"BLOCKS","target":"AISystem","props":{"ref_id":"9619d872-2839-48ba-9682-9babcdebe031"}},{"source":"GapItem","edge":"FLAGS","target":"Excerpt","props":{"undefined_term":"?string","ref_id":"66af7d02-0e4e-4b10-9a1d-5189ecbe172a","flag_category":"?string","gap_reason":"?string"}},{"source":"GapItem","edge":"GOVERNED_BY","target":"Regulation","props":{"ref_id":"22dffca9-c829-4be7-8e3e-daa7dd3461c7"}},{"source":"GapItem","edge":"PROMPTED","target":"CommentPeriod","props":{"ref_id":"851c0385-78e4-424f-9356-5e8d07c7b21a"}},{"source":"Generated_step","edge":"NEXT","target":"Generated_step","props":{"ref_id":"2c531788-446d-490d-8d56-e6a952abbba0"}},{"source":"GitHubRepo","edge":"HAS","target":"Commits","props":{"ref_id":"f7c97d66-23c4-4c43-8361-1a28838be86f"}},{"source":"GitHubRepo","edge":"HAS","target":"Issues","props":{"ref_id":"0fa00ef9-63a0-4fe6-824b-d810149e1495"}},{"source":"HiveAgent","edge":"HAS_PROMPT","target":"Prompt","props":{"ref_id":"9a07a3a5-326a-499d-ab8f-36391458b367"}},{"source":"HiveFeature","edge":"HAS_MESSAGE","target":"HiveChatMessage","props":{"ref_id":"5b5a93f7-2f23-47f3-a5e7-b45e54d6a060"}},{"source":"HiveFeature","edge":"HAS_TASK","target":"HiveTask","props":{"ref_id":"23729b9d-b461-4269-9479-a4bf4fae8025"}},{"source":"HiveInitiative","edge":"HAS_MILESTONE","target":"HiveMilestone","props":{"ref_id":"ff77e898-0d27-44bb-bad0-bc9b0e9514d7"}},{"source":"HiveInitiative","edge":"HAS_RESEARCH","target":"HiveResearch","props":{"ref_id":"66009917-257c-4ae8-b940-413aa69b5a2a"}},{"source":"HiveMilestone","edge":"HAS_RESEARCH","target":"HiveResearch","props":{"ref_id":"aecd59e2-82b1-4a10-bf77-e1985ede95f5"}},{"source":"HiveTask","edge":"HAS_MESSAGE","target":"HiveChatMessage","props":{"ref_id":"ab35c9f3-74d4-47c4-aa21-ed9cc0f55787"}},{"source":"HiveTask","edge":"RESULTED_IN","target":"PullRequest","props":{"ref_id":"92932c6f-8e24-4da0-a7d2-ce4a69b6ba33"}},{"source":"HiveWorkspace","edge":"BEST_PRACTICE","target":"Concept","props":{"ref_id":"2532bbb5-45c7-4200-9031-d1c2728404d0"}},{"source":"HiveWorkspace","edge":"GOTCHA","target":"Concept","props":{"ref_id":"28538827-dde7-4cca-afa0-b49d8e134d49"}},{"source":"HiveWorkspace","edge":"HAS_CONCEPT","target":"Concept","props":{"ref_id":"260f5e60-5fe6-47d4-9dfe-a5a5b48ff3cc"}},{"source":"HiveWorkspace","edge":"HAS_MEMBER","target":"HiveWorkspaceMember","props":{"ref_id":"f5af8b5e-0080-4885-9338-b16079d30ec9"}},{"source":"HiveWorkspace","edge":"PREFERENCE","target":"Concept","props":{"ref_id":"945365b0-f06b-4ead-8eff-792a732da04c"}},{"source":"HiveWorkspace","edge":"PROCESS","target":"Concept","props":{"ref_id":"8fdcfa72-30c6-44b4-904d-c8a4b3dcc9a2"}},{"source":"HiveWorkspaceMember","edge":"APPROVED","target":"Concept","props":{"ref_id":"dee58243-7a08-43eb-8a55-081c4e48f59b"}},{"source":"HiveWorkspaceMember","edge":"PREFERENCE","target":"Concept","props":{"ref_id":"5db0d514-d3a9-4c70-93d3-87f647ad4314"}},{"source":"IPAsset","edge":"HAS_DEADLINE","target":"Deadline","props":{"ref_id":"12d4ce1e-9e3e-46fa-9881-f67c2ea6fc8f"}},{"source":"IPAsset","edge":"LICENSED_UNDER","target":"ContractClause","props":{"ref_id":"93d07f6d-211b-45ff-bd10-d4cf0bc949a2"}},{"source":"IPAsset","edge":"REGISTERED_IN","target":"Jurisdiction","props":{"filing_status":"?string","ref_id":"c9fad082-fe6a-482c-9cf5-ba57bde19dc6","filing_date":"?string","application_number":"?string"}},{"source":"IPAsset","edge":"RENEWAL_TRACKED_BY","target":"RenewalEntry","props":{"ref_id":"f460ae29-4d6b-45de-9d6d-b91cc6d9baaf"}},{"source":"IPAsset","edge":"SUPPORTED_BY","target":"Excerpt","props":{"support_type":"?string","ref_id":"39008e61-f751-438a-a25c-23adda7c5e42","support_reason":"?string"}},{"source":"Import","edge":"IMPORTS","target":"Class","props":{"ref_id":"f41e73df-bf21-42e9-a932-2f99d38b475c"}},{"source":"Import","edge":"IMPORTS","target":"Datamodel","props":{"ref_id":"0194ea4b-b6e2-4b4f-9b01-3222d784af6e"}},{"source":"Import","edge":"IMPORTS","target":"Function","props":{"ref_id":"0e28ad60-7da5-4d1d-a76a-2b8f6098e80c"}},{"source":"Instance","edge":"OF","target":"Class","props":{"ref_id":"2a989132-1d2f-4bae-a83e-f1566bd3c77b","call_start":"?int","call_end":"?int","operand":"?string"}},{"source":"IntegrationTest","edge":"CALLS","target":"Endpoint","props":{"ref_id":"de3007c7-f4d9-4120-ad74-793ac409e941","call_start":"?int","call_end":"?int","operand":"?string"}},{"source":"IntegrationTest","edge":"CALLS","target":"Function","props":{"ref_id":"8f5fcca1-ca75-47cc-ba1c-5c120bcee45f","call_start":"?int","call_end":"?int","operand":"?string"}},{"source":"Jargon","edge":"DEFINES","target":"Organization","props":{"ref_id":"cbdcae05-8476-4f00-9910-665a4b47471e"}},{"source":"Jargon","edge":"DEFINES","target":"Person","props":{"ref_id":"0a7db9e5-51f2-4388-8642-bdc15ffc34b8"}},{"source":"Jargon","edge":"DEFINES","target":"Thing","props":{"ref_id":"bc402ac3-e6be-43c6-9ad8-eb836e758b6b"}},{"source":"Jargon","edge":"DEFINES","target":"Topic","props":{"ref_id":"99813661-ce22-488f-91c9-6e6c57c5d13f"}},{"source":"Jurisdiction","edge":"PART_OF","target":"Jurisdiction","props":{"ref_id":"7eb5c5c4-8171-4736-910f-2c64abe2ea61"}},{"source":"LeaseAgreement","edge":"HAS_CLAUSE","target":"ContractClause","props":{"ref_id":"e471a631-b4ff-40eb-be36-208f63b2774f"}},{"source":"LegalArgument","edge":"APPLIES_IN","target":"Jurisdiction","props":{"ref_id":"674169b0-f467-42be-be24-c8a7e7425e33"}},{"source":"LegalArgument","edge":"APPLIES_TO","target":"Matter","props":{"ref_id":"96c0c3ec-6c42-4068-b405-c530d5326e7b"}},{"source":"LegalArgument","edge":"BLOCKS","target":"Deadline","props":{"ref_id":"98dd3cfe-9740-4828-832e-eb7e9227c16d"}},{"source":"LegalArgument","edge":"CONTRADICTS","target":"LegalArgument","props":{"ref_id":"6c52b475-2d38-47ae-8da4-8cdac31508e6"}},{"source":"LegalArgument","edge":"GROUNDED_IN","target":"Doctrine","props":{"ref_id":"e087f876-1cd8-4a43-9b4d-10d66f55cc68"}},{"source":"LegalArgument","edge":"REFERENCES_DATE","target":"Deadline","props":{"ref_id":"4ea94598-e753-46e4-b7a8-06e44ffa4f75"}},{"source":"LegalArgument","edge":"SUPPORTED_BY","target":"Excerpt","props":{"ref_id":"9f7e5b96-4aeb-4851-b456-01c4179cdb55"}},{"source":"LegalArgument","edge":"SUPPORTED_BY","target":"FormulaComponent","props":{"support_type":"?string","ref_id":"97824854-56ee-49bb-98ac-6b407b0c636a","support_reason":"?string"}},{"source":"LegalArgument","edge":"SUPPORTED_BY","target":"LegalArgument","props":{"ref_id":"83d199ed-3cc3-4b2a-8f3d-f65a1ddb1641"}},{"source":"LegalArgument","edge":"SUPPORTED_BY","target":"TimelineEntry","props":{"support_type":"?string","ref_id":"62409bea-c6d1-4f0d-8892-0e587932c9c7","support_reason":"?string"}},{"source":"LegalHold","edge":"IS_CUSTODIAN","target":"Person","props":{"ref_id":"afcbed23-fdc9-4d36-8a10-3636fb24119a"}},{"source":"LegalParty","edge":"REPRESENTS_ORG","target":"Organization","props":{"ref_id":"a249d354-114f-49c7-acf6-b064626d57f1"}},{"source":"LegalParty","edge":"REPRESENTS_PERSON","target":"Person","props":{"ref_id":"941efe1c-53b7-4bfb-9954-622eca3152bc"}},{"source":"LimitationStatute","edge":"APPLIES_IN","target":"Jurisdiction","props":{"ref_id":"65a6f905-bf2b-4e13-b6d7-06d087678622"}},{"source":"Lingo","edge":"RELATED_TO","target":"Lingo","props":{"ref_id":"9e50acae-2aba-46fd-8414-a0b4e1e8d1e5"}},{"source":"Location","edge":"HAS_WEATHER","target":"Thing","props":{"volatility":"VOLATILE","confidence_score":"?float","invalid_at":"?datetime","temporal":true,"valid_at":"?datetime","ref_id":"0ee6d0ec-fbc4-48cc-a270-dd2554d5eb4f","last_confirmed_at":"?datetime","invalidated_by":"?string","expired_at":"?datetime","cardinality":"single"}},{"source":"Location","edge":"MENTIONED_IN","target":"Section","props":{"ref_id":"efa1df61-31b2-470f-893c-5933f0efcdaf"}},{"source":"Matter","edge":"ASSIGNED_TO","target":"Person","props":{"ref_id":"2b04cf58-07f8-44b2-b874-e32f2a0ccf7b"}},{"source":"Matter","edge":"CONCERNS","target":"IPAsset","props":{"ref_id":"e4667dfa-b226-4648-bd90-f2646ce2e25c"}},{"source":"Matter","edge":"CONTAINS_REQUEST","target":"DDRequestItem","props":{"ref_id":"dcfea4bf-9572-4149-ac74-dc60f3c2721a"}},{"source":"Matter","edge":"FILED_IN","target":"Jurisdiction","props":{"ref_id":"81d51b1c-a502-4816-a0b4-56a4c8d283f2"}},{"source":"Matter","edge":"HAS_ARGUMENT","target":"LegalArgument","props":{"ref_id":"8ec9576a-faa8-4aa5-b1c1-dbdcbb716d04"}},{"source":"Matter","edge":"HAS_CONFLICT_CHECK","target":"ConflictCheck","props":{"ref_id":"8fa5112b-5509-4672-bad0-cc26a425a062"}},{"source":"Matter","edge":"HAS_DEADLINE","target":"Deadline","props":{"ref_id":"9206cf3a-efcf-4ed0-bdaf-2f37012bd574"}},{"source":"Matter","edge":"HAS_DILIGENCE_ISSUE","target":"DiligenceIssue","props":{"ref_id":"b505c727-3bea-4a65-926a-92ffcd65a306"}},{"source":"Matter","edge":"HAS_ESCALATION","target":"EscalationRecord","props":{"ref_id":"8d0e67e5-bba9-4299-a796-9bbee1c20f2d"}},{"source":"Matter","edge":"HAS_INVESTIGATION","target":"InvestigationLog","props":{"ref_id":"56588905-92bd-42ab-a7f1-2179fc00926b"}},{"source":"Matter","edge":"HAS_LEGAL_HOLD","target":"LegalHold","props":{"ref_id":"bd0f956c-943e-4350-b3e3-ab93f783c4b7"}},{"source":"Matter","edge":"HAS_PARTY","target":"LegalParty","props":{"ref_id":"7e37d505-160c-477a-9c02-2a950a5b630c"}},{"source":"Matter","edge":"HAS_TIMELINE_ENTRY","target":"TimelineEntry","props":{"volatility":"STABLE","confidence_score":"?float","invalid_at":"?datetime","temporal":true,"valid_at":"?datetime","ref_id":"53b136a2-5698-4282-82a6-9121704181fe","last_confirmed_at":"?datetime","invalidated_by":"?string","expired_at":"?datetime"}},{"source":"Matter","edge":"INVOLVES_PARTY","target":"Organization","props":{"ref_id":"9d939108-f27e-432a-a6b6-4c51b2dc15e9"}},{"source":"Matter","edge":"OUTSIDE_COUNSEL","target":"Organization","props":{"ref_id":"3ccd3264-b8f6-4f08-8559-a23b096cda3e"}},{"source":"Message","edge":"RELATED_TO","target":"Topic","props":{"ref_id":"6866dcba-8b33-4909-b161-6ada71846256"}},{"source":"Organization","edge":"BACKED_BY","target":"Organization","props":{"ref_id":"dbd48da3-b759-4803-b584-99b704ea934b"}},{"source":"Organization","edge":"CONDUCTS","target":"ProcessingActivity","props":{"ref_id":"9880b1f7-4a09-4153-b4c2-1ab5210f345d"}},{"source":"Organization","edge":"CONFLICTS_WITH","target":"Organization","props":{"doc_b_name":"?string","entity_reference":"?string","doc_a_source":"?string","ref_id":"bbb5d897-66ed-48fa-b79c-64a1de6069fb","doc_b_source":"?string","conflict_type":"?string","doc_a_name":"?string"}},{"source":"Organization","edge":"FINANCES","target":"Organization","props":{"amount":"?string","financing_type":"?string","ref_id":"8eb493e7-89e1-4e9a-baef-42fa3be6ab76"}},{"source":"Organization","edge":"HAS","target":"Repository","props":{"ref_id":"d5b3bb27-1603-4407-9942-021ef0a499e2"}},{"source":"Organization","edge":"HAS_JURISDICTION","target":"Jurisdiction","props":{"ref_id":"d19f993e-81a9-4e2f-9bc6-8bad4b1012d2"}},{"source":"Organization","edge":"IDENTIFIED_AS","target":"TwitterAccount","props":{"ref_id":"48717b34-2250-4d10-b240-0f2122df6a75"}},{"source":"Organization","edge":"INCORPORATED_IN","target":"Country","props":{"ref_id":"b98e438c-9efe-44a1-ac1b-24bc3d79a791"}},{"source":"Organization","edge":"IS_PARTY_TO","target":"Agreement","props":{"ref_id":"03c56b45-e89b-4384-a41a-f2b70e212d00"}},{"source":"Organization","edge":"IS_PARTY_TO","target":"LeaseAgreement","props":{"ref_id":"c774d1b9-87c4-4be8-a325-20bcabc7067c"}},{"source":"Organization","edge":"MENTIONED_IN","target":"Agreement","props":{"ref_id":"a1d8d918-a68e-45b4-bf12-1871e892fb81"}},{"source":"Organization","edge":"MENTIONED_IN","target":"Section","props":{"ref_id":"6d258357-403c-4617-8168-b05bcade8741"}},{"source":"Organization","edge":"OPERATES","target":"AISystem","props":{"ref_id":"71c10c19-3289-4565-a9cf-14a63b5d56b1"}},{"source":"Organization","edge":"OWNS","target":"IPAsset","props":{"ref_id":"0a2b9adc-ff54-487f-8bea-3617953fd0d6"}},{"source":"Page","edge":"RENDERS","target":"Function","props":{"ref_id":"d8f07d98-ddd3-447e-ada1-1b991792a468"}},{"source":"Person","edge":"ACCEPTED","target":"EmailInvite","props":{"ref_id":"acbe237a-a63c-4d9f-839c-14466e716203"}},{"source":"Person","edge":"AUTHORED_BY","target":"Document","props":{"ref_id":"b6b647e8-27d3-4c0f-892a-f9f671b9f299"}},{"source":"Person","edge":"BELIEVES","target":"Belief","props":{"ref_id":"6b214710-e4d8-4506-8c20-933e993c773e"}},{"source":"Person","edge":"BORN_ON","target":"Thing","props":{"volatility":"STATIC","confidence_score":"?float","invalid_at":"?datetime","temporal":true,"valid_at":"?datetime","ref_id":"e525ef74-3434-4a92-9673-770d61f5d4b4","last_confirmed_at":"?datetime","invalidated_by":"?string","expired_at":"?datetime","cardinality":"single"}},{"source":"Person","edge":"CC_ON","target":"Email","props":{"ref_id":"f86404c3-cdcf-451f-803b-51e8d0625847"}},{"source":"Person","edge":"COMPLAINANT_IN","target":"InvestigationLog","props":{"ref_id":"54e02c15-6325-4474-90e8-fb4d94173348"}},{"source":"Person","edge":"CONFLICTS_WITH","target":"Person","props":{"doc_b_name":"?string","entity_reference":"?string","doc_a_source":"?string","ref_id":"4b12bfe7-1895-42d3-8038-031c0fb1cb5e","doc_b_source":"?string","conflict_type":"?string","doc_a_name":"?string"}},{"source":"Person","edge":"DECLINED","target":"EmailInvite","props":{"ref_id":"4a735155-0e52-4894-8559-cd454078d9f0"}},{"source":"Person","edge":"EMPLOYED_BY","target":"Organization","props":{"volatility":"EVOLVING","confidence_score":"?float","invalid_at":"?datetime","temporal":true,"valid_at":"?datetime","ref_id":"104f96eb-b272-48ea-9b58-15a429ecd6e1","last_confirmed_at":"?datetime","invalidated_by":"?string","expired_at":"?datetime"}},{"source":"Person","edge":"IDENTIFIED_AS","target":"TwitterAccount","props":{"ref_id":"8f02cd57-2da7-431f-b766-f9deffe41109"}},{"source":"Person","edge":"INVENTED","target":"IPAsset","props":{"ref_id":"07001853-9ea4-429b-be37-33f7ee88d64c"}},{"source":"Person","edge":"INVITED_TO","target":"EmailInvite","props":{"ref_id":"32572cb9-8306-46ef-b4fa-347a4958460d"}},{"source":"Person","edge":"IS_GUEST","target":"Episode","props":{"ref_id":"27c8db4d-3623-429d-9ac8-4afe07407229"}},{"source":"Person","edge":"IS_HOST","target":"Episode","props":{"ref_id":"e9f3a398-e85a-40eb-9d1e-ed076a584199"}},{"source":"Person","edge":"IS_PARTY_TO","target":"Agreement","props":{"ref_id":"1fb4b8f5-b632-41cd-adfb-e71f06650c2f"}},{"source":"Person","edge":"IS_SPEAKER","target":"Episode","props":{"ref_id":"c3ea7c39-03c3-45aa-96be-c3435d74e773"}},{"source":"Person","edge":"MADE_CLAIM","target":"Claim","props":{"ref_id":"5d8993bb-263c-46e7-b79b-b9421af3f076"}},{"source":"Person","edge":"MENTIONED","target":"Episode","props":{"ref_id":"711f3c9c-9b2b-4663-bbb9-a1549a181739"}},{"source":"Person","edge":"MENTIONED_IN","target":"Agreement","props":{"ref_id":"bd5a99b5-ddd3-4a11-90c0-20b39587fcb1"}},{"source":"Person","edge":"MENTIONED_IN","target":"Section","props":{"ref_id":"f830b012-cd13-42b6-9a11-02a647d0800c"}},{"source":"Person","edge":"ORGANIZED","target":"EmailInvite","props":{"ref_id":"006a28dc-e206-4478-8d61-f23eeedd74df"}},{"source":"Person","edge":"POSTED","target":"Tweet","props":{"ref_id":"38d0bc21-d483-4321-9ac5-841f8ee45fb1"}},{"source":"Person","edge":"RECEIVED","target":"Email","props":{"ref_id":"4c429ced-06ad-4b6d-b638-dab8b0f970ec"}},{"source":"Person","edge":"RESPONDENT_IN","target":"InvestigationLog","props":{"ref_id":"a91f0ecc-3e1c-447c-b55c-69ef17f5ba9f"}},{"source":"Person","edge":"SAME_AS","target":"Person","props":{"basis":"?string","doc_b_name":"?string","ref_id":"84998585-8ab3-41c5-abdf-6e0a61eb62dc","doc_a_name":"?string"}},{"source":"Person","edge":"SENT","target":"Email","props":{"ref_id":"4ca2366d-1323-4e40-8811-d483bf174309"}},{"source":"Person","edge":"SENT","target":"Message","props":{"ref_id":"b2b233cc-0b38-43ae-bf3b-10eeab7b255c"}},{"source":"Person","edge":"TENTATIVE","target":"EmailInvite","props":{"ref_id":"551cf5f0-49cf-4f15-9ff6-2d6da4f29d7a"}},{"source":"Person","edge":"WORKS_AT","target":"Organization","props":{"volatility":"EVOLVING","confidence_score":"?float","fluent":true,"invalid_at":"?datetime","temporal":true,"valid_at":"?datetime","ref_id":"056aa25c-afd5-4753-ba72-c0533f27e883","last_confirmed_at":"?datetime","invalidated_by":"?string","expired_at":"?datetime","cardinality":"single"}},{"source":"PlaybookEntry","edge":"APPLIES_IN","target":"Jurisdiction","props":{"ref_id":"4388d55e-722c-4a9a-847c-fa3ca1d9ad7d"}},{"source":"PlaybookEntry","edge":"CALIBRATED_FOR","target":"Matter","props":{"ref_id":"f42cd61f-9492-46a2-bf99-601cfee627d1"}},{"source":"PlaybookEntry","edge":"EVIDENCED_BY","target":"Excerpt","props":{"ref_id":"1b83ab97-f430-4643-a14a-154a5fe199bc"}},{"source":"PlaybookEntry","edge":"GOVERNED_BY","target":"Regulation","props":{"ref_id":"0b957d1c-7de0-4dbc-a65d-881c151b3ccb"}},{"source":"PlaybookEntry","edge":"HAS_COMPONENT","target":"FormulaComponent","props":{"ref_id":"1cec4e3e-a31c-4068-83b1-1ef61e0fc15a"}},{"source":"PlaybookEntry","edge":"HAS_DEADLINE","target":"Deadline","props":{"ref_id":"20939295-99b3-48ef-9222-403e684ffb0d"}},{"source":"Podcast","edge":"RELATED_TO","target":"Topic","props":{"ref_id":"a3775ed6-340e-4b58-be3e-82c81aa7d34d"}},{"source":"Policy","edge":"HAS_CLAUSE","target":"PlaybookEntry","props":{"ref_id":"dc99ab6a-9c1e-4020-ba6c-562bd8405039"}},{"source":"Policy","edge":"HAS_GAP","target":"GapItem","props":{"ref_id":"3b677e75-7f4b-43f7-a303-0ec459823506"}},{"source":"Policy","edge":"IMPLEMENTS","target":"Regulation","props":{"ref_id":"e150dd5b-4ced-4a55-af1c-bc7a43082151"}},{"source":"ProcessingActivity","edge":"GOVERNED_BY","target":"Regulation","props":{"ref_id":"bd4da6b0-60b5-448f-b3b2-a87e9eb51f56"}},{"source":"Product","edge":"MENTIONED_IN","target":"Section","props":{"ref_id":"6ac7850b-83e9-4716-80a2-0f8013f516e3"}},{"source":"ProposedFix","edge":"DERIVED_FROM","target":"ProposedFix","props":{"ref_id":"c3b22531-5dec-4609-8d72-9536b7db5eb2"}},{"source":"ProposedFix","edge":"PRODUCED_BY","target":"EvalTriggerOutput","props":{"ref_id":"3b5792c2-99bf-4862-aeb4-9efd5037b4da"}},{"source":"ProposedFix","edge":"TARGETS_CAUSE","target":"Cause","props":{"ref_id":"ecef4894-b4b7-4f1f-8b3d-18fbe285a30c","cardinality":"many"}},{"source":"ProposedFix","edge":"TARGETS_CONCEPT","target":"Concept","props":{"ref_id":"66dcf394-d69b-4926-8921-6146a5a93896","cardinality":"many"}},{"source":"PullRequest","edge":"CREATED_BY","target":"Contributor","props":{"ref_id":"8dd02d28-1d4d-471c-a3b6-df4e8cfb254a"}},{"source":"PullRequest","edge":"TOUCHES","target":"Concept","props":{"ref_id":"a031c900-91cb-428b-b5da-f3b14794c776"}},{"source":"PullRequest","edge":"TOUCHES","target":"Feature","props":{"ref_id":"40009fd0-10f5-4c3c-9e51-57c62dc2d4bb"}},{"source":"Regulation","edge":"HAS_COMMENT_PERIOD","target":"CommentPeriod","props":{"ref_id":"bf329298-f5b6-43b9-ac26-12ee2b2a8ed9"}},{"source":"Regulation","edge":"HAS_GAP","target":"GapItem","props":{"ref_id":"bfd7c9c5-dbd7-41dd-92ca-2e98ed94f2df"}},{"source":"Regulation","edge":"ISSUED_UNDER","target":"RegulatoryItem","props":{"ref_id":"0fd6ba61-0160-4cd6-a2c7-35980d1e6840"}},{"source":"RegulatoryItem","edge":"EVIDENCED_BY","target":"Excerpt","props":{"ref_id":"fcc25b9d-c9e1-4a74-a0e8-e0a30652bf09"}},{"source":"Repository","edge":"HAS","target":"PullRequest","props":{"ref_id":"7210e064-e05e-44db-ac93-b833f1970bd0"}},{"source":"Request","edge":"CALLS","target":"Endpoint","props":{"ref_id":"e42e5df6-2867-4390-a004-11e4c2000426"}},{"source":"Run","edge":"START","target":"Run_step","props":{"ref_id":"ab03be44-5866-4ed7-8403-7eb9544537a3"}},{"source":"Run_step","edge":"START","target":"Run_step","props":{"accurate":"?boolean","ref_id":"a47eddac-3a19-4849-abdf-a631f148da39"}},{"source":"Section","edge":"AUTHORED_BY","target":"Person","props":{"ref_id":"202cbe5e-1992-407f-82e3-6be9dd007fde"}},{"source":"Section","edge":"HAS_COMPONENT","target":"FormulaComponent","props":{"ref_id":"44c43d8b-4a5a-463e-9697-dbbb519fb269"}},{"source":"Section","edge":"RELATED_TO","target":"Topic","props":{"ref_id":"a5bb0cfb-7fc2-4803-84e6-526af463192f"}},{"source":"Show","edge":"HAS","target":"Episode","props":{"ref_id":"96abe772-258b-4a99-9cc9-bb911b127615"}},{"source":"Show","edge":"HAS_CLAIM","target":"Claim","props":{"ref_id":"253f97cf-ab28-4824-a563-86f8c2424223"}},{"source":"TabularReviewCell","edge":"CELL_OF","target":"Document","props":{"ref_id":"838ad33c-4400-495a-9c6c-49daf85e19e0"}},{"source":"TabularReviewCell","edge":"CONFLICTS_WITH","target":"TabularReviewCell","props":{"field_name":"?string","delta":"?string","ref_id":"0f76ff64-183d-476a-8576-1cb241934b8e","doc_a_value":"?string","doc_b_value":"?string"}},{"source":"Task","edge":"GLOBAL_MEMORY","target":"Memory","props":{"ref_id":"b63352b4-6b76-40ce-bd73-c3da923f1cdc"}},{"source":"Thing","edge":"HAS_FLUENT","target":"Fluent","props":{"volatility":"STATIC","ref_id":"1f4ba273-0b99-4137-8055-5bcd77631e32","cardinality":"many"}},{"source":"Thing","edge":"HAS_PRICE","target":"Thing","props":{"volatility":"INSTANTANEOUS","confidence_score":"?float","invalid_at":"?datetime","temporal":true,"valid_at":"?datetime","ref_id":"81d29516-82ed-4e83-84f8-ed76d96d0557","last_confirmed_at":"?datetime","invalidated_by":"?string","expired_at":"?datetime","cardinality":"single"}},{"source":"TimelineEntry","edge":"CONTRADICTS","target":"TimelineEntry","props":{"contradiction_type":"?string","ref_id":"20246428-c172-4e20-aec9-dab98eb482fb","contradiction_reason":"?string"}},{"source":"Topic","edge":"MENTIONED_IN","target":"Section","props":{"ref_id":"a9a4412f-1c85-479c-80d5-8d5674166487"}},{"source":"Trait","edge":"OPERAND","target":"Function","props":{"ref_id":"077357be-8425-435a-a228-a236ff104fd6"}},{"source":"Turn","edge":"NEXT","target":"Turn","props":{"ref_id":"8c75f4bc-295d-44a8-b003-dd32c153dd31"}},{"source":"Tweet","edge":"RELATED_TO","target":"Topic","props":{"ref_id":"8a460d32-4505-43d3-a5f5-0dcf209dae2c"}},{"source":"Tweet","edge":"THREAD_PART","target":"Tweet","props":{"ref_id":"435348f9-f45c-4228-a6eb-bcf63420bd28"}},{"source":"TwitterAccount","edge":"POSTED","target":"Tweet","props":{"ref_id":"6f02c7c3-30fa-4648-bc87-a7ab212be830"}},{"source":"UnitTest","edge":"CALLS","target":"Class","props":{"ref_id":"2f93edd2-1c07-426c-b4a6-35114614ea46","call_start":"?int","call_end":"?int","operand":"?string"}},{"source":"UnitTest","edge":"CALLS","target":"Endpoint","props":{"ref_id":"d7dbefc8-7b6a-4113-8ff1-e038193fd8eb","call_start":"?int","call_end":"?int","operand":"?string"}},{"source":"UnitTest","edge":"CALLS","target":"Function","props":{"ref_id":"864b554e-bd9c-4300-8556-b29537e31560","call_start":"?int","call_end":"?int","operand":"?string"}},{"source":"Var","edge":"CONTAINS","target":"Var","props":{"ref_id":"0300dc59-949c-4df1-8938-2aac635513de"}},{"source":"Video","edge":"RELATED_TO","target":"Topic","props":{"ref_id":"e07de5da-e8f6-49a4-b9a3-f76604d39a0a"}},{"source":"Workflow_version","edge":"START","target":"Generated_step","props":{"ref_id":"30d2a1d8-d344-4123-afb2-dd8119ca6e0d"}}],"hidden_domains":["Scratchpad"]}; +export const JARVIS_ONTOLOGY: OntologyFixture = {"source":"jarvis-backend local default seed (sphinxlightning/sphinx-neo4j) at migration 125 (Check node + ABOUT/TESTS/PRODUCED_BY pairs), read-only dump","schemas":[{"ref_id":"83bb6c45-40c5-45de-b76f-8aeb5ee75f33","type":"*","is_system":true},{"next_review":"?datetime","node_key":"aisystem-name","secondary_color":"#D7CCC8","type":"AISystem","obligations_assessed":"?boolean","workflow_position":"?string","review_trigger":"?string","risk_tier":"?string","description":"?string","name":"string","governance_tier":"?string","type_description":"An AI or automated decision-making system subject to governance and regulatory obligations.","domain":"Legal","role":"?string","icon":"ChipIcon","primary_color":"#6D4C41","index":["ref_id","name","risk_tier","regulatory_regime"],"regulatory_regime":"?string","status":"?string","parent":"Thing","shape":"sphere","eu_nexus":"?boolean","human_in_loop":"?boolean","description_key":"description","title_key":"name","classification":"?string","ref_id":"1c93f74c-b212-4493-98b9-833decb96315","obligations_note":"?string","affected_population":"?string","output_type":"?string","tier_basis":"?string"},{"icon":"BookIcon","primary_color":"#222B48","index":["text","level"],"text":"string","abstraction_id":"string","parent":"Workflow","node_key":"abstraction-abstraction_id","secondary_color":"#5E84F8","shape":"sphere","type":"Abstraction","level":"string","description_key":"text","title_key":"level","domain":"Workflow","type_description":"A named level of abstraction for an agent session — title, narrative, or decision — with optional embedding for retrieval.","ref_id":"31ba479a-5928-4ad1-b074-1dedb265036e","embedding":"?string"},{"action_id":"string","icon":"GeneratedStepIcon","primary_color":"#38243C","index":["name","description"],"status":"?string","parent":"Workflow","node_key":"action-action_id","secondary_color":"#F468D4","shape":"sphere","type":"Action","description_key":"description","title_key":"name","description":"?string","name":"?string","domain":"Workflow","type_description":"Represents a single action or procedural step within a broader strategy.","ref_id":"28159b06-14d2-4df3-b0e1-bace578f0320"},{"icon":"GeneratedStepIcon","primary_color":"#38243C","index":["agent_id","role"],"parent":"Workflow","node_key":"agent-agent_id","secondary_color":"#F468D4","shape":"sphere","agent_id":"int","type":"Agent","description_key":"role","title_key":"agent_id","domain":"Workflow","type_description":"An autonomous entity that can perform actions and make decisions.","ref_id":"e54fef17-be5f-4893-8bee-9f1da0bab9b2","role":"?string"},{"icon":"PersonIcon","primary_color":"#222B48","index":["source"],"parent":"Workflow","node_key":"agentrole-source","secondary_color":"#5E84F8","shape":"sphere","type":"AgentRole","description_key":"source","title_key":"source","source":"string","domain":"CodeArtifact","type_description":"A named agent type (e.g. plan-agent, coding-agent) that groups its execution sessions.","ref_id":"cd4025c7-575c-4fd2-8f18-e162bafd0eea"},{"model":"?string","session_id":"string","output_tokens":"?int","total_tokens":"?int","task_id":"?string","duration_ms":"?int","node_key":"agentsession-session_id","log_url":"?string","secondary_color":"#C25AF3","type":"AgentSession","repo":"?string","feature_id":"?string","error_message":"?string","type_description":"A single agent execution run, keyed by per-run UUID.","domain":"CodeArtifact","created_at":"?datetime","icon":"GeneratedStepIcon","primary_color":"#302342","index":["source","status","repo"],"status":"?string","cache_read_tokens":"?int","parent":"Workflow","provider":"?string","input_tokens":"?int","shape":"sphere","description_key":"status","title_key":"source","source":"string","end_time":"?int","ref_id":"e0eace93-0e5c-4f27-bd28-ec94a0f1b17a","start_time":"?int","cache_write_tokens":"?int"},{"agreement_type":"?string","icon":"DocumentIcon","primary_color":"#6D4C41","index":["name","agreement_type","effective_date","governing_law"],"status":"?string","governing_law":"?string","parent":"Legal","node_key":"agreement-id","secondary_color":"#D7CCC8","shape":"sphere","type":"Agreement","id":"string","description_key":"agreement_type","title_key":"name","execution_date":"?string","expiration_date":"?string","name":"?string","domain":"Legal","type_description":"A legally binding contract between two or more parties. Matches the Agreement label produced by the graphrag-contract-review ingestion pipeline.","ref_id":"718a2c7e-fe85-44ae-90ea-847974f6fa78","effective_date":"?string"},{"icon":"PaperClipIcon","primary_color":"#1A3A52","size_bytes":"?int","index":["filename","content_type"],"parent":"Content","node_key":"attachment-filename","secondary_color":"#5BA4CF","shape":"sphere","type":"Attachment","description_key":"content_type","title_key":"filename","domain":"Content","type_description":"A file attached to an email message.","ref_id":"4bbd7399-656d-48b3-9718-8e47ed63c49c","filename":"string","source_link":"?string","content_type":"?string"},{"icon":"NodesIcon","primary_color":"#2A3229","index":"user_reference","update_type":"string","parent":"KnowledgeArtifact","node_key":"belief-user_reference-referenced_id","refutes_supports":"float","date_created":"datetime","secondary_color":"#96BD3F","shape":"sphere","type":"Belief","decay_curve":"?string","confidence_score":"float","user_reference":"string","description_key":"user_reference","title_key":"user_reference","update_trigger":"?string","graph_url":"?string","domain":"KnowledgeArtifact","type_description":"A user epistemic position on a referenced graph node, with confidence scoring, support/refutation polarity, and configurable decay or trigger-based update logic","ref_id":"980dd66a-e226-4bab-9768-0c64be03d14c","referenced_id":"string"},{"cause_detail":"string","icon":"ExclamationCircleIcon","primary_color":"#6D4C41","index":["cause_type","task_slug_prefix","dedup_hash"],"first_seen_run_id":"?string","cause_summary":"string","parent":"Legal","suggested_fix":"?string","node_key":"benchmarkfailurecause-dedup_hash","secondary_color":"#D7CCC8","issue_group":"?string","shape":"sphere","type":"BenchmarkFailureCause","cause_type":"string","dedup_hash":"string","log_evidence":"?string","description_key":"cause_detail","title_key":"cause_summary","last_seen_run_id":"?string","domain":"Legal","type_description":"A classified root cause of failure for one or more Harvey LAB benchmark criteria. One node per unique cause_type + task_slug_prefix + issue_group combination, deduped via SHA-256 hash. Linked to EvalTriggerOutput nodes of all runs that exhibited this cause via ATTRIBUTED_TO, and to the EvalRequirement it affects via AFFECTS. occurrence_count is incremented on each association. Cause types: CALCULATION_ERROR, THRESHOLD_NOT_APPLIED, MISSING_DOWNSTREAM_CONCLUSION, JURISDICTION_RULE_MISSING, MULTI_JURISDICTION_INCOMPLETE, SCOPE_GAP_NOT_IDENTIFIED, CROSS_DOCUMENT_CONFLICT_MISSED, HEADCOUNT_ATTRIBUTION_ERROR, MISSING_STATUTORY_CITATION, PRIVILEGE_MISCLASSIFICATION, AGENT_REASONING_GAP, RETRIEVAL_FAILURE.","ref_id":"4edc2855-3f17-4d1a-bb36-207c07a9b0e5","occurrence_count":"?int","task_slug_prefix":"?string"},{"one_sentence_summary":"?string","phase_priority":"?int","org_uuid":"?string","bounty_type":"?string","node_key":"bounty-bounty_id","show":"?boolean","secondary_color":"#54AC52","estimated_completion_date":"?string","type":"Bounty","bounty_id":"int","estimated_session_length":"?string","title":"?string","deliverables":"?string","paid":"?boolean","created":"?int","updated":"?string","description":"?string","type_description":"A reward offered for completing a task or challenge","domain":"Entity","phase_uuid":"?string","github_description":"?boolean","bounty_expires":"?string","completed":"?boolean","primary_color":"#22362A","workspace_uuid":"?string","index":["description","title"],"ticket_url":"?string","tribe":"?string","assigned_hours":"?int","commitment_fee":"?int","parent":"Thing","assignee":"?string","shape":"sphere","wanted_type":"?string","price":"?int","description_key":"description","title_key":"title","owner_id":"?string","ref_id":"704f1683-7a0d-4792-b772-7c317f4514cb","coding_languages":"?string"},{"icon":"EpisodeIcon","primary_color":"#222B48","index":["episode_title","description"],"status":"?string","episode_title":"?string","media_url":"?string","parent":"Show","node_key":"call-source_link","show_title":"?string","secondary_color":"#5E84F8","shape":"sphere","date":"?datetime","type":"Call","pubkey":"?string","description_key":"description","title_key":"episode_title","image_url":"?string","description":"?string","project_id":"?string","domain":"Content","type_description":"A single installment in a series of related productions e.g Show","ref_id":"1d02e951-e415-4e3f-beb4-b777778501c1","source_link":"string"},{"icon":"ExclamationCircleIcon","primary_color":"#362429","index":["cause_type","severity"],"parent":"Thing","node_key":"cause-id","severity":"?string","secondary_color":"#D25353","shape":"sphere","type":"Cause","cause_type":"?string","id":"string","title":"string","description_key":"description","title_key":"title","resolved":"?boolean","description":"?string","domain":"CodeArtifact","type_description":"A structured root cause attributed to one or more CriterionResult failures. Supports hierarchical grouping via CAUSE_CHILD_OF. cause_type values: prompt_gap | reasoning_error | missing_context | format_violation. severity values: critical | high | medium | low.","ref_id":"a5d69c3a-5208-4cae-8063-5a80493503d0","created_at":"?datetime"},{"transcript":"?string","index":["name","description"],"parent":"Content","node_key":"chapter-name-timestamp-source_link","type":"Chapter","is_favorite":"?boolean","timestamp":"string","description_key":"description","title_key":"name","paid_properties":["transcript"],"is_ad":"?boolean","description":"?string","name":"string","domain":"Content","ref_id":"babd19b8-9d80-46ed-b4b6-0d8472b5fa5a","source_link":"string"},{"retired_at":"?datetime","node_key":"check-id","secondary_color":"#4FA7D9","type":"Check","step_type":"?string","publisher":"?string","id":"string","step_config":"?string","description":"?string","name":"string","type_description":"One instrument that can test one Claim (migration 125) — the node the epistemic layer first held off on. A Claim is the statement, a Check is a way of testing it, Evidence is what one test observed: Check -[TESTS]-> Claim, and the Evidence a check yields points back via Evidence -[PRODUCED_BY]-> Check. A node rather than attributes on the Claim because one claim can have SEVERAL checks (a free script on every run, a model judge on change, a person once per version), each with its own policy, cost and evidence stream, and a check can be replaced without touching the statement. step_type names the executable instrument in the producer's registry and step_config is its JSON config; step_type ABSENT means an EXTERNAL check — answered by a person or an outside system, usually by filling a planned Evidence slot (Evidence.evidence_status) — and description then says what to look at and why code cannot. run_when: run | publish — whether the check fires on an execution of the subject or when a new version of it is published. policy: always | on_change | sample | manual — how often a run check fires; freshness_days bounds on_change (re-run when the latest evidence is older, catching environment drift) and sample_rate is the fraction of runs for sample. publisher records who wrote the check (ai, a person, a seeder). A check is IMMUTABLE once it has evidence: editing it creates a new Check that SUPERSEDES the old one and sets the old node's retired_at — a changed instrument has measured nothing yet, so a retired check's evidence stays in the graph but no longer counts. Active = retired_at unset (there is no status attribute: `status` is a reserved name). run_when and policy are documented contracts, not DB-enforced enums (the Claim.verdict / Evidence.evidence_status convention); readers must treat any other value as unknown/unspecified.","domain":"Epistemic","created_at":"datetime","run_when":"?string","icon":"TaskIcon","primary_color":"#1D3140","index":["name","description"],"sample_rate":"?float","parent":"Thing","shape":"sphere","description_key":"description","title_key":"name","ref_id":"c4902dcf-4a44-4d38-9389-361d43d24bef","policy":"?string","freshness_days":"?int"},{"answer_volatility":"?string","claim_text":"string","valid_from":"?datetime","node_key":"claim-id","type":"Claim","id":"string","polarity":"?string","name":"string","type_description":"An assertion whose truth can be evaluated — the unit of knowing in the epistemic layer (Claim / Evidence, migration 119). Extracted claims record a speaker's statement; the epistemic attributes score ANY claim by its evidence. answer_volatility uses the temporal-layer classes (STATIC | STABLE | EVOLVING | VOLATILE | INSTANTANEOUS): STATIC is the static-vs-dynamic dimension's static pole, everything else is dynamic (named answer_volatility because bare `volatility` is a schema-level core property the read path swallows out of attributes). derivation: atomic | calculated — calculated claims point at their inputs via DERIVED_FROM. polarity: affirms | negates — whether the claim asserts or denies its proposition. Bitemporal fields split ontic from epistemic time: valid_from/valid_to is when the claim is true IN THE WORLD; belief_valid_from/belief_valid_to is when the graph believed it (closed by supersession or retraction). verdict: known | likely | contested | unknown | stale, with confidence_score in [0.0, 1.0] as of assessed_at. Evidence hangs off EVIDENCED_BY {strength: -1.0 disproves .. +1.0 proves}; compound claims decompose via PARENT_OF; claim-to-claim relations use the existing SUPPORTS / CONTRADICTS / SUPERSEDES edges.","triplicate_object":"?string","verdict":"?string","domain":"Epistemic","belief_valid_from":"?datetime","belief_valid_to":"?datetime","valid_to":"?datetime","speaker_name":"?string","assessed_at":"?datetime","index":["name","claim_text","speaker_name"],"triplicate_subject":"?string","parent":"Thing","triplicate":"?string","confidence_score":"?float","source_role":"?string","derivation":"?string","triplicate_predicate":"?string","description_key":"claim_text","title_key":"name","paid_properties":[],"ref_id":"67fcc508-e296-4411-896b-6f431e8c0c9d"},{"icon":"NodesIcon","body":"?string","index":["name","body"],"parent":"CodeArtifact","node_key":"class-name-file","shape":"sphere","type":"Class","description_key":"body","title_key":"name","start":"?int","file":"string","name":"string","domain":"CodeArtifact","type_description":"A class definition in source code, representing an object-oriented structure with attributes and methods.","ref_id":"1738aaf1-b30a-4f7d-9a76-4cc88c4c367f","end":"?int"},{"icon":"TagIcon","primary_color":"#6D4C41","index":["name"],"parent":"Thing","node_key":"clausetype-name","secondary_color":"#D7CCC8","shape":"sphere","type":"ClauseType","description_key":"description","title_key":"name","description":"?string","name":"string","domain":"Legal","type_description":"A classification label for a ContractClause (e.g. indemnification, termination, confidentiality).","ref_id":"924bb19a-b6f4-4aa2-92a6-832f9dc96a46"},{"transcript":"?string","link":"?string","node_key":"clip-episode_title-timestamp","secondary_color":"#4FA7D9","sentiment_score":"?float","date":"?datetime","type":"Clip","timestamp":"string","image_url":"?string","description":"?string","type_description":"A short audio or video segment extracted from a longer episode","domain":"Content","source_link":"?string","pub_key":"?string","icon":"AudioIcon","primary_color":"#1D3140","text":"?string","index":["episode_title","text","description"],"episode_title":"string","media_url":"string","parent":"Episode","show_title":"?string","shape":"sphere","num_boost":"?int","description_key":"description","title_key":"episode_title","english_translation":"?string","ref_id":"788d5a44-1a5e-4533-8de4-ceaf887ede41","boost":"?int","language":"?string"},{"icon":"ConstructionIcon","index":"name","domain":"CodeArtifact","type_description":"An abstract parent for all source code constructs within a repository.","ref_id":"b9d1bbef-bf58-4877-9181-9e058af71078","parent":"Thing","type":"CodeArtifact"},{"summary":"?string","icon":"ChatAltIcon","primary_color":"#6D4C41","index":["regulation_name","comment_deadline","decision"],"link":"?string","parent":"Legal","node_key":"commentperiod-regulation_name-comment_deadline","regulation_name":"string","secondary_color":"#D7CCC8","shape":"sphere","type":"CommentPeriod","decision":"string","description_key":"decision","title_key":"regulation_name","owner":"?string","domain":"Legal","type_description":"A public comment period for a proposed regulation or rule, with tracking of the organization's response.","ref_id":"9e9c1a31-cf54-4162-8a63-4bff2bdeb952","filed_at":"?datetime","comment_deadline":"datetime","rationale":"?string","notes":"?string"},{"summary":"?string","icon":"NodesIcon","index":["name","message","summary"],"committed_at":"?string","sha":"?string","parent":"Repository","node_key":"commit-sha","shape":"sphere","type":"Commit","url":"?string","namespace":"?string","message":"?string","author":"?string","description_key":"summary","title_key":"name","name":"string","domain":"CodeArtifact","type_description":"An individual git commit, including its message, author, and a summary of the change.","ref_id":"6b564e70-e0c7-4111-8fc1-47a56b689153","source_link":"?string"},{"icon":"NodesIcon","index":"name","count":"?int","parent":"Repository","node_key":"commits-name","shape":"sphere","type":"Commits","namespace":"?string","description_key":"name","title_key":"name","name":"string","domain":"CodeArtifact","type_description":"A collection of individual commit nodes belonging to a repository.","ref_id":"dfe13962-8ead-400c-9145-444acd917242"},{"icon":"CalculatorIcon","primary_color":"#6D4C41","result":"?float","index":["label","matter_slug","discrepancy_flag"],"computed_by":"?string","parent":"Legal","node_key":"computedfigure-label","label":"string","secondary_color":"#D7CCC8","shape":"sphere","type":"ComputedFigure","discrepancy_note":"?string","discrepancy_flag":"boolean","description_key":"formula","title_key":"label","inputs":"?string","verified":"boolean","domain":"Legal","type_description":"An independently-derived numerical calculation with its formula, inputs (Excerpt refs), and result — used by the computation verifier agent to cross-check figures extracted by research agents and flag discrepancies.","ref_id":"434f593f-c719-4010-a5e5-4537e1abce6f","result_string":"?string","statutory_basis":"?string","formula":"string"},{"icon":"NodesIcon","index":["name","description","docs"],"docs":"?string","pr_numbers":"?string","parent":"Thing","node_key":"concept-name","commit_shas":"?string","shape":"sphere","type":"Concept","id":"?string","description_key":"description","title_key":"name","repo":"?string","description":"?string","file":"?string","name":"string","documentation":"?string","domain":"General","type_description":"A reusable unit of knowledge or capability — a codebase capability, a legal methodology, a domain practice. Cross-domain by design, which is why it is homed in General rather than CodeArtifact. BODY FIELD — `docs` is the canonical long-form body (migration 106). stakgraph's lab/concepts pipeline writes `docs` directly over a bolt session (bypassing this API and its schema validation) and its nodeToConcept() reads only `docs`, so `docs` is the field that actually carries nearly every live Concept's content. DEPRECATED — `documentation` is the pre-106 body field. It is still declared so existing API writers do not hard-fail, but it is NO LONGER INDEXED: content written there is invisible to both the composite Data_Bank fulltext index and text_embeddings, and invisible to stakgraph. Write `docs`, not `documentation`. Migration 106 backfilled existing `documentation` bodies into `docs`; removing the attribute outright requires a coordinated writer migration first (a removal would 400 any payload still sending it).","ref_id":"a530f22b-b0aa-46d6-bf6c-3e7e1afa1111","created_at":"?string","last_updated":"?string"},{"conflicts":"?string","icon":"ShieldCheckIcon","primary_color":"#6D4C41","result":"string","index":["matter_slug","checked_date","result"],"parent":"Legal","node_key":"conflictcheck-matter_slug-checked_date","checked_by":"string","secondary_color":"#D7CCC8","shape":"sphere","type":"ConflictCheck","description_key":"result","title_key":"matter_slug","domain":"Legal","type_description":"A conflict-of-interest check performed before or during a legal matter.","ref_id":"6e875c60-3757-4011-8b05-a4f749f97dfe","checked_date":"datetime","notes":"?string","matter_slug":"string"},{"icon":"InterestsIcon","index":"name","domain":"Content","type_description":"Any form of media, including text, images, or videos","ref_id":"60883d1a-6b68-44d5-b0c5-d06bbf9c84f5","parent":"Thing","type":"Content"},{"summary":"?string","icon":"ClipboardIcon","primary_color":"#6D4C41","index":["clause_type","risk_level"],"parent":"Thing","recommendation":"?string","node_key":"contractclause-id","secondary_color":"#D7CCC8","shape":"sphere","confidence":"?float","type":"ContractClause","clause_type":"?string","id":"string","description_key":"risk_level","title_key":"clause_type","risk_level":"?string","priority":"?string","domain":"Legal","type_description":"A discrete clause or provision within a legal agreement, categorised by type and risk level.","ref_id":"8e304fe3-8cd5-43ba-bccf-3aa47af2652e"},{"location":"?string","node_key":"contributor-username","type":"Contributor","namespace":"?string","followers":"?int","following":"?int","username":"string","image_url":"?string","description":"?string","public_repos":"?int","name":"string","type_description":"A person who contributes to a project or repository, including authors and maintainers.","domain":"Entity","icon":"PersonIcon","index":["username","description","bio"],"parent":"Person","shape":"sphere","account_created_at":"?datetime","description_key":"description","title_key":"username","bio":"?string","email":"?string","company":"?string","ref_id":"858f9367-a600-486e-99e4-120a55cc90cf","role_name":"?string"},{"icon":"CorporationIcon","primary_color":"#222B48","index":["name","description"],"parent":"Organization","node_key":"corporation-name","secondary_color":"#5E84F8","shape":"sphere","type":"Corporation","description_key":"description","title_key":"name","image_url":"?string","description":"?string","name":"string","domain":"Entity","type_description":"A legally recognized business entity or company","ref_id":"ccc1a080-92f0-473b-82c1-331cd670346e"},{"icon":"PlaceIcon","primary_color":"#2A2545","index":["name","iso_code"],"iso_code":"?string","parent":"Thing","node_key":"country-name","secondary_color":"#9368FB","shape":"sphere","type":"Country","description_key":"name","title_key":"name","image_url":"?string","name":"string","domain":"Entity","type_description":"A sovereign nation or country, used e.g. as a place of incorporation or governing-law jurisdiction","ref_id":"3a892221-8210-4031-ad89-182677d3c035"},{"icon":"CheckCircleIcon","primary_color":"#36292D","index":["criterion_id","verdict"],"flagged":"?boolean","parent":"Thing","node_key":"criterionresult-id","secondary_color":"#A96755","shape":"sphere","type":"CriterionResult","contested":"?boolean","document_excerpt":"?string","id":"string","llm_flag_reason":"?string","title":"?string","reasoning":"?string","description_key":"reasoning","title_key":"title","verdict":"?string","domain":"CodeArtifact","type_description":"A generic per-criterion, per-run verdict node — the domain-neutral form of RubricCriterion. Records a judge's verdict, reasoning, and flag metadata for a single criterion within one EvalTriggerOutput scored attempt. Used by both Hive prompt/agent evals and the Harvey benchmark unified model.","ref_id":"13959bfa-6d96-4106-b490-34316bb311e5","criterion_id":"string","agent_thinking_excerpt":"?string"},{"icon":"ClipboardListIcon","primary_color":"#6D4C41","index":["request_id","status","category"],"status":"?string","request_text":"?string","parent":"Legal","node_key":"ddrequestitem-request_id","secondary_color":"#D7CCC8","shape":"sphere","type":"DDRequestItem","category":"?string","description_key":"category","request_id":"string","title_key":"request_text","fulfilled_date":"?datetime","responding_party":"?string","domain":"Legal","type_description":"A discrete line item within a due diligence request list, tracking the request, its status, and the responding party.","ref_id":"ca6bd1b2-4177-4a09-bc1f-a3841383c5f6","due_date":"?datetime","notes":"?string"},{"exemptions":"?string","deadline_statutory":"?datetime","node_key":"dsarrequest-id","secondary_color":"#D7CCC8","type":"DSARRequest","date_responded":"?datetime","what_produced":"?string","id":"string","deadline_internal":"?datetime","type_description":"A Data Subject Access Request (DSAR) submitted under a privacy regulation.","domain":"Legal","regime":"?string","icon":"IdentificationIcon","primary_color":"#6D4C41","index":["id","right_invoked","regime","status"],"status":"?string","date_received":"?datetime","parent":"Thing","date_identity_verified":"?datetime","shape":"sphere","identity_verified":"?boolean","description_key":"right_invoked","title_key":"id","handled_by":"?string","ref_id":"adffcca1-968d-46c3-b7aa-066899b9c5dd","right_invoked":"?string"},{"field_name":"string","observed_value":"?string","icon":"ExclamationIcon","primary_color":"#6D4C41","index":["anomaly_type","source_doc_ref","resolved"],"parent":"Legal","node_key":"dataanomalyrecord-source_doc_ref-field_name","severity":"?string","secondary_color":"#D7CCC8","shape":"sphere","type":"DataAnomalyRecord","source_doc_ref":"string","description_key":"anomaly_type","title_key":"field_name","detected_at":"datetime","resolved":"boolean","expected_value_or_rule":"?string","domain":"Legal","type_description":"A data quality anomaly detected at document ingest time — impossible dates, missing required fields, stale denominators, or cross-document value mismatches. Also covers one-sided findings (observed_value and expected_value_or_rule are both optional): unexpected-location facts (anomaly_type='unexpected_location' — a substantive fact appearing in a document where it is not expected, or a controlling primary record differing from the citing document; observed_value=actual location, expected_value_or_rule=expected location), absent clause types (anomaly_type='missing_clause_type'), figures asserted without derivation (anomaly_type='asserted_without_derivation'), and intra-section self-contradictions (anomaly_type='self_contradiction'). Anchor to the relevant node via FLAGS (TimelineEntry, ContractClause, Figure, Document, Excerpt, or a clause-type Concept for absence findings). Seeded before research rounds begin so all agents are aware of adversarial data traps.","ref_id":"921bc872-85f1-45aa-98b9-4ded0b027362","anomaly_type":"string"},{"icon":"NodesIcon","body":"?string","index":["name","body"],"parent":"CodeArtifact","node_key":"datamodel-name-file","shape":"sphere","type":"Datamodel","description_key":"body","title_key":"name","start":"?int","file":"string","name":"string","domain":"CodeArtifact","type_description":"A structured representation of data within a system, typically defining entities, relationships, attribute types, and corresponding SQL table definitions.","ref_id":"a76bdcfb-652e-4568-ab51-949e97f4543d","end":"?int"},{"icon":"CalendarIcon","primary_color":"#6D4C41","index":["deadline_type","matter_slug","due_date","status"],"status":"?string","parent":"Thing","node_key":"deadline-deadline_type-matter_slug-due_date","secondary_color":"#D7CCC8","shape":"sphere","type":"Deadline","description_key":"due_date","title_key":"deadline_type","deadline_type":"string","description":"?string","warning_days":"?int","owner":"?string","domain":"Legal","type_description":"A legal or regulatory deadline associated with a matter, agreement, IP asset, or AI system.","ref_id":"45edc8b5-a569-40f2-9470-152940e688e3","due_date":"string","matter_slug":"string"},{"icon":"BookOpenIcon","primary_color":"#6D4C41","index":["term","matter_slug","source_ref","scope"],"scope":"?string","definition_text":"string","parent":"Legal","node_key":"definedterm-matter_slug-source_ref-term","source_ref":"string","secondary_color":"#D7CCC8","shape":"sphere","first_used_section":"?string","type":"DefinedTerm","description_key":"definition_text","title_key":"term","term":"string","domain":"Legal","type_description":"A term as defined by one specific document — the per-document definition record, distinct from Lingo (the global vocabulary entry). One node per (matter_slug, source_ref, term), so two documents defining the same term differently yield two nodes; definitional divergence is asserted via DefinedTerm-[CONFLICTS_WITH]->DefinedTerm, and cross-document borrowing (a document using a term it does not itself define) via DefinedTerm-[USED_IN]->the borrowing document.","ref_id":"b7017069-4ffa-4851-847b-115aead96392","matter_slug":"string"},{"icon":"ClipboardCheckIcon","primary_color":"#6D4C41","index":["deliverable_type"],"parent":"Legal","node_key":"deliverableschema-deliverable_type","required_sections":"?string","deliverable_type":"string","secondary_color":"#D7CCC8","shape":"sphere","type":"DeliverableSchema","required_tabs":"?string","description_key":"deliverable_type","title_key":"deliverable_type","placeholder_patterns":"?string","domain":"Legal","type_description":"Specifies the required structure of a task output deliverable — tabs, columns, required sections, and placeholder patterns. Used by the deliverable schema enforcer to validate synthesized outputs before scoring.","required_columns":"?string","ref_id":"f66c86cd-a615-42d7-afad-da26efa1a355","formatting_rules":"?string"},{"icon":"ExclamationCircleIcon","primary_color":"#6D4C41","index":["category","severity","status"],"status":"string","parent":"Legal","node_key":"diligenceissue-category-description","severity":"string","secondary_color":"#D7CCC8","shape":"sphere","resolution":"?string","type":"DiligenceIssue","category":"string","description_key":"category","title_key":"description","description":"string","domain":"Legal","type_description":"A due-diligence issue identified during a deal or transaction review.","ref_id":"bcce77bc-1cc4-409f-bb93-64bd977da832","source_doc":"?string","materiality":"?string"},{"body":"?string","index":["name","description"],"parent":"Repository","node_key":"directory-name-file","shape":"sphere","type":"Directory","description_key":"description","title_key":"name","start":"?int","description":"?string","file":"string","name":"string","domain":"CodeArtifact","type_description":"A folder within a repository that organizes files and subdirectories.","ref_id":"350dee1e-e4be-4366-b878-2dcda901d3f1","source_link":"?string","end":"?int"},{"icon":"BookOpenIcon","primary_color":"#6D4C41","practice_area":"?string","index":["name","jurisdiction_ref","authority_level","practice_area"],"superseded_by":"?string","parent":"Legal","node_key":"doctrine-jurisdiction_ref-name","secondary_color":"#D7CCC8","shape":"sphere","type":"Doctrine","authority_level":"?string","jurisdiction_ref":"string","description_key":"rule_text","title_key":"name","source_citation":"?string","rule_text":"string","name":"string","domain":"Legal","type_description":"A standing legal rule or doctrine scoped to a jurisdiction — e.g. Third Circuit dominant-purpose test, post-TCJA 80% NOL cap. Distinct from LegalArgument (per-matter finding) and LegalDocument (case citation). Allows agents to retrieve jurisdiction-specific legal rules directly from the graph.","ref_id":"56f872ad-b980-4aea-8cac-79cd0ed583cc","effective_date":"?string"},{"summary":"?string","icon":"DocumentIcon","primary_color":"#302342","index":["source_link","title"],"status":"?string","parent":"Content","node_key":"document-source_link","secondary_color":"#C25AF3","shape":"sphere","type":"Document","pubkey":"?string","author":"?string","title":"?string","description_key":"summary","title_key":"title","domain":"Content","type_description":"A written, printed, or digital record of information","ref_id":"cde66016-4d46-4d55-b5b6-285ddcc5cfff","source_link":"string","content_type":"?string"},{"icon":"DocumentSearchIcon","primary_color":"#6D4C41","index":["document_subtype"],"parent":"Legal","example_values":"?string","node_key":"documenttypetemplate-document_subtype","secondary_color":"#D7CCC8","shape":"sphere","type":"DocumentTypeTemplate","description_key":"document_subtype","title_key":"document_subtype","mandatory_fields":"string","domain":"Legal","type_description":"Maps a legal document subtype to its mandatory quantitative fields — used by specificsSweep to generate targeted extraction queries per document type at task start, ensuring policy parameters (e.g. RWI limit + retention) are always seeded as findings even if no research agent explicitly queries for them.","ref_id":"631316a5-10e8-4ed8-83e3-75f7b91647df","document_subtype":"string"},{"body":"?string","index":["name","body"],"parent":"Test","node_key":"e2etest-name-file","type":"E2etest","description_key":"body","title_key":"name","start":"?int","file":"string","name":"string","domain":"CodeArtifact","type_description":"An end-to-end test that validates the entire system functionality by simulating real-world interactions.","ref_id":"15fed8c3-c844-407d-bd3a-e0e9049d2c5f","end":"?int"},{"icon":"MailIcon","primary_color":"#1A3A52","index":["message_id","subject","thread_id"],"subject":"string","parent":"Message","message_id":"string","node_key":"email-message_id","secondary_color":"#5BA4CF","shape":"sphere","type":"Email","in_reply_to":"?string","references":"?string","description_key":"message_id","title_key":"subject","domain":"Content","type_description":"An email message in the knowledge graph.","ref_id":"56eacec5-cd7c-4cd7-a581-4a5c4a6c75f3","thread_id":"?string"},{"body":"?string","subject":"string","node_key":"emailinvite-message_id","secondary_color":"#5BA4CF","conference_platform":"?string","type":"EmailInvite","timezone":"?string","type_description":"A calendar invitation delivered via email (iCalendar / .ics attachment).","domain":"Content","conference_url":"?string","icon":"CalendarIcon","primary_color":"#1A3A52","index":["message_id","subject","start_datetime"],"location_type":"?string","end_datetime":"?datetime","status":"?string","organizer":"?string","parent":"Message","message_id":"string","shape":"sphere","start_datetime":"?datetime","description_key":"start_datetime","recurrence":"?string","title_key":"subject","ref_id":"4d3f887d-6268-4683-8811-b9b2ca5cbb9f"},{"summary":"?string","body":"?string","node_key":"endpoint-name-file-verb","type":"Endpoint","namespace":"?string","verb":"string","description":"?string","file":"string","name":"string","type_description":"A defined entry point for accessing functionality within an application or service, typically through an API.","domain":"CodeArtifact","source_link":"?string","end":"?int","icon":"ConstructionIcon","text":"?string","index":["name","summary","description"],"hash":"?string","parent":"CodeArtifact","shape":"sphere","description_key":"summary","title_key":"name","start":"?int","ref_id":"87dcc921-01cb-4552-a710-ab06dc994260","method":"?string","handler":"?string"},{"icon":"NodesIcon","primary_color":"#1B3134","index":"entity","trusted":"?boolean","entity":"string","parent":"Thing","node_key":"entity-entity","secondary_color":"#21B38A","shape":"sphere","type":"Entity","entity_lower":"?string","description_key":"entity","title_key":"entity","domain":"Entity","type_description":"A general entity that can represent any identified concept or object","ref_id":"5b8aea7c-1ffd-433d-989f-6be32a0d6788","metaphone3":"?string"},{"summary":"?string","transcript":"?string","node_key":"episode-source_link","secondary_color":"#5E84F8","date":"?datetime","type":"Episode","image_url":"?string","description":"?string","type_description":"A single installment in a series of related productions e.g Show","domain":"Content","source_link":"string","icon":"EpisodeIcon","primary_color":"#222B48","index":["episode_title","description","summary"],"status":"?string","episode_title":"?string","media_url":"?string","parent":"Show","show_title":"?string","shape":"sphere","pubkey":"?string","duration":"?string","description_key":"description","title_key":"episode_title","paid_properties":["media_url","transcript"],"project_id":"?string","ref_id":"79c0a059-e819-42ec-bc69-d274076e1e62"},{"repo_key":"?string","icon":"ConstructionIcon","primary_color":"#2A2545","exceptionType":"?string","index":["title","exceptionType","fingerprint","status"],"first_seen_at":"?string","status":"?string","workspace_id":"?string","parent":"CodeArtifact","node_key":"errorissue-fingerprint","secondary_color":"#C25AF3","shape":"sphere","type":"ErrorIssue","repository_id":"?string","occurrenceCount":"?int","fingerprint":"string","title":"?string","description_key":"exceptionType","title_key":"title","last_seen_at":"?string","domain":"CodeArtifact","type_description":"A tracked error or exception in a codebase, identified by a unique fingerprint and associated with files and functions in the call stack.","ref_id":"afd6193e-e613-4ea0-a7cc-3cfa3d681145"},{"icon":"ExclamationCircleIcon","primary_color":"#6D4C41","index":["matter_slug","escalation_type","status"],"status":"string","trigger_finding_ref":"string","parent":"Legal","node_key":"escalationrecord-matter_slug-trigger_finding_ref","secondary_color":"#D7CCC8","shape":"sphere","type":"EscalationRecord","rpc_rule_ref":"?string","escalation_type":"string","description_key":"matter_slug","title_key":"escalation_type","resolved_by":"?string","domain":"Legal","type_description":"A structured record of a legal ethics or crime-fraud escalation triggered by a finding — capturing the escalation type, recommended actions (senior counsel / in camera / ethics consult), and the RPC rule that mandates it.","ref_id":"02e1b372-1816-4381-b54e-c5c1f927d640","recommended_actions":"?string","notes":"?string","matter_slug":"string"},{"icon":"StepIcon","primary_color":"#1B3134","index":["name","description"],"positive_cases":"?list","negative_cases":"?list","parent":"Thing","node_key":"evalrequirement-id","secondary_color":"#21B38A","shape":"sphere","type":"EvalRequirement","contested":"?boolean","id":"string","deliverables":"?list","updated_at":"?datetime","description_key":"description","title_key":"name","prompt_snippet":"?string","description":"?string","name":"?string","domain":"CodeArtifact","type_description":"A success criterion (test case) with positive and negative examples, captured from a real agent interaction.","ref_id":"3c1385ec-7602-4a68-9605-780ab678a97a","created_at":"?datetime"},{"icon":"TaskIcon","primary_color":"#1D3140","index":["name","description"],"parent":"Thing","node_key":"evalset-id","secondary_color":"#4FA7D9","shape":"sphere","type":"EvalSet","id":"string","updated_at":"?datetime","description_key":"description","title_key":"name","description":"?string","project_id":"?int","name":"?string","domain":"CodeArtifact","type_description":"A dataset (suite) grouping related test cases (EvalRequirements) for validating agent behaviour.","ref_id":"3e5e8c3d-0819-4659-8cff-ff7ad6f9a7ee","created_at":"?datetime","recursion":"?boolean"},{"body":"?string","start_point":"?string","workflow_input":"?string","prompts":"?list","node_key":"evaltrigger-id","secondary_color":"#BA9D39","type":"EvalTrigger","id":"string","prompt_id":"?string","workflow_id":"?string","type_description":"A captured, replayable input configuration — the source event that gave rise to an EvalRequirement.","domain":"CodeArtifact","icon":"GeneratedStepIcon","primary_color":"#353124","positive_cases":"?list","index":["agent","environment","change_type"],"end_point":"?string","negative_cases":"?list","workflow_version_id":"?string","parent":"Thing","shape":"sphere","change_type":"?string","agent":"?string","endpoint_url":"?string","prompt_version_id":"?string","run_count":"?int","description_key":"change_type","environment":"?string","title_key":"agent","source":"?string","paid_properties":["workflow_input"],"project_id":"?string","ref_id":"0b3c193c-6b9a-4020-96eb-c798b645cccd"},{"result":"string","judge_notes":"?string","judge_model":"?string","score":"?float","node_key":"evaltriggeroutput-id","secondary_color":"#E09242","type":"EvalTriggerOutput","id":"string","n_passed":"?int","n_total":"?int","type_description":"The result of a single evaluation run attempt: score, verdict, and judge notes. report_url, when present, is the URL of the run report bundle for this scored attempt — an https:// Stakwork-hosted bundle URL; optional and null on historical nodes. Consumers must treat it as untrusted input and must validate scheme and host before fetching (SSRF caution). The concept-analysis consumer must read report_url via an authenticated/proxied route, as it is a paid property and is silently stripped to null for unauthenticated readers.","verdict":"?string","domain":"CodeArtifact","max_score":"?float","icon":"StepIcon","primary_color":"#392828","index":"evaltriggeroutput-id","attempt_number":"?int","parent":"Thing","shape":"sphere","endpoint_url":"?string","description_key":"judge_notes","title_key":"result","report_url":"?string","paid_properties":["report_url"],"ref_id":"1826ff32-dfe6-409e-857d-187142aff915"},{"icon":"EventIcon","primary_color":"#38243C","index":["name","description"],"parent":"Thing","node_key":"event-name","secondary_color":"#F468D4","shape":"sphere","type":"Event","description_key":"description","title_key":"name","image_url":"?string","description":"?string","name":"string","domain":"Entity","type_description":"A thing that happens or takes place, especially one of importance","ref_id":"431687d4-a420-46fb-93b2-0acc412559e1"},{"icon":"DocumentIcon","primary_color":"#222B48","index":["name","description","content"],"parent":"Thing","node_key":"evidence-id","secondary_color":"#5E84F8","shape":"sphere","type":"Evidence","evidence_mode":"?string","content":"?string","observed_at":"?datetime","id":"string","confidence_score":"?float","description_key":"description","title_key":"name","description":"?string","name":"string","domain":"Epistemic","type_description":"One captured datum backing a Claim — a quote, reading, attestation, or record, captured AS OBSERVED (the Figure discipline from the Legal domain: capture first, derive later). content holds the observed value, statement, or excerpt — ON THE NODE, where it is indexed and searchable; where-in-the-source locators (timestamps, message id, page, url) ride the HAS_SOURCE edge instead. observed_at is when the datum was true in the world — the anchor for freshness against the claim's answer_volatility. evidence_mode is the asserted-vs-observed dimension: observed | asserted — observed means we or an instrument measured it, asserted means someone said it. Provenance: this Evidence -[HAS_SOURCE {authority_level, locators}]-> the canonical entity it came from (Person, Organization, Document, ...); a Claim points here via EVIDENCED_BY {strength}. Independent corroboration is counted over DISTINCT source entities, not Evidence rows. confidence_score is this datum's own trustworthiness in [0.0, 1.0], consistent with Belief and temporal-edge confidence. evidence_status is the collection lifecycle: planned means the slot has been named via name/description but content is still empty; collected means content has been populated. The allowed set is exactly {'planned', 'collected'} — a documented contract, not a DB-enforced enum (same convention as Claim.verdict and ProposedFix.eval_status); readers must treat any other value as unknown/unspecified.","ref_id":"d6ad0728-1710-4e49-a237-c684bc8ca70a","evidence_status":"?string"},{"icon":"QuoteIcon","primary_color":"#6D4C41","index":["text"],"text":"string","is_verbatim":"?boolean","page_number":"?int","parent":"Thing","node_key":"excerpt-text","secondary_color":"#D7CCC8","shape":"sphere","confidence":"?float","type":"Excerpt","bbox_y1":"?float","section":"?string","bbox_y0":"?float","bbox_x1":"?float","bbox_x0":"?float","description_key":"text","title_key":"text","domain":"Legal","type_description":"A verbatim text excerpt from a legal document. May carry a dual embedding: jarvis 384-dim text_embeddings and a graphrag-contract-review 1536-dim embedding property.","ref_id":"4a8dda06-3855-4be4-83c6-1b0de2c222ef"},{"icon":"NodesIcon","index":["name","description","documentation"],"pr_numbers":"?string","parent":"CodeArtifact","node_key":"feature-name","commit_shas":"?string","shape":"sphere","type":"Feature","description_key":"description","title_key":"name","repo":"?string","description":"?string","file":"?string","name":"string","documentation":"?string","domain":"CodeArtifact","type_description":"A specific capability or functionality within the application or codebase.","ref_id":"0fb370dc-4a4b-4caf-b3d7-a56149cdc366","created_at":"?string","last_updated":"?string"},{"page_number":"?int","node_key":"figure-matter_slug-source_ref-locator","secondary_color":"#D7CCC8","source_ref":"string","type":"Figure","currency":"?string","value_as_stated":"string","value_normalized":"?float","cell_ref":"?string","sheet_name":"?string","type_description":"The primary, as-stated datum captured verbatim from one specific source location — PRE-COMPUTATION and PRE-DERIVATION. NOT a ComputedFigure (a derived calculation) and NOT a FormulaComponent (a named formula input). ComputedFigure and FormulaComponent should link here via DERIVED_FROM edges to trace every calculated result back to its primary source.\n\nSOURCE_REF / LOCATOR GRAMMAR (agents MUST emit this exact form — punctuation and case are stripped before keying, so an inconsistent rendering creates a duplicate node for the same cell):\n source_ref = workbook or document filename exactly as ingested (e.g. 'RateCard_2024.xlsx').\n locator = ! for spreadsheets (e.g. 'RateCard!C2') OR p\\u00a7
for prose (e.g. 'p12\\u00a74.2').\n\nRE-INGEST SEMANTICS: re-ingest updates the node only when the caller passes reprocess=True (per create_or_merge_node in api/helper/schema_node_helper.py); non-key fields are last-write-wins (ON MATCH SET).","domain":"Legal","icon":"DocumentIcon","primary_color":"#6D4C41","locator":"string","index":["label","matter_slug","source_ref","locator","sheet_name","cell_ref","page_number","section","value_as_stated"],"parent":"Legal","label":"string","shape":"sphere","section":"?string","unit":"?string","extracted_by":"?string","description_key":"value_as_stated","title_key":"label","ref_id":"35aa277c-080f-41ba-8964-8d1d882ea91c","matter_slug":"string"},{"summary":"?string","icon":"ConstructionIcon","body":"?string","index":["name","summary"],"text":"?string","hash":"?string","parent":"Directory","node_key":"file-name-file","code":"?string","shape":"sphere","type":"File","description_key":"summary","title_key":"name","start":"?int","file":"string","name":"string","domain":"CodeArtifact","type_description":"A file within a repository, containing source code, configuration, or other project-related content.","ref_id":"1edd4f6d-b07c-4309-b40b-c4997cf35830","source_link":"?string","end":"?int"},{"current_value":"?string","index":["fluent_of","fluent_property"],"fluent_of":"string","domain":"entity","ref_id":"4c3db758-aec4-411e-bd5f-5b4fd78f6ccd","parent":"Thing","node_key":"fluent-fluent_of-fluent_property","type":"Fluent","fluent_property":"string"},{"value_ref_id":"string","asserted_by":"?string","index":["fluent_ref_id","valid_from"],"valid_from":"datetime","domain":"entity","ref_id":"3da3f8ca-7826-4137-ab3e-fe1a1f40fdf1","parent":"Thing","node_key":"fluentevent-fluent_ref_id-valid_from","confidence":"?float","fluent_ref_id":"string","type":"FluentEvent","valid_to":"?datetime"},{"icon":"CalculatorIcon","primary_color":"#6D4C41","index":["label"],"parent":"Legal","node_key":"formulacomponent-label","label":"string","secondary_color":"#D7CCC8","shape":"sphere","type":"FormulaComponent","unit":"?string","description_key":"description","title_key":"label","source":"?string","description":"?string","domain":"Legal","value":"?float","type_description":"A named, typed numeric input to a formula — carries its raw value, unit, source attribution, and plain-language description.","ref_id":"66362f5a-19d8-46cc-8f5b-faf53d10cc03"},{"summary":"?string","icon":"ConstructionIcon","body":"?string","index":["name","summary","description"],"text":"?string","docs":"?string","parent":"CodeArtifact","node_key":"function-name-file","code":"?string","shape":"sphere","type":"Function","operand":"?string","description_key":"summary","title_key":"name","output_data_type":"?string","start":"?int","description":"?string","file":"string","name":"string","domain":"CodeArtifact","type_description":"A function or method definition in source code, representing executable logic within a program, including backend logic and frontend components.","ref_id":"2c2b38fb-3bc9-415e-ac9d-af2d7326b153","source_link":"?string","end":"?int"},{"gap_type":"?string","remediation_plan":"?string","node_key":"gapitem-id","secondary_color":"#D7CCC8","resolution":"?string","type":"GapItem","id":"string","significance":"?string","description":"?string","priority":"?string","type_description":"A compliance gap — a requirement not yet met by the organization, with remediation tracking.","domain":"Legal","notified":"?boolean","opened":"?datetime","comment_tracker_id":"?string","icon":"ExclamationIcon","primary_color":"#6D4C41","policy_affected":"?string","index":["id","gap_type","owner","status"],"requirement":"?string","status":"?string","parent":"Thing","status_verified":"?boolean","shape":"sphere","change_needed":"?string","description_key":"gap_type","title_key":"requirement","owner":"?string","regulation":"?string","ref_id":"54743019-f3eb-4c15-b9ca-0ab5cece8753","due":"?string"},{"output_json":"?string","node_key":"generated_step-step_unique_id","secondary_color":"#F468D4","type":"Generated_step","workflow_position":"?int","id":"string","description":"?string","name":"string","type_description":"A dynamically generated step in a workflow with specific configuration and parameters","domain":"Workflow","skill_name":"?string","skill_id":"?int","icon":"GeneratedStepIcon","primary_color":"#38243C","index":["name","description"],"parent":"Workflow","input_json":"?string","shape":"sphere","params":"?string","step_unique_id":"string","description_key":"description","title_key":"name","step_json":"?string","ref_id":"72935346-72fa-41e8-b1d2-b05392010461","attributes":"?string"},{"icon":"NodesIcon","index":["name","description"],"parent":"Repository","node_key":"githubrepo-name","shape":"sphere","type":"GitHubRepo","namespace":"?string","forks":"?int","description_key":"description","title_key":"name","description":"?string","stars":"?int","age_years":"?float","name":"string","domain":"CodeArtifact","type_description":"A GitHub repository containing metadata such as stars, forks, description, and language information.","ref_id":"ca6c9c0b-3bad-4421-993e-aedb7dbcd783","language":"?string"},{"index":["name","description"],"twitter_handle":"?string","parent":"Person","node_key":"guest-name","type":"Guest","is_favorite":"?boolean","description_key":"description","title_key":"name","image_url":"?string","description":"?string","name":"string","domain":"Entity","ref_id":"dde71be2-df05-4fa4-a47e-8ba8a4575a0b"},{"icon":"NodesIcon","index":["ref_id","question","answer"],"answer":"?string","parent":"KnowledgeArtifact","node_key":"hint-ref_id","shape":"sphere","type":"Hint","description_key":"answer","title_key":"ref_id","domain":"KnowledgeArtifact","type_description":"A question and answer pair generated from exploring the codebase to capture contextual understanding.","ref_id":"db4b0f4c-d1df-4188-813c-ba2b928085e3","question":"?string"},{"icon":"NodesIcon","primary_color":"#1B3134","index":["name","display_name","description"],"default_model":"?string","parent":"Thing","node_key":"hiveagent-name","secondary_color":"#21B38A","shape":"sphere","type":"HiveAgent","display_name":"?string","updated_at":"?string","description_key":"description","title_key":"display_name","description":"?string","name":"string","domain":"Hive","type_description":"An AI agent in the Hive agent catalog, mirrored from the gateway registry.","ref_id":"6704bd70-20b1-4891-8fb7-2689751f9e97"},{"icon":"MessageIcon","primary_color":"#1D3140","index":["name","message"],"status":"?string","task_id":"?string","parent":"Thing","message_id":"string","node_key":"hivechatmessage-message_id","secondary_color":"#4FA7D9","shape":"sphere","type":"HiveChatMessage","message":"string","updated_at":"?string","description_key":"message","title_key":"name","feature_id":"?string","name":"string","domain":"Hive","type_description":"A chat message in a Hive task/feature conversation, mirrored from Postgres.","ref_id":"4ca85386-86da-49fc-b85d-53452dac07e9","role":"?string","created_at":"?string","user_id":"?string"},{"decision_id":"string","icon":"MessageIcon","primary_color":"#302342","index":["name","text"],"text":"?string","canvas_ref":"?string","parent":"Thing","node_key":"hivedecision-decision_id","secondary_color":"#C25AF3","shape":"sphere","type":"HiveDecision","description_key":"text","title_key":"name","name":"string","domain":"Hive","type_description":"A decision extracted from a Hive canvas, mirrored from Postgres.","ref_id":"8694bce9-a70b-4922-8bed-4e86733cbfa5","y":"?float","x":"?float"},{"icon":"NodesIcon","primary_color":"#2A2545","index":["name","brief","requirements","architecture"],"status":"?string","workspace_id":"?string","parent":"Thing","node_key":"hivefeature-feature_id","secondary_color":"#9368FB","shape":"sphere","architecture":"?string","type":"HiveFeature","requirements":"?string","updated_at":"?string","description_key":"brief","title_key":"name","assignee_id":"?string","priority":"?string","feature_id":"string","name":"string","domain":"Hive","type_description":"A roadmap feature in the Hive PM app, mirrored from Postgres.","ref_id":"b9c9f00c-40dd-48a8-8c38-656fdfa325d7","created_at":"?string","brief":"?string"},{"icon":"NodesIcon","primary_color":"#22362A","index":["name","description"],"completed_at":"?string","status":"?string","parent":"Thing","node_key":"hiveinitiative-initiative_id","secondary_color":"#54AC52","shape":"sphere","type":"HiveInitiative","org_id":"?string","updated_at":"?string","description_key":"description","title_key":"name","assignee_id":"?string","description":"?string","name":"string","initiative_id":"string","domain":"Hive","type_description":"An org-level initiative in the Hive PM app, mirrored from Postgres.","ref_id":"abebfe52-8b71-487c-84ce-6c7048e49f04","target_date":"?string","created_at":"?string","start_date":"?string"},{"milestone_id":"string","icon":"NodesIcon","primary_color":"#2A3229","index":["name","description"],"completed_at":"?string","status":"?string","parent":"Thing","node_key":"hivemilestone-milestone_id","secondary_color":"#96BD3F","shape":"sphere","type":"HiveMilestone","updated_at":"?string","description_key":"description","title_key":"name","assignee_id":"?string","description":"?string","sequence":"?int","name":"string","initiative_id":"?string","domain":"Hive","type_description":"A milestone within a Hive initiative, mirrored from Postgres.","ref_id":"8b0a7cf1-4d62-4470-a360-768874a9686b","due_date":"?string","created_at":"?string"},{"icon":"MessageIcon","primary_color":"#38243C","index":["name","text"],"text":"?string","canvas_ref":"?string","parent":"Thing","node_key":"hivenote-note_id","secondary_color":"#F468D4","shape":"sphere","type":"HiveNote","description_key":"text","title_key":"name","note_id":"string","name":"string","domain":"Hive","type_description":"A note extracted from a Hive canvas, mirrored from Postgres.","ref_id":"28f166cc-6b8e-411b-b6fe-fd2f8e432a18","y":"?float","x":"?float"},{"summary":"?string","topic":"?string","icon":"NodesIcon","primary_color":"#36292D","index":["name","topic","summary","content"],"parent":"Thing","node_key":"hiveresearch-research_id","secondary_color":"#A96755","shape":"sphere","type":"HiveResearch","content":"?string","org_id":"?string","updated_at":"?string","description_key":"summary","title_key":"name","name":"string","research_id":"string","initiative_id":"?string","domain":"Hive","type_description":"A research document in the Hive PM app, mirrored from Postgres.","ref_id":"486de1e8-9fe2-4509-bbf7-cfdc15c3cecd","created_at":"?string","slug":"?string"},{"summary":"?string","task_id":"string","node_key":"hivetask-task_id","source_type":"?string","secondary_color":"#5E84F8","type":"HiveTask","repository_id":"?string","assignee_id":"?string","description":"?string","priority":"?string","feature_id":"?string","name":"string","type_description":"A task / work item in the Hive PM app, mirrored from Postgres.","domain":"Hive","created_at":"?string","phase_id":"?string","icon":"TaskIcon","primary_color":"#222B48","index":["name","description","summary"],"status":"?string","workspace_id":"?string","parent":"Thing","shape":"sphere","updated_at":"?string","description_key":"description","title_key":"name","ref_id":"4c420f55-a788-443a-af4c-75fbe926513e","branch":"?string"},{"icon":"BriefcaseIcon","primary_color":"#362429","index":["name","description","mission"],"workspace_id":"string","parent":"Thing","node_key":"hiveworkspace-workspace_id","secondary_color":"#D25353","shape":"sphere","type":"HiveWorkspace","updated_at":"?string","description_key":"description","title_key":"name","mission":"?string","description":"?string","name":"string","domain":"Hive","type_description":"A Hive workspace, mirrored from Postgres. The single anchor node for workspace-level knowledge: general Concepts (preferences, best practices, gotchas, processes) link here rather than to any repository.","ref_id":"d7f784a6-faa5-4721-99ef-76f1d628c13a","created_at":"?string","slug":"?string"},{"icon":"PersonIcon","primary_color":"#38243C","index":["name","description"],"parent":"Thing","node_key":"hiveworkspacemember-member_id","secondary_color":"#F468D4","shape":"sphere","type":"HiveWorkspaceMember","member_id":"string","description_key":"description","title_key":"name","description":"?string","name":"string","joined_at":"?string","domain":"Hive","type_description":"A member of a Hive workspace, mirrored from Postgres. Anchor node for per-person knowledge: a member's preference Concepts link here via PREFERENCE edges.","ref_id":"88e5a7c7-2b22-471e-a7d4-11cea2a57130","role":"?string","github_username":"?string","user_id":"?string"},{"index":["name","description"],"description_key":"description","title_key":"name","image_url":"?string","description":"?string","name":"string","domain":"Entity","ref_id":"b1c5f9b6-0f92-4613-9145-9a2798624fed","parent":"Person","node_key":"host-name","type":"Host","is_favorite":"?boolean"},{"registration_id":"string","use_in_commerce":"?boolean","owner_name":"?string","node_key":"ipasset-registration_id","secondary_color":"#D7CCC8","type":"IPAsset","docket_id":"?string","asset_type":"?string","name":"?string","type_description":"An intellectual property asset such as a patent, trademark, or copyright.","domain":"Legal","agent_managed":"?boolean","business_owner":"?string","classes":"?string","icon":"LightBulbIcon","primary_color":"#6D4C41","index":["registration_id","asset_type","name","status"],"priority_date":"?string","status":"?string","deployment_risk":"?string","local_agent":"?string","parent":"Thing","shape":"sphere","filing_date":"?string","description_key":"asset_type","title_key":"name","renewal_deadline":"?string","license_bucket":"?string","expiration_date":"?string","grant_date":"?string","ref_id":"911e578d-4dcf-4117-bf8d-e31162a451ac"},{"body":"?string","index":["name","body"],"parent":"CodeArtifact","node_key":"import-name-file","type":"Import","description_key":"body","title_key":"name","start":"?int","file":"string","name":"string","domain":"CodeArtifact","type_description":"A section at the top of a file that contains all imported modules, libraries, or dependencies used within the file.","ref_id":"370d619f-e4fb-42ca-8ed4-25034c178aab","end":"?int"},{"icon":"NodesIcon","body":"?string","index":["name","body"],"parent":"CodeArtifact","node_key":"instance-name-file","shape":"sphere","type":"Instance","description_key":"body","title_key":"name","start":"?int","file":"string","name":"string","domain":"CodeArtifact","type_description":"An instantiation of a class in source code, representing a specific object created from a class definition.","ref_id":"7aa1521f-4804-476f-9aca-066976165a5d","end":"?int"},{"icon":"NodesIcon","body":"?string","index":["name","body"],"parent":"Test","node_key":"integrationtest-name-file","shape":"sphere","type":"IntegrationTest","description_key":"body","title_key":"name","start":"?int","file":"string","name":"string","domain":"CodeArtifact","type_description":"An integration test verifying multiple components or an API boundary working together.","ref_id":"d29c7643-41e0-4d28-b334-d234e96be5d2","end":"?int"},{"icon":"SearchIcon","primary_color":"#6D4C41","index":["matter_slug","status","investigation_type"],"status":"string","parent":"Legal","node_key":"investigationlog-allegation","secondary_color":"#D7CCC8","shape":"sphere","type":"InvestigationLog","conduct_timeframe":"?string","attorney_directed":"boolean","investigation_type":"?string","allegation":"string","description_key":"investigation_type","title_key":"allegation","domain":"Legal","type_description":"An attorney-directed or HR investigation log for a workplace or regulatory matter.","ref_id":"40876b28-17da-4fbd-aef0-305af9809be9","last_updated":"?datetime"},{"icon":"NodesIcon","index":"name","count":"?int","parent":"Repository","node_key":"issues-name","shape":"sphere","type":"Issues","namespace":"?string","description_key":"name","title_key":"name","name":"string","domain":"CodeArtifact","type_description":"A collection of individual issue nodes belonging to a repository.","ref_id":"57061e5d-760d-4765-87da-e91cf6420b66"},{"icon":"NodesIcon","index":["name","description"],"parent":"KnowledgeArtifact","node_key":"jargon-name","shape":"sphere","type":"Jargon","description_key":"description","title_key":"name","description":"?string","name":"string","domain":"KnowledgeArtifact","type_description":"An internally-used term and its meaning, optionally linked to the graph node it names.","ref_id":"9bde9004-f412-48c2-ad52-3809ce2fbdf6"},{"forum":"?string","state":"?string","node_key":"jurisdiction-name","secondary_color":"#D7CCC8","type":"Jurisdiction","court_system":"?string","level":"?string","name":"string","type_description":"A legal jurisdiction, such as a country, state, or regulatory body forum.","domain":"Legal","icon":"GlobeIcon","primary_color":"#6D4C41","index":["name","forum"],"sali_locale":"?string","parent":"Thing","shape":"sphere","country":"?string","sali_court_code":"?string","description_key":"forum","parent_ref_id":"?string","appeal_path_ref_id":"?string","title_key":"name","ref_id":"f08db6ef-c471-400f-b418-41a5b7a7ad2e","circuit":"?string","jurisdiction_kind":"?string"},{"icon":"BookIcon","index":"name","domain":"KnowledgeArtifact","type_description":"An abstract parent for AI-generated and user-facing knowledge nodes.","ref_id":"715266b4-4e95-47c1-800c-5591608fdd69","parent":"Thing","type":"KnowledgeArtifact"},{"icon":"NodesIcon","index":"name","description_key":"name","title_key":"name","name":"string","domain":"CodeArtifact","type_description":"A programming language used in the repository.","ref_id":"aef66983-e555-4fbd-8d18-bf1675656503","parent":"CodeArtifact","node_key":"language-name","shape":"sphere","type":"Language"},{"icon":"BookIcon","index":["name","description"],"parent":"CodeArtifact","node_key":"learning-name","shape":"sphere","type":"Learning","description_key":"description","title_key":"name","description":"?string","name":"string","domain":"CodeArtifact","type_description":"A learning resource or educational content item associated with a codebase.","ref_id":"2942e22b-8c6c-4189-bfc6-a2f769a69f51"},{"icon":"HomeIcon","lessee":"?string","primary_color":"#6D4C41","index":["name","lessor","lessee","property_address","lease_term"],"lessor":"?string","parent":"Agreement","node_key":"leaseagreement-id","property_address":"?string","secondary_color":"#D7CCC8","shape":"sphere","type":"LeaseAgreement","security_deposit":"?float","id":"string","description_key":"property_address","title_key":"name","monthly_rent":"?float","name":"?string","domain":"Legal","type_description":"A lease agreement specifying terms between a lessor and lessee for a property.","ref_id":"151c0569-88b7-4780-a822-e67832506dd4","lease_term":"?string"},{"icon":"ScalesIcon","primary_color":"#6D4C41","index":["name"],"parent":"Thing","secondary_color":"#D7CCC8","shape":"sphere","type":"Legal","description_key":"description","title_key":"name","description":"?string","name":"?string","domain":"Legal","type_description":"Domain anchor for all Legal entity types in the knowledge graph.","ref_id":"a08a10c6-d169-4924-8565-28533ffa9985"},{"node_key":"legalargument-matter_slug-argument_id","secondary_color":"#D7CCC8","resolution":"?string","type":"LegalArgument","source_document":"?string","priority":"?string","type_description":"A legal argument, statutory position, citation, or evidentiary issue identified in a litigation document.","domain":"Legal","raised_by":"?string","argument_id":"string","icon":"ScaleIcon","primary_color":"#6D4C41","statute_or_case":"?string","index":["matter_slug","argument_kind","statute_or_case"],"parent":"Legal","weakness_flag":"?boolean","severity":"?string","shape":"sphere","assertion":"string","description_key":"argument_kind","title_key":"assertion","ref_id":"081b2c0d-4f76-44f4-ba9b-51b2b8ab336a","notes":"?string","argument_kind":"string","matter_slug":"string"},{"icon":"LockClosedIcon","primary_color":"#6D4C41","next_refresh":"?string","index":["matter_slug","status","issued_date"],"last_refresh":"?datetime","scope":"?string","status":"?string","parent":"Thing","node_key":"legalhold-matter_slug","secondary_color":"#D7CCC8","shape":"sphere","type":"LegalHold","issued_by":"?string","released":"?boolean","description_key":"scope","title_key":"matter_slug","issued_date":"?string","release_date":"?datetime","domain":"Legal","type_description":"A legal hold (litigation hold) preserving documents and data relevant to anticipated or ongoing litigation.","ref_id":"af04c6fd-58fd-416c-815c-c66fa7de8eb4","matter_slug":"string"},{"player_role":"string","node_key":"legalparty-matter_slug-entity_id-player_role","secondary_color":"#D7CCC8","type":"LegalParty","entity_id":"string","end_date":"?datetime","side":"?string","type_description":"A matter-scoped player (person or organization) with a specific role in a legal matter. Implements the SALI LMSS Player model with player_role and representation_role separation.","domain":"Legal","firm_name":"?string","icon":"UserCircleIcon","representation_role":"?string","primary_color":"#6D4C41","index":["player_role","representation_role","matter_slug"],"entity_kind":"string","parent":"Legal","shape":"sphere","entity_name":"string","description_key":"player_role","bar_number":"?string","title_key":"entity_name","ref_id":"ad1cd3b9-6230-42bb-9dc2-2b38cbac7022","is_primary":"?boolean","notes":"?string","start_date":"?datetime","matter_slug":"string"},{"body":"?string","index":["name","body"],"parent":"CodeArtifact","node_key":"library-name-file","type_descrioption":"A reusable collection of code or modules providing functionality that can be imported and used in other projects.","type":"Library","description_key":"body","title_key":"name","start":"?int","file":"string","name":"string","domain":"CodeArtifact","ref_id":"5714a98a-d44b-4b09-b3c4-841963535f10","end":"?int"},{"icon":"ClockIcon","primary_color":"#6D4C41","index":["jurisdiction","matter_type"],"parent":"Legal","node_key":"limitationstatute-jurisdiction-matter_type","limitation_years":"float","secondary_color":"#D7CCC8","shape":"sphere","jurisdiction":"string","type":"LimitationStatute","description_key":"jurisdiction","title_key":"matter_type","matter_type":"string","domain":"Legal","type_description":"A statute of limitations applicable to a given matter type within a jurisdiction.","ref_id":"8f8e1d3d-ddbe-484a-9998-0bdf4a4e5b6c","notes":"?string","discovery_rule":"boolean"},{"icon":"BookTextIcon","primary_color":"#1D3140","index":["name","definition"],"definition":"?string","parent":"Thing","node_key":"lingo-name","secondary_color":"#4FA7D9","shape":"sphere","type":"Lingo","lingo_type":"?string","description_key":"definition","title_key":"name","name":"string","domain":"Entity","type_description":"A domain-specific term or vocabulary entry","ref_id":"425b3de0-fb58-4144-a334-32e6c86497bb"},{"icon":"PlaceIcon","primary_color":"#2A2545","index":["name","description"],"parent":"Thing","node_key":"location-name","secondary_color":"#9368FB","shape":"sphere","type":"Location","description_key":"description","title_key":"name","description":"?string","name":"string","domain":"Entity","type_description":"A physical or virtual location","ref_id":"7420f347-3b7a-4b06-b024-a0a5630eeb75"},{"next_action":"?string","closed_date":"?datetime","limitation_deadline":"?datetime","risk_likelihood":"?string","node_key":"matter-slug","secondary_color":"#D7CCC8","jurisdiction":"?string","type":"Matter","instruction_ledger":"?string","damages_calculation":"?string","stage":"?string","initial_posture":"?string","termination_kind":"?string","our_role":"?string","file_no":"?string","name":"?string","next_action_date":"?datetime","type_description":"A legal matter or case, including litigation, regulatory investigation, or transactional matter.","domain":"Legal","outside_counsel":"?string","last_touched":"?datetime","conflict_check_done":"?boolean","client_name":"?string","opened_date":"?datetime","icon":"BriefcaseIcon","statutory_exceptions":"?string","primary_color":"#6D4C41","index":["slug","name","matter_type","status"],"status":"?string","parent":"Thing","shape":"sphere","outcome":"?string","exposure_range":"?string","description_key":"matter_type","title_key":"name","matter_type":"?string","confidentiality":"?string","risk_severity":"?string","ref_id":"c327a21e-6b39-4139-a361-cb3ead2eb56c","slug":"string","materiality":"?string","final_cost":"?string"},{"icon":"GeneratedStepIcon","primary_color":"#38243C","index":"content","parent":"Workflow","memory_id":"string","node_key":"memory-memory_id","secondary_color":"#F468D4","shape":"sphere","type":"Memory","content":"string","description_key":"content","title_key":"memory_id","domain":"Workflow","type_description":"A memory unit for storing information and context learned by an Agent.","ref_id":"35e2971d-468d-44b7-8c0d-085e5c3239ca"},{"media_type":"?string","chat_pubkey":"?string","node_key":"message-uuid","secondary_color":"#54AC52","media_token":"?string","date":"int","type":"Message","kind":"?int","amount":"?int","sender":"string","media_key":"?string","type_description":" A communication conveyed through text, speech, or signals","domain":"Content","reply":"?string","thread_uuid":"?string","icon":"MessageIcon","primary_color":"#22362A","index":"content","parent":"Content","shape":"sphere","content":"string","description_key":"content","title_key":"content","ref_id":"7df3f6c6-afc0-4e30-97f7-494fcab47632","uuid":"string"},{"body":"?string","index":["name","body"],"parent":"CodeArtifact","node_key":"mock-name","mocked":"boolean","type":"Mock","description_key":"body","title_key":"name","start":"?int","file":"?string","name":"string","domain":"CodeArtifact","type_description":"A mock object or function used for testing purposes, simulating the behavior of real components.","ref_id":"0fefd66a-92c5-4a82-b90b-31c36db85fa2","end":"?int"},{"icon":"DocumentIcon","primary_color":"#2A2545","index":"source_link","parent":"Content","node_key":"multimedia-source_link","secondary_color":"#9368FB","shape":"sphere","type":"Multimedia","description_key":"source_link","title_key":"source_link","domain":"Content","type_description":"A generic type of content","ref_id":"3a74c3eb-73ff-46a1-86e7-d3de66775eed","source_link":"string"},{"firm_size":"?string","node_key":"organization-name","secondary_color":"#C25AF3","type":"Organization","practice_areas":"?string","court_system":"?string","image_url":"?string","description":"?string","name":"string","type_description":"A structured group of people with a collective purpose","domain":"Entity","org_kind":"?string","enabling_statute":"?string","court_level":"?string","icon":"OrganizationIcon","sali_govt_code":"?string","primary_color":"#302342","index":["name","description"],"sali_industry_code":"?string","acronym":"?string","parent":"Thing","primary_jurisdiction":"?string","shape":"sphere","sali_court_code":"?string","description_key":"description","title_key":"name","ref_id":"f9cae1c9-aab6-4d7a-bcdd-4d867e1ebc6f","circuit":"?string"},{"icon":"NodesIcon","body":"?string","index":["name","body"],"parent":"CodeArtifact","node_key":"package-name-file","shape":"sphere","type":"Package","description_key":"body","title_key":"name","start":"?int","file":"string","name":"string","domain":"CodeArtifact","type_description":"A package or module grouping related source files and dependencies within a repository.","ref_id":"b58ce83c-582c-45c2-a3c7-3ee4b61a7a31","end":"?int"},{"body":"?string","index":["name","body"],"parent":"CodeArtifact","node_key":"page-name-file","type":"Page","description_key":"body","title_key":"name","start":"?int","file":"string","name":"string","domain":"CodeArtifact","type_description":"A webpage or route within an application, representing a specific view or section of the system. It can serve as the starting point for a codemap.","ref_id":"45ed4c69-359f-4a24-8edf-3df33c09f9ee","end":"?int"},{"certification_number":"?string","icon":"PersonIcon","primary_color":"#362429","bar_state":"?string","index":["name","description"],"alias":"?string","bar_admitted_date":"?datetime","twitter_handle":"?string","parent":"Thing","node_key":"person-name","secondary_color":"#D25353","shape":"sphere","type":"Person","title":"?string","bar_number":"?string","description_key":"description","title_key":"name","image_url":"?string","expertise_domain":"?string","description":"?string","name":"string","domain":"Entity","type_description":"A human being regarded as an individual","ref_id":"3b7acdb2-3238-4ce9-b595-01f68d2aaee0"},{"icon":"PlaceIcon","primary_color":"#2A2545","index":["name","description"],"parent":"Thing","node_key":"place-name","secondary_color":"#9368FB","shape":"sphere","type":"Place","description_key":"description","title_key":"name","image_url":"?string","description":"?string","name":"string","domain":"Entity","type_description":"A particular position, point, or area in space","ref_id":"392aa4ca-b1d5-4c66-8e36-3f41f602165d"},{"icon":"BookmarkIcon","primary_color":"#6D4C41","index":["scope","clause_type","severity"],"scope":"string","parent":"Legal","node_key":"playbookentry-scope-clause_type","severity":"string","suggested_text":"?string","secondary_color":"#D7CCC8","shape":"sphere","fallback_position":"?string","type":"PlaybookEntry","clause_type":"string","red_line":"boolean","description_key":"firm_position","title_key":"clause_type","firm_position":"string","domain":"Legal","type_description":"A negotiation playbook entry specifying firm positions and fallback positions for a contract clause type.","ref_id":"1829e31d-421d-49fa-8078-6685e113519d","notes":"?string"},{"link":"?string","node_key":"podcast-episode_title-timestamp","secondary_color":"#4FA7D9","sentiment_score":"?float","date":"?datetime","type":"Podcast","timestamp":"string","image_url":"?string","description":"?string","type_description":"A digital audio program made up of episodic recordings","domain":"Content","source_link":"?string","pub_key":"?string","icon":"AudioIcon","primary_color":"#1D3140","text":"?string","index":["episode_title","text","description"],"episode_title":"string","media_url":"string","parent":"Episode","show_title":"?string","shape":"sphere","num_boost":"?int","description_key":"description","title_key":"episode_title","english_translation":"?string","ref_id":"3ed41af8-6d8e-4d0b-a799-8e74712e6f8a","boost":"?int","language":"?string"},{"icon":"DocumentTextIcon","primary_color":"#6D4C41","index":["name"],"parent":"Thing","node_key":"policy-name","secondary_color":"#D7CCC8","shape":"sphere","type":"Policy","description_key":"description","title_key":"name","description":"?string","name":"string","domain":"Legal","type_description":"An internal or external policy document governing organizational conduct.","ref_id":"87ab2b1b-db8c-4083-86d9-c1a4b70c53c2"},{"icon":"DatabaseIcon","primary_color":"#6D4C41","index":["name","legal_basis","regime"],"parent":"Thing","node_key":"processingactivity-name","secondary_color":"#D7CCC8","shape":"sphere","purpose":"?string","type":"ProcessingActivity","description_key":"purpose","title_key":"name","legal_basis":"?string","name":"string","domain":"Legal","type_description":"A data processing activity subject to privacy regulations such as GDPR or CCPA.","ref_id":"6b70e0dd-d633-49df-9bd5-5563a021bd8d","triage_result":"?string","regime":"?string"},{"primary_color":"#353124","index":"name","description_key":"name","title_key":"name","name":"string","domain":"Entity","type_description":"A product or item","ref_id":"9fe7badd-c23a-42ca-9638-a22c47d1e15a","parent":"Thing","node_key":"product-name","secondary_color":"#BA9D39","type":"Product"},{"icon":"NodesIcon","body":"?string","index":["name","description","body"],"parent":"Workflow","node_key":"prompt-id","shape":"sphere","type":"Prompt","id":"string","description_key":"description","title_key":"name","description":"?string","name":"?string","domain":"Workflow","type_description":"A prompt template used within workflow definitions.","ref_id":"915b2c28-4905-4dc6-a9cd-9ed43cecbdfa","customer_id":"?int"},{"summary":"?string","fix_type":"string","node_key":"proposedfix-fix_id","secondary_color":"#E09242","before_score":"?string","type":"ProposedFix","criterion_title":"?string","stakwork_run_id":"?string","new_prompt_version_id":"?string","fix_id":"string","prompt_name":"?string","rerun_run_id":"?string","prompt_id":"?string","resolved_by":"?string","type_description":"A fix-scoped, fix-type-polymorphic node representing any eval-driven improvement. One node per fix, identified by a caller-generated, globally-unique fix_id (NOT one node per criterion). fix_type is an open string; conventional values: prompt | workflow | architecture | schema | other — new fix kinds require no migration. Criterion and prompt fields are optional; populate them only when fix_type=prompt. rerun_run_id references the upstream evaluation run id (e.g. EvalTriggerOutput.id) that produced this fix proposal — it is NOT an EvalRun.id reference (EvalRun is deprecated from the canonical model as of migration 084). Anchored to the unified eval chain: EvalSet → EvalRequirement → EvalTrigger → EvalTriggerOutput → CriterionResult → ProposedFix. Duplicate fix_id values are silently merged (not rejected) by the node_key migration helper; callers must ensure global uniqueness. OUTCOME FIELD — eval_status (canonical): the accept/reject outcome of this fix. Lifecycle: pending → accepted | rejected. Exact allowed set only — downstream readers and writers MUST validate against this fixed set before acting on it; a malformed or unexpected value would silently mis-branch accept/reject logic in the recursion loop (accepted = trunk node, rejected = dead-end leaf). LEGACY FIELD — status: retained for compatibility; no longer the outcome signal. Do not use status for accept/reject branching in new code; use eval_status instead. FIX LINEAGE — DERIVED_FROM edge (child → parent, append-only, git-commit-parent style): accepted fixes form a linear trunk (latest accepted = the tip, i.e. the accepted node with no accepted child pointing to it); a rejected fix is a dead-end leaf pointing at the accepted parent it was tried against. Only the accepted trunk is linearly ordered by topology; rejected siblings off one accepted parent are an unordered set by design — no timestamp or sequence field is added. PARENT LOOKUP — parent_fix_id: a denormalized, edge-free mirror of the DERIVED_FROM parent (analogous to rerun_run_id). The DERIVED_FROM edge is the source of truth; keeping parent_fix_id consistent with the edge and rejecting cycles is the responsibility of the sibling writer feature, not enforced by this ontology. TRAVERSAL SAFETY CONTRACT: no acyclicity or depth guarantee exists at the ontology layer. Downstream chain-walkers (recursion cron, eval-runner) MUST bound traversal depth and reject cycles — e.g. validate parent_fix_id lineage before writing a new DERIVED_FROM edge — otherwise a cycle written later could loop a walker unbounded. TARGET SNAPSHOT (generic before/after for any fix kind) — five optional fields added by migration 105: target_type, target_name, target_version, old_value, new_value. old_value/new_value each hold ONE JSON object serialized to a JSON string via json.dumps — never a raw object (both are typed ?string, so validate_by_schema rejects non-string values). Conventional envelope shapes by target_type: prompt -> {\"text\": ...}; concept -> {\"name\": ..., \"documentation\": ...} — readers MUST accept either \"documentation\" or \"docs\" as the concept body key (Concept's body field may be renamed from documentation to docs in a separate in-flight change; this convention is forward-compatible with either); workflow -> the workflow's JSON definition. These shapes are a documented convention only, NOT runtime-validated at the schema layer — readers must tolerate a missing or unparseable envelope gracefully (render the raw text and flag it, never throw). DISCRIMINATOR PRECEDENCE: target_type is authoritative for interpreting the snapshot envelope; fix_type remains the existing fix-kind label. The two are not schema-enforced to agree. When target_type is absent (true for every pre-existing ProposedFix node — no backfill is performed), readers fall back to fix_type for labelling and render an empty-snapshot state. RELATION TO EXISTING BEFORE/AFTER FIELDS (none deprecated by 105): failing_value/passing_value/delta are criterion-level observed values; before_score/after_score/score_delta are numeric eval scores; old_value/new_value are the full target-artifact snapshot — a diff renderer should read only old_value/new_value. DENORMALIZED IDENTITY: target_ref remains the source of truth for target identity; target_name/target_version are edge-free display mirrors (same spirit as parent_fix_id) that may go stale — keeping them consistent is the writer's responsibility, not enforced here. For fix_type=prompt, prompt_name/prompt_version_id/new_prompt_version_id remain canonical; target_name/target_version merely mirror them. WRITE PATH: attach a snapshot to an existing ProposedFix via POST /v2/nodes with reprocess=true (in-place update, preserves DERIVED_FROM lineage); never force_delete (DETACH DELETE destroys lineage) and never allow_scratchpad=true (a rejected write would be durably parked as a ScratchpadEntry under a different access-control model — undesirable for large snapshot payloads). SECRET HYGIENE: workflow-target JSON snapshots can embed credentials/secrets; old_value/new_value are deliberately publicly readable (no paid_properties gating), so producers MUST redact credentials/secret values before writing a workflow-targeted snapshot.","domain":"CodeArtifact","target_version":"?string","criterion_id":"?string","parent_fix_id":"?string","after_score":"?string","icon":"PencilAltIcon","rerun_status":"?string","primary_color":"#392828","index":["fix_id","fix_type","summary","status","task_slug"],"new_value":"?string","target_name":"?string","status":"?string","delta":"?string","parent":"Thing","shape":"sphere","target_ref":"?string","resolved_at":"?string","eval_status":"?string","task_slug":"?string","passing_value":"?string","reasoning":"string","prompt_version_id":"?string","title_key":"summary","old_value":"?string","rubric_criterion_ref":"?string","score_delta":"?string","ref_id":"9f787d04-0aa0-4131-b284-548245517792","target_type":"?string","failing_value":"?string"},{"icon":"NodesIcon","index":["name","description"],"state":"?string","parent":"Repository","node_key":"pullrequest-name","number":"?int","shape":"sphere","type":"PullRequest","namespace":"?string","description_key":"description","title_key":"name","description":"?string","name":"string","domain":"CodeArtifact","type_description":"A pull request created by a developer to merge changes into the main branch.","ref_id":"e9eed9da-2487-4ab3-beca-702c04e6c4a6","created_at":"?string","source_link":"?string","user":"?string"},{"icon":"BookOpenIcon","primary_color":"#6D4C41","index":["name","agency","regime"],"status":"?string","parent":"Thing","agency":"?string","status_verified":"?boolean","node_key":"regulation-name","secondary_color":"#D7CCC8","shape":"sphere","type":"Regulation","relevance_hook":"?string","description_key":"regime","title_key":"name","name":"string","domain":"Legal","type_description":"A law, regulation, or regulatory framework applicable to the organization (e.g. GDPR, CCPA, SOX).","ref_id":"a5e50fb4-1b08-4679-bd56-6927d469c39e","effective_date":"?string","regime":"?string","item_type":"?string"},{"link":"?string","node_key":"regulatoryitem-id","secondary_color":"#D7CCC8","type":"RegulatoryItem","decision":"?string","id":"string","title":"?string","type_description":"A discrete regulatory requirement, rule, or guidance item issued under a Regulation.","domain":"Legal","filed_at":"?datetime","detected":"?datetime","item_type":"?string","icon":"ClipboardListIcon","citation":"?string","primary_color":"#6D4C41","index":["id","item_type","agency","materiality_tier"],"materiality_tier":"?string","parent":"Thing","agency":"?string","shape":"sphere","description_key":"item_type","title_key":"title","ref_id":"c80b1515-af2d-41ea-8dba-d19bebf857ba","effective_date":"?datetime","owner_slack":"?string","comment_deadline":"?string"},{"business_owner":"?string","icon":"RefreshIcon","primary_color":"#6D4C41","renewal_mechanism":"?string","index":["counterparty","signed_date","status"],"cancel_by":"?datetime","signed_date":"string","status":"string","notice_period_days":"?int","initial_term_end":"?datetime","parent":"Legal","node_key":"renewalentry-counterparty-signed_date","secondary_color":"#D7CCC8","shape":"sphere","price_on_renewal":"?string","type":"RenewalEntry","counterparty":"string","description_key":"status","title_key":"counterparty","domain":"Legal","type_description":"A contract renewal record tracking key renewal dates, terms, and owners.","ref_id":"dda88769-ae75-41df-9cce-accd9d5cb4de","annual_value":"?string","clm_id":"?string"},{"icon":"HomeIcon","body":"?string","index":["name","body"],"hash":"?string","parent":"Thing","node_key":"repository-name-file-start","shape":"sphere","type":"Repository","description_key":"body","title_key":"name","start":"int","file":"string","name":"string","domain":"CodeArtifact","type_description":"A code repository that contains source files, directories, and version history.","ref_id":"35d0b681-fa57-4289-bc73-cceed785fcae","source_link":"?string","end":"?int"},{"summary":"?string","body":"?string","node_key":"request-name-file-verb","type":"Request","namespace":"?string","verb":"string","description":"?string","file":"string","name":"string","type_description":"An HTTP request representation, defining interactions with an API or web service.","domain":"CodeArtifact","source_link":"?string","end":"?int","icon":"ConstructionIcon","text":"?string","index":["name","summary","description"],"hash":"?string","parent":"CodeArtifact","shape":"sphere","description_key":"summary","title_key":"name","start":"?int","ref_id":"c71ceadd-e1d6-43b4-a23e-167e3366d6b4","method":"?string","handler":"?string"},{"icon":"RunIcon","primary_color":"#36292D","index":["project_id","workflow_state"],"parent":"Workflow","node_key":"run-project_id","secondary_color":"#A96755","shape":"sphere","type":"Run","evaluation":"?boolean","description_key":"workflow_state","title_key":"project_id","workflow_state":"?string","project_id":"int","domain":"Workflow","type_description":"An execution instance of a workflow or process","ref_id":"0127e76b-39f5-4e7f-b12c-4b29ab2eddbe"},{"icon":"StepIcon","primary_color":"#362429","index":["description","step_unique_id"],"output_json":"?string","parent":"Workflow","node_key":"run_step-step_unique_id","secondary_color":"#D25353","shape":"sphere","input_json":"?string","type":"Run_step","step_unique_id":"string","id":"string","description_key":"description","title_key":"step_unique_id","description":"?string","domain":"Workflow","type_description":"An individual step within a workflow run execution","ref_id":"6a640450-9511-45e7-8d42-95c7b8eae72f"},{"icon":"NodesIcon","index":["name","description"],"parent":"KnowledgeArtifact","node_key":"scope-name","shape":"sphere","type":"Scope","description_key":"description","title_key":"name","description":"?string","name":"string","domain":"KnowledgeArtifact","type_description":"A scope label that groups related learnings, such as a technology, pattern, or area of the codebase.","ref_id":"806755c4-2080-419e-9cd5-bd904bc8e3bb"},{"icon":"PencilAltIcon","index":["intended_type","name"],"entry_hash":"string","intended_type":"string","parent":"Thing","node_key":"scratchpadentry-intended_type-entry_hash","shape":"sphere","rejection_reason":"?string","type":"ScratchpadEntry","payload_json":"?string","title_key":"name","name":"?string","rejection_detail":"?string","domain":"Scratchpad","type_description":"A write that did not match the strict ontology, preserved verbatim instead of being rejected. intended_type records the node type the caller tried to create; payload_json holds the original node_data as a JSON string (the ontology validators reject undeclared attributes, so the payload cannot be spread into real properties). entry_hash is a sha256 of that payload, so a writer retrying the same rejected request repeatedly produces one node, not one per try. The Scratchpad domain is system-hidden: these nodes are excluded from search and are never offered to the extraction agent as a target type. INVARIANT: a ScratchpadEntry may be the SOURCE of an edge (so an entry keeps context by pointing at canonical nodes) but never the TARGET, so nothing traversing the canonical graph can walk into ungoverned data. Promotion of entries into real node types is deliberately out of scope; this tier is a staging area and a record of which node types the ontology is missing.","ref_id":"9b433ec6-e502-4cab-aabe-6b7ae0eadee3"},{"icon":"TaskIcon","primary_color":"#1B3134","body":"?string","index":["name","description","body"],"parent":"Workflow","node_key":"script-id","secondary_color":"#21B38A","shape":"sphere","type":"Script","id":"int","description_key":"description","title_key":"name","description":"?string","name":"?string","domain":"Workflow","type_description":"A script or code block used within workflow execution","ref_id":"7d605ac2-d97f-4c44-85b1-d1fbb3910c28","customer_id":"?int"},{"icon":"TaskIcon","primary_color":"#1B3134","usage_count":"?int","index":["name","description"],"parent":"Thing","node_key":"secret-id","secondary_color":"#21B38A","shape":"sphere","type":"Secret","id":"int","description_key":"description","title_key":"name","description":"string","name":"string","domain":"Workflow","type_description":"A named credential or sensitive value optionally scoped to a skill and customer.","usage_count_30d":"?int","ref_id":"9dfc3e87-4d02-454f-8c41-35d9cfb549d0","skill_id":"?int","customer_id":"?int"},{"summary":"?string","icon":"DocumentIcon","primary_color":"#2A2545","index":["text","source_link"],"text":"string","parent":"Document","node_key":"section-text","secondary_color":"#9368FB","shape":"sphere","type":"Section","description_key":"text","title_key":"text","domain":"Content","type_description":"A distinct part or subdivision of a document","ref_id":"7099575b-ee14-4e40-82e9-31668c8933c8","source_link":"?string"},{"icon":"VideoIcon","primary_color":"#38243C","index":"show_title","parent":"Content","node_key":"show-show_title","show_title":"string","secondary_color":"#F468D4","shape":"sphere","type":"Show","description_key":"show_title","title_key":"show_title","image_url":"?string","domain":"Content","type_description":" A podcast is a digital medium consisting of audio (or video) episodes that relate to a specific theme","ref_id":"6e6cd66e-9c96-412a-a0db-6330a55e98f1"},{"icon":"TaskIcon","primary_color":"#1D3140","usage_count":"?int","index":["name","description","input_schema","output_schema"],"graph_description":"?string","input_schema":"?string","output_schema":"?string","parent":"Workflow","node_key":"skill-name","secondary_color":"#4FA7D9","shape":"sphere","type":"Skill","id":"?int","description_key":"description","title_key":"name","description":"?string","name":"string","domain":"Workflow","type_description":"A reusable capability or function that can be composed into workflows","usage_count_30d":"?int","ref_id":"2af571af-d21a-412d-8177-3ce792e46317","skill_id":"?int","mockable":"?boolean"},{"icon":"StrategyIcon","primary_color":"#353124","index":["name","steps_list"],"LLM_response_id":"?int","parent":"Workflow","node_key":"strategy-strategy_id","secondary_color":"#BA9D39","shape":"sphere","type":"Strategy","description_key":"steps_list","title_key":"name","steps_list":"?string","name":"?string","domain":"Workflow","type_description":"A plan or approach for accomplishing a specific goal or set of objectives","ref_id":"b5c26d54-ad93-4685-aa22-dbc59d281bc2","strategy_id":"int","success":"?boolean"},{"icon":"TableIcon","primary_color":"#6D4C41","index":["column_name","state","column_type"],"column_type":"string","location":"?string","state":"string","parent":"Legal","node_key":"tabularreviewcell-column_name-column_type-state","secondary_color":"#D7CCC8","shape":"sphere","type":"TabularReviewCell","column_name":"string","description_key":"value","title_key":"column_name","quote":"?string","domain":"Legal","value":"?string","type_description":"A single cell in a tabular contract or document review, capturing a named column value for a specific document.","ref_id":"1459218f-3d70-4b21-8533-0d0cee7c1266"},{"taskId":"?string","icon":"TaskIcon","primary_color":"#2A3229","index":"task","status":"?string","task":"?string","task_id":"int","parent":"Workflow","node_key":"task-task_id","secondary_color":"#96BD3F","shape":"sphere","type":"Task","description_key":"task","title_key":"task","project_id":"?int","domain":"Workflow","type_description":"A specific work item or unit of work that needs to be completed","ref_id":"ff186ad2-b21e-427f-850e-8a6b346575c2","success":"?boolean"},{"icon":"ConstructionIcon","index":"name","domain":"CodeArtifact","type_description":"An abstract parent for all test types (unit, integration, e2e).","ref_id":"058238a1-f4bb-430f-a682-1ce9d8911b95","parent":"CodeArtifact","type":"Test"},{"unique_source_id":"?string","icon":"NodesIcon","primary_color":"#36292D","index":["name","description"],"weight":"?float","node_key":"thing-name","secondary_color":"#A96755","shape":"sphere","type":"Thing","description_key":"description","title_key":"name","image_url":"?string","description":"?string","name":"string","is_muted":"?boolean","type_description":"The highest-level node in the ontology hierarchy, representing an abstract concept with no direct individual instances","ref_id":"2f8b346c-74e3-43c2-9700-ce22c397c2ce"},{"icon":"CalendarIcon","primary_color":"#6D4C41","index":["matter_slug","entry_date","entry_type"],"parent":"Legal","node_key":"timelineentry-matter_slug-entry_date","entry_type":"string","secondary_color":"#D7CCC8","shape":"sphere","type":"TimelineEntry","author":"string","description_key":"entry_type","title_key":"description","source":"string","email_thread_id":"?string","description":"string","domain":"Legal","type_description":"A dated event or milestone on a legal matter timeline.","ref_id":"d52c3dfe-14c0-46c8-b31e-93a9aad85617","entry_date":"datetime","matter_slug":"string"},{"icon":"BookIcon","primary_color":"#1D3140","index":["name","description"],"parent":"Thing","node_key":"topic-name","secondary_color":"#4FA7D9","shape":"sphere","type":"Topic","description_key":"description","title_key":"name","image_url":"?string","description":"?string","name":"string","is_muted":"?boolean","domain":"Content","relevancy_score":"?float","type_description":"A subject or theme of discussion, study, or interest","ref_id":"83819187-c84f-492d-a112-f64e8ea62adc"},{"body":"?string","index":["name","body"],"parent":"CodeArtifact","node_key":"trait-name-file","type":"Trait","description_key":"body","title_key":"name","start":"?int","file":"string","name":"string","domain":"CodeArtifact","type_description":"A reusable set of properties or behaviors in source code, often used in object-oriented or functional programming.","ref_id":"594ae6bb-c5ac-42d4-ab8f-e4b31a5eafb8","end":"?int"},{"icon":"StepIcon","primary_color":"#2A2545","index":["content","tool"],"tool":"?string","parent":"Workflow","node_key":"turn-turn_id","secondary_color":"#9368FB","shape":"sphere","type":"Turn","turn_id":"string","outcome":"?string","content":"?string","turn_type":"?string","description_key":"content","title_key":"tool","order":"?int","domain":"Workflow","type_description":"A single turn in an agent session — a tool call, thought, response, user input, or error.","ref_id":"6888e270-a3fd-4aa0-b3cf-c447b747b84f","tokens":"?int"},{"quote_count":"?string","tweet_id":"string","twitter_handle":"?string","node_key":"tweet-tweet_id","secondary_color":"#D25353","date":"?datetime","type":"Tweet","followers":"?string","impression_count":"?string","image_url":"?string","description":"?string","verified":"?boolean","name":"?string","type_description":"A short message posted on a social media platform","domain":"Content","source_link":"?string","icon":"TwitterIcon","primary_color":"#362429","text":"?string","index":["text","twitter_handle"],"reply_count":"?string","status":"?string","media_url":"?string","parent":"Content","shape":"sphere","pubkey":"?string","description_key":"text","title_key":"twitter_handle","like_count":"?string","project_id":"?string","ref_id":"d7103144-1c0a-4ec9-91ee-713a84335514","retweet_count":"?string","bookmark_count":"?string"},{"icon":"TwitterIcon","primary_color":"#362429","index":["twitter_handle","name"],"verified_type":"?string","twitter_handle":"string","parent":"Thing","node_key":"twitteraccount-twitter_handle","secondary_color":"#D25353","shape":"sphere","type":"TwitterAccount","is_identity_verified":"?boolean","description_key":"name","title_key":"twitter_handle","image_url":"?string","verified":"?boolean","name":"?string","domain":"Content","type_description":"A Twitter/X account associated with a person or organization","ref_id":"96f8744c-554e-4f9b-9e10-01965d83e544","author_id":"?string"},{"icon":"NodesIcon","body":"?string","index":["name","body"],"parent":"Test","node_key":"unittest-name-file","shape":"sphere","type":"UnitTest","description_key":"body","title_key":"name","start":"?int","file":"string","name":"string","domain":"CodeArtifact","type_description":"A unit test verifying a single function or module in isolation.","ref_id":"1af06b0b-d6f1-4944-b330-89d6524f1842","end":"?int"},{"icon":"TargetIcon","index":["name","description"],"parent":"KnowledgeArtifact","node_key":"userobjective-name","shape":"sphere","type":"UserObjective","description_key":"description","title_key":"name","description":"?string","name":"string","is_muted":"?boolean","domain":"KnowledgeArtifact","type_description":"A user-defined objective or goal to track","ref_id":"2c22ea3f-a917-4116-9e01-08453ad691fd"},{"body":"?string","index":["name","body"],"parent":"CodeArtifact","node_key":"var-name-file","type":"Var","description_key":"body","title_key":"name","start":"?int","file":"string","name":"string","domain":"CodeArtifact","type_description":"A variable declaration or usage in source code, representing data storage and manipulation within a program.","ref_id":"a7f02c51-eb83-41e2-8c2c-cf8289ed8471","end":"?int"},{"link":"?string","node_key":"video-episode_title-timestamp","secondary_color":"#21B38A","sentiment_score":"?float","date":"?datetime","type":"Video","timestamp":"string","image_url":"?string","description":"?string","type_description":"A recording of moving visual images","domain":"Content","source_link":"?string","pub_key":"?string","icon":"VideoIcon","primary_color":"#1B3134","text":"?string","index":["episode_title","text","description"],"episode_title":"string","media_url":"string","parent":"Episode","show_title":"?string","shape":"sphere","num_boost":"?int","description_key":"description","title_key":"episode_title","english_translation":"?string","ref_id":"36dcd4bc-25c5-4c71-9dc8-243ca3dc7185","boost":"?int","language":"?string"},{"body":"?string","workflow_version":"?int","node_key":"workflow-workflow_id","secondary_color":"#54AC52","type":"Workflow","description":"?string","name":"?string","workflow_id":"?int","type_description":"An abstract parent for all workflow automation execution types.","domain":"Workflow","usage_count_30d":"?int","published_workflow_version_id":"?int","icon":"TaskIcon","usage_count":"?int","primary_color":"#22362A","index":["name","description","body","input_schema","output_schema"],"input_schema":"?string","output_schema":"?string","parent":"Thing","workflow_json":"?string","shape":"sphere","description_key":"description","title_key":"name","parent_version_id":"?int","ref_id":"82793c01-c13a-4175-a2fc-86df96dd421b","branch":"?string","customer_id":"?int"},{"body":"?string","node_key":"workflow_version-workflow_id-workflow_version_id","secondary_color":"#E09242","type":"Workflow_version","description":"?string","name":"?string","workflow_id":"int","type_description":"A specific version of a workflow configuration with defined steps and parameters","domain":"Workflow","published":"?boolean","icon":"WorkflowIcon","primary_color":"#392828","index":["name","description","body","input_schema","output_schema"],"workflow_version_id":"int","input_schema":"?string","output_schema":"?string","parent":"Workflow","workflow_json":"?string","shape":"sphere","published_at":"?string","description_key":"description","title_key":"name","ref_id":"cb32dd65-19cb-4e15-8af1-f2d9a9c7d013","branch":"?string","success":"?boolean","customer_id":"?int"}],"edge_schemas":[{"source":"AISystem","edge":"HAS_DEADLINE","target":"Deadline","props":{"ref_id":"bfc2d84d-c01c-4f70-92f3-1d195719a5c9"}},{"source":"AISystem","edge":"IMPACT_ASSESSED_BY","target":"ProcessingActivity","props":{"ref_id":"209ca84b-7233-4753-b756-340ceea66311"}},{"source":"AISystem","edge":"SUBJECT_TO","target":"Regulation","props":{"ref_id":"f357bdaf-2807-49ea-99b8-3752d3421bf0"}},{"source":"AISystem","edge":"VENDOR_COVERED_BY","target":"Agreement","props":{"ref_id":"22f1cdea-87a0-48ab-881e-c8998d7cd857"}},{"source":"AgentRole","edge":"HAS_SESSION","target":"AgentSession","props":{"ref_id":"ce7e4dfb-cc16-4efd-86b9-69cf7627b186"}},{"source":"AgentSession","edge":"HAS_ABSTRACTION","target":"Abstraction","props":{"ref_id":"3ba0f08b-ce81-482a-9632-af9989b0e219"}},{"source":"AgentSession","edge":"HAS_TURN","target":"Turn","props":{"ref_id":"43eca24e-e4c7-4875-bf12-cbb8667ae5b9"}},{"source":"AgentSession","edge":"NEXT","target":"AgentSession","props":{"ref_id":"0dfd7a8d-54cf-4525-8de8-68264893dcad"}},{"source":"Agreement","edge":"AMENDS","target":"Agreement","props":{"ref_id":"bc8ec2a1-21eb-4f91-8030-9f2b3dc8d5f0"}},{"source":"Agreement","edge":"APPLIED_TO","target":"ComputedFigure","props":{"ref_id":"08863969-6fbe-4d41-8ebc-ca651614907f"}},{"source":"Agreement","edge":"CONTAINS_REQUEST","target":"DDRequestItem","props":{"ref_id":"ea3b3f26-0a0c-497f-bb78-69a7225b006a"}},{"source":"Agreement","edge":"DEFINES","target":"DefinedTerm","props":{"ref_id":"d729e3cd-4e5c-41c4-a9f0-d3fe478e6e25"}},{"source":"Agreement","edge":"GOVERNED_BY_LAW","target":"Country","props":{"state":"?string","ref_id":"6a415e72-e7de-4c18-8298-aaaf310f43f6"}},{"source":"Agreement","edge":"GOVERNED_BY_LAW","target":"Location","props":{"state":"?string","ref_id":"dcc0e542-b28b-4feb-b101-35398f746170"}},{"source":"Agreement","edge":"GOVERNS","target":"IPAsset","props":{"ref_id":"22c960f6-ad5f-44b0-b778-3f012288ccaf"}},{"source":"Agreement","edge":"HAS_CLAUSE","target":"ContractClause","props":{"ref_id":"223dec2d-5c5f-4621-a4d5-344aa03cbbe5"}},{"source":"Agreement","edge":"HAS_DEADLINE","target":"Deadline","props":{"ref_id":"a55e76a6-308b-4095-8f7a-995beadc0d66"}},{"source":"Agreement","edge":"HAS_RENEWAL_ENTRY","target":"RenewalEntry","props":{"ref_id":"dec5188d-b689-499a-be2f-6cd18b3e44fc"}},{"source":"Agreement","edge":"REFERENCES","target":"Agreement","props":{"ref_id":"c936009f-4f5f-413c-859e-184ee1581b03"}},{"source":"Agreement","edge":"REVIEWED_IN","target":"TabularReviewCell","props":{"ref_id":"0f5b82a7-2e31-47a2-9a82-d175f7d45b98"}},{"source":"Belief","edge":"REFERS_TO","target":"Thing","props":{"ref_id":"4da1dfd6-4733-4944-8925-736623c1087e"}},{"source":"BenchmarkFailureCause","edge":"AFFECTS","target":"EvalRequirement","props":{"ref_id":"aef65a04-d96f-4259-b6ba-9376a4817e68"}},{"source":"Cause","edge":"CAUSE_CHILD_OF","target":"Cause","props":{"ref_id":"67466570-1d4a-4259-9785-95bab2e5ace2","cardinality":"many"}},{"source":"Check","edge":"SUPERSEDES","target":"Check","props":{"ref_id":"0a79f10f-ee33-4646-b9ed-33be78d14a67"}},{"source":"Check","edge":"TESTS","target":"Claim","props":{"ref_id":"7963757b-3491-4ce4-b068-931b047b6010"}},{"source":"Claim","edge":"ABOUT","target":"Thing","props":{"ref_id":"fc24db78-a30a-488f-aa5c-7817c0f81468"}},{"source":"Claim","edge":"CONTRADICTS","target":"Claim","props":{"ref_id":"d5881f57-b95d-4212-b8c1-612fb6c576fa"}},{"source":"Claim","edge":"DERIVED_FROM","target":"Claim","props":{"ref_id":"14ec537e-7714-4174-99eb-b258b7e6b157"}},{"source":"Claim","edge":"EVIDENCED_BY","target":"Evidence","props":{"strength":"?float","ref_id":"dc4c6f37-71dc-47f4-b6b2-0124a5b58d7b"}},{"source":"Claim","edge":"PARENT_OF","target":"Claim","props":{"ref_id":"ec7818b9-e379-4691-adf3-ab8b8407841a"}},{"source":"Claim","edge":"SOURCE","target":"Chapter","props":{"ref_id":"1824e0af-b190-4548-bf91-97615077b26c"}},{"source":"Claim","edge":"SOURCE","target":"Section","props":{"ref_id":"83315901-2e1f-427d-9c31-d93900f855e8"}},{"source":"Claim","edge":"SUPERSEDES","target":"Claim","props":{"ref_id":"30914867-6ade-4d7e-8129-990b276700c9"}},{"source":"Claim","edge":"SUPPORTS","target":"Claim","props":{"ref_id":"1b7576ee-1e9f-4f01-a035-72b239133f30"}},{"source":"Class","edge":"CALLS","target":"Class","props":{"ref_id":"162d5620-fbe6-4eee-a989-28c6fe925c11","call_start":"?int","call_end":"?int","operand":"?string"}},{"source":"Class","edge":"CONTAINS","target":"Datamodel","props":{"ref_id":"674805f9-8f7c-4cea-a949-e93fdc340d4a"}},{"source":"Class","edge":"IMPORTS","target":"Class","props":{"ref_id":"d2c979b6-259d-4231-99e7-46376c6f887e"}},{"source":"Class","edge":"OPERAND","target":"Function","props":{"ref_id":"c488fbdc-ad8f-4a3c-8662-a6a0ad69ffbf"}},{"source":"Class","edge":"PARENT_OF","target":"Class","props":{"ref_id":"710af56b-8f2f-4181-bb37-4c1dcf0708e7"}},{"source":"Clip","edge":"RELATED_TO","target":"Topic","props":{"ref_id":"6d1b3221-514e-469a-92ba-7f314f6e45de"}},{"source":"Commit","edge":"TOUCHES","target":"Concept","props":{"ref_id":"9b01949b-74c4-4422-99ef-1e7ad9f175c9"}},{"source":"Commit","edge":"TOUCHES","target":"Feature","props":{"ref_id":"bacb4e3d-aab0-4bba-a98d-622f997718ea"}},{"source":"ComputedFigure","edge":"APPLIED_TO","target":"Matter","props":{"ref_id":"db0159b0-5552-4c64-a25c-3b8063555e28"}},{"source":"ComputedFigure","edge":"CONTRADICTS","target":"Deadline","props":{"contradiction_type":"?string","ref_id":"ce7fe9ef-641a-4dde-9896-90452cd97728","contradiction_reason":"?string"}},{"source":"ComputedFigure","edge":"DERIVED_FROM","target":"Excerpt","props":{"ref_id":"e2bac065-b2f1-460d-8334-d55563d91752"}},{"source":"ComputedFigure","edge":"DERIVED_FROM","target":"Figure","props":{"ref_id":"6f83d676-a339-4a59-b880-3f5de370e76f"}},{"source":"ComputedFigure","edge":"GOVERNED_BY","target":"RegulatoryItem","props":{"ref_id":"0c0464b5-43d4-4d2a-a291-124c8519a96b"}},{"source":"ComputedFigure","edge":"HAS_COMPONENT","target":"FormulaComponent","props":{"ref_id":"4b84cc0a-f72b-4cc9-9cba-4538d8a3cee3"}},{"source":"ComputedFigure","edge":"QUANTIFIES","target":"DefinedTerm","props":{"field_name":"?string","value_as_stated":"?string","ref_id":"65dcc3b9-9a38-4e3d-b687-afe6c4d5417d","notes":"?string"}},{"source":"ComputedFigure","edge":"QUANTIFIES","target":"PlaybookEntry","props":{"field_name":"?string","value_as_stated":"?string","ref_id":"6cce34d1-2608-4a66-81eb-0e79177fe332","notes":"?string"}},{"source":"ComputedFigure","edge":"VALIDATES","target":"Figure","props":{"ref_id":"3e97ceb0-36d6-4e5c-8480-6c2a8a800a61"}},{"source":"ComputedFigure","edge":"VALIDATES","target":"TabularReviewCell","props":{"ref_id":"f3499d0f-ca47-44e3-ab77-ffb0bcf9e8f6"}},{"source":"Concept","edge":"IN_REPO","target":"Repository","props":{"ref_id":"49bbf792-f9aa-434b-b2b2-fdf2bfa6f1d6"}},{"source":"Concept","edge":"MODIFIES","target":"File","props":{"importance":"?float","ref_id":"c70e1a56-381a-4a1a-b36e-6838c286bdf1"}},{"source":"Concept","edge":"PARENT_OF","target":"Concept","props":{"ref_id":"cb56c71e-190d-40a1-a553-7abfdb89ddb6","cardinality":"many"}},{"source":"ContractClause","edge":"CONFLICTS_WITH","target":"Regulation","props":{"ref_id":"a743e310-cdc7-400e-a5a2-1b156d46d2ae"}},{"source":"ContractClause","edge":"CONTRADICTS","target":"ContractClause","props":{"ref_id":"d7d565c8-f190-4be7-b95c-0a006495e9a7"}},{"source":"ContractClause","edge":"DEFINES","target":"DefinedTerm","props":{"ref_id":"1b439211-627c-4eac-aacc-87af66663d63"}},{"source":"ContractClause","edge":"DEVIATES_FROM","target":"PlaybookEntry","props":{"ref_id":"278df46c-8d46-450e-8f93-bf15ca760e69"}},{"source":"ContractClause","edge":"HAS_EXCERPT","target":"Excerpt","props":{"ref_id":"1cf871f9-9664-4e1c-80b4-a2c246f550f1"}},{"source":"ContractClause","edge":"HAS_GAP","target":"GapItem","props":{"ref_id":"07ffd9af-7fb0-49af-a160-fa9af03ca290"}},{"source":"ContractClause","edge":"HAS_TYPE","target":"ClauseType","props":{"ref_id":"7bb7e3ae-c7a4-4517-88b0-d900f4f500c5"}},{"source":"ContractClause","edge":"SUPERSEDES","target":"ContractClause","props":{"ref_id":"d93d2267-12a8-4895-8320-62d8eb3427f0"}},{"source":"Corporation","edge":"EVIDENCED_BY","target":"Excerpt","props":{"ref_id":"237c39b6-c8f4-4219-97e8-e47026abe771"}},{"source":"Corporation","edge":"GOVERNED_BY","target":"Regulation","props":{"ref_id":"75cd81c8-064a-4489-9e95-ac8f07302834"}},{"source":"Corporation","edge":"INVOLVED_IN","target":"TimelineEntry","props":{"ref_id":"d21fe49c-cbfd-4107-8beb-2a00e1a6e37e"}},{"source":"Corporation","edge":"OUTSIDE_COUNSEL","target":"Corporation","props":{"ref_id":"181e0874-7ac6-4629-9729-72e35f61830d"}},{"source":"Corporation","edge":"REFERENCED_IN","target":"Excerpt","props":{"ref_id":"0250cbf4-d420-4e63-8d84-5b4db937f5f5"}},{"source":"Corporation","edge":"TRUSTEE_OF","target":"Entity","props":{"ref_id":"7f31b1db-2cb3-4ee0-8dd0-b5a8b43ad32b"}},{"source":"Country","edge":"HAS_CAPITAL","target":"Location","props":{"volatility":"STABLE","confidence_score":"?float","fluent":true,"invalid_at":"?datetime","temporal":true,"valid_at":"?datetime","ref_id":"57cae04d-0a5d-4cd5-a7fb-8b24ba98e15a","last_confirmed_at":"?datetime","invalidated_by":"?string","expired_at":"?datetime","cardinality":"single"}},{"source":"CriterionResult","edge":"HAS","target":"Cause","props":{"ref_id":"91f207a0-1c99-4336-a71c-dccb616b05b4","cardinality":"many"}},{"source":"CriterionResult","edge":"HAS_PROPOSED_FIX","target":"ProposedFix","props":{"volatility":"EVOLVING","ref_id":"e722f757-07cf-4827-9b94-76a936ba01e5"}},{"source":"DSARRequest","edge":"SUBMITTED_BY","target":"Person","props":{"ref_id":"0f92bb3f-c906-46cf-ae34-7c3007f1c82c"}},{"source":"DSARRequest","edge":"SUBMITTED_TO","target":"Organization","props":{"ref_id":"0705993b-a2a4-4c04-859a-acb1a99554a6"}},{"source":"DataAnomalyRecord","edge":"FLAGS","target":"Concept","props":{"anomaly_reason":"?string","ref_id":"9cd73dd2-1383-4584-b164-660ef3e34c0b","severity":"?string","flag_category":"?string"}},{"source":"DataAnomalyRecord","edge":"FLAGS","target":"ContractClause","props":{"anomaly_reason":"?string","ref_id":"fd147673-4055-4cc2-9188-d2f6c0539cb0","severity":"?string","flag_category":"?string"}},{"source":"DataAnomalyRecord","edge":"FLAGS","target":"Document","props":{"anomaly_reason":"?string","ref_id":"3bf34751-cb6a-4cc8-a6e4-fb9f40507df2","severity":"?string","flag_category":"?string"}},{"source":"DataAnomalyRecord","edge":"FLAGS","target":"Excerpt","props":{"anomaly_reason":"?string","ref_id":"b547d12c-fe1e-4ff9-a769-8815b1244e32","severity":"?string","flag_category":"?string"}},{"source":"DataAnomalyRecord","edge":"FLAGS","target":"Figure","props":{"anomaly_reason":"?string","ref_id":"7eb2aaa0-387b-4795-a05e-a0e6bd8b10ff","severity":"?string","flag_category":"?string"}},{"source":"DataAnomalyRecord","edge":"FLAGS","target":"TimelineEntry","props":{"anomaly_reason":"?string","ref_id":"f4558b0d-6273-4174-8399-a123e75b5c4c","severity":"?string","flag_category":"?string"}},{"source":"Deadline","edge":"COINCIDES_WITH","target":"Deadline","props":{"ref_id":"837b3681-0064-423a-9455-959bd7a138f2","coincidence_date":"?string","note":"?string"}},{"source":"Deadline","edge":"CONTRADICTS","target":"Deadline","props":{"contradiction_type":"?string","ref_id":"0367da1f-512d-4193-89aa-c2dc71eb6f58","contradiction_reason":"?string"}},{"source":"DefinedTerm","edge":"CONFLICTS_WITH","target":"DefinedTerm","props":{"field_name":"?string","delta":"?string","ref_id":"020f1597-fe49-4cd6-a063-11b744beed25","doc_a_value":"?string","doc_b_value":"?string"}},{"source":"DefinedTerm","edge":"LOCATED_IN","target":"Place","props":{"ref_id":"a366664d-f122-419b-b7e6-9ea56041d78a"}},{"source":"DefinedTerm","edge":"RELATED_TO","target":"Lingo","props":{"ref_id":"aa96b836-7db6-42ec-8479-f5ca14e9e1e0"}},{"source":"DefinedTerm","edge":"USED_IN","target":"Agreement","props":{"ref_id":"4c86eaaa-3b80-44c4-86d2-d6767233fd6e"}},{"source":"DefinedTerm","edge":"USED_IN","target":"Document","props":{"ref_id":"5361a962-ef96-4f58-acae-3a1055008c33"}},{"source":"DeliverableSchema","edge":"APPLIES_TO","target":"Matter","props":{"ref_id":"34b5ccbb-e0d5-41e2-8258-ed10f291403b"}},{"source":"DiligenceIssue","edge":"BLOCKS","target":"Deadline","props":{"ref_id":"ffb0007a-2e65-408d-8ee5-fedd7e67b848"}},{"source":"DiligenceIssue","edge":"EVIDENCED_BY","target":"Figure","props":{"ref_id":"a7cd9801-c826-459c-bb91-a195a2a26ed6"}},{"source":"DiligenceIssue","edge":"TRIGGERED_BY","target":"RegulatoryItem","props":{"ref_id":"a717795f-22c6-42bc-8930-d7e7a4f211cf"}},{"source":"Directory","edge":"CONTAINS","target":"File","props":{"ref_id":"2e39bb19-4cb3-4097-805c-53cd4c4149d4"}},{"source":"Doctrine","edge":"APPLIES_IN","target":"Jurisdiction","props":{"ref_id":"30fff9b4-5b30-4a82-b912-5d37fbe75bd5"}},{"source":"Doctrine","edge":"GOVERNS","target":"ClauseType","props":{"ref_id":"254fc04a-5f6d-41ff-a805-d67fa14ecf86"}},{"source":"Document","edge":"AMENDS","target":"Document","props":{"ref_id":"2acbbd4b-99d3-4eee-bd1c-1e61f890bd31"}},{"source":"Document","edge":"DEFINES","target":"DefinedTerm","props":{"ref_id":"dd5a9ba9-3cf7-459c-938a-4be6f1f1b9b8"}},{"source":"Document","edge":"HAS","target":"Section","props":{"ref_id":"36c4ba60-2141-40bf-9b4c-525bb347342e"}},{"source":"Document","edge":"HAS_COMPONENT","target":"FormulaComponent","props":{"ref_id":"47adeda8-5fc9-4b8e-9fd8-8111741de995"}},{"source":"Document","edge":"SUPPLEMENTS","target":"Document","props":{"ref_id":"8f4ad736-5252-451d-8a39-d7b5b9549f61"}},{"source":"E2etest","edge":"CALLS","target":"Function","props":{"ref_id":"ba574e17-d3a6-45fe-8fd4-6661d96733e5","call_start":"?int","call_end":"?int","operand":"?string"}},{"source":"Email","edge":"HAS","target":"Section","props":{"ref_id":"1decf156-eae4-4388-bdc4-346305208676"}},{"source":"Email","edge":"HAS_ATTACHMENT","target":"Attachment","props":{"ref_id":"62b2d6ff-a953-4cbc-845a-3d6ca0acae03"}},{"source":"Email","edge":"HAS_INVITE","target":"EmailInvite","props":{"ref_id":"ff8dddb5-eeae-448b-ac6c-f6b685f0cba6"}},{"source":"Email","edge":"PART_OF_THREAD","target":"Email","props":{"ref_id":"2b17d074-3ef8-4c8c-8183-fe0e1079fd96"}},{"source":"Email","edge":"REPLY_TO","target":"Email","props":{"ref_id":"2a3cfba7-271d-4d2c-9abf-d1543f82735d"}},{"source":"EmailInvite","edge":"LOCATED_AT","target":"Location","props":{"ref_id":"57473a78-fe24-46e0-bba4-076f650d56cf"}},{"source":"EmailInvite","edge":"RELATED_TO","target":"Event","props":{"ref_id":"bee56d0e-7682-4123-bc8a-6854b0960f57"}},{"source":"Endpoint","edge":"CALLS","target":"Function","props":{"ref_id":"15e1e05e-0651-4555-b1ab-ea5818b134f9"}},{"source":"Endpoint","edge":"HANDLER","target":"Function","props":{"ref_id":"36cc8137-09cf-4d2e-a08c-37e9767c8528"}},{"source":"Entity","edge":"INVOLVED_IN","target":"TimelineEntry","props":{"ref_id":"500aa8a8-e962-45b1-a9cc-31cba87b0b38"}},{"source":"Entity","edge":"REFERENCED_IN","target":"Excerpt","props":{"ref_id":"4cda2746-4f89-466f-8e55-87f9d8e5c31c"}},{"source":"Episode","edge":"HAS","target":"Chapter","props":{"ref_id":"23551de1-77ab-4c95-9423-221ef6089ae2"}},{"source":"Episode","edge":"HAS","target":"Clip","props":{"ref_id":"6bf30640-6a6c-42b4-9daf-a15ff1f699f1"}},{"source":"Episode","edge":"HAS","target":"Podcast","props":{"ref_id":"5143a0cc-5f02-4997-a545-31d90084a3c6"}},{"source":"Episode","edge":"HAS","target":"Video","props":{"ref_id":"5403cdc2-fcc6-4f64-90e5-1d483e98bda1"}},{"source":"Episode","edge":"MENTIONS","target":"Organization","props":{"ref_id":"963ee4c6-64e9-4280-a2a0-d3d788dfc44d"}},{"source":"Episode","edge":"MENTIONS","target":"Place","props":{"ref_id":"2f63340a-b40f-4531-a958-2faeb7f728d1"}},{"source":"Episode","edge":"MENTIONS","target":"Product","props":{"ref_id":"afb13bed-4415-48e1-908f-374fa3b2c36a"}},{"source":"Episode","edge":"MENTIONS","target":"Topic","props":{"ref_id":"58d4d5dd-c492-488f-9917-2fbada672f7f"}},{"source":"ErrorIssue","edge":"REFERENCES","target":"File","props":{"ref_id":"52eec5f9-8d1b-498b-aaf9-f1f9f81b7f37"}},{"source":"ErrorIssue","edge":"REFERENCES","target":"Function","props":{"ref_id":"05f550a4-f31b-4b9b-bfa1-8e6161b64cbc"}},{"source":"EscalationRecord","edge":"GOVERNED_BY","target":"Doctrine","props":{"ref_id":"87e22c36-e8b8-4e72-bf10-7195c7048e65"}},{"source":"EscalationRecord","edge":"TRIGGERED_BY","target":"LegalArgument","props":{"ref_id":"1b3f5dbd-648b-4d37-a16d-b6bae7c93887"}},{"source":"EvalRequirement","edge":"HAS_CRITERION_RESULT","target":"CriterionResult","props":{"ref_id":"d4606913-b5d1-4720-997e-90fa6079db85"}},{"source":"EvalRequirement","edge":"HAS_TRIGGER","target":"EvalTrigger","props":{"ref_id":"8cb0ac92-848e-4ae4-8105-28c637825619"}},{"source":"EvalSet","edge":"HAS_BASELINE_TRIGGER","target":"EvalTrigger","props":{"ref_id":"11a40913-f560-4c34-8137-049e0f7f19a4"}},{"source":"EvalSet","edge":"HAS_REQUIREMENT","target":"EvalRequirement","props":{"order":"?int","ref_id":"42fec7d0-4767-4868-81ca-dd09f634ebd7"}},{"source":"EvalSet","edge":"HAS_SUBSET","target":"EvalSet","props":{"ref_id":"d75d1988-b70a-4886-84ea-0caa440e80ac"}},{"source":"EvalSet","edge":"HAS_TRIGGER","target":"EvalTrigger","props":{"ref_id":"5d596d4a-d950-4486-be23-1e15997c4602"}},{"source":"EvalTrigger","edge":"ATTRIBUTED_TO","target":"HiveAgent","props":{"ref_id":"d6dea5d2-195e-4e93-9e30-a9ede0b18a4a"}},{"source":"EvalTrigger","edge":"EVALUATED","target":"AgentSession","props":{"ref_id":"536274c5-116b-45c4-bb6b-0a1b56a0308e"}},{"source":"EvalTrigger","edge":"HAS_OUTPUT","target":"EvalTriggerOutput","props":{"ref_id":"4f0bca60-7cdc-474e-b019-a71bc17723a8"}},{"source":"EvalTrigger","edge":"HAS_PROPOSED_FIX","target":"ProposedFix","props":{"volatility":"EVOLVING","ref_id":"bfc07a6e-9ced-4464-8dca-c6c6fbe1b7a6"}},{"source":"EvalTriggerOutput","edge":"ATTRIBUTED_TO","target":"BenchmarkFailureCause","props":{"ref_id":"865b439f-7a77-4bd3-a166-05e3e3953e2c"}},{"source":"EvalTriggerOutput","edge":"HAS_CRITERION_RESULT","target":"CriterionResult","props":{"ref_id":"03a5ea7c-ee89-4f7c-b355-3cc63965fdfb"}},{"source":"Evidence","edge":"ABOUT","target":"Thing","props":{"ref_id":"b19e33bc-a342-4b34-afb0-e697a5838ecb"}},{"source":"Evidence","edge":"HAS_SOURCE","target":"Thing","props":{"end_time":"?float","post_url":"?string","context":"?string","ref_id":"e71e06da-d9a2-4553-8204-393336eadcb8","start_time":"?float","created_at":"?datetime","message_id":"?string","page_reference":"?string","authority_level":"?string"}},{"source":"Evidence","edge":"PRODUCED_BY","target":"Check","props":{"ref_id":"52a15d77-15d6-46d6-967d-7e39102e7bd9"}},{"source":"Excerpt","edge":"CONFLICTS_WITH","target":"Excerpt","props":{"ref_id":"45cdf025-e5d9-4697-8916-80f9467d03c5"}},{"source":"Excerpt","edge":"DESCRIBES","target":"Agreement","props":{"ref_id":"2b64e4e8-4cc9-4873-862f-0e448671fc7f"}},{"source":"Excerpt","edge":"DESCRIBES","target":"Corporation","props":{"ref_id":"070b5aa9-4f49-4072-8922-87b61f6e63a3"}},{"source":"Excerpt","edge":"DESCRIBES","target":"Organization","props":{"ref_id":"e39bddf1-29fb-4865-b1ce-599c142b8da6"}},{"source":"Excerpt","edge":"EVIDENCED_BY","target":"LegalArgument","props":{"ref_id":"67dd377d-1bb3-4020-9a12-bd7a4eccf5ce"}},{"source":"Excerpt","edge":"REFERENCED_IN","target":"Person","props":{"ref_id":"25b8f668-13a9-40b5-aceb-2ea30a6696df"}},{"source":"Feature","edge":"MODIFIES","target":"File","props":{"importance":"?float","ref_id":"c249176d-0d55-4623-9750-3742fa67c7bf"}},{"source":"Figure","edge":"APPLIED_TO","target":"Matter","props":{"ref_id":"69511a87-2d74-4b62-962f-b87911f8a839"}},{"source":"Figure","edge":"CONFLICTS_WITH","target":"Figure","props":{"field_name":"?string","delta":"?string","ref_id":"8bc3847c-a077-46f8-9e02-e3bb44cf204e","doc_a_value":"?string","doc_b_value":"?string"}},{"source":"Figure","edge":"DERIVED_FROM","target":"Figure","props":{"ref_id":"9fb43204-5e27-4d98-96dd-61cef31b1d74"}},{"source":"Figure","edge":"DESCRIBES","target":"Organization","props":{"ref_id":"8a2b54fb-1f75-4090-bc65-4e33e3d468fc"}},{"source":"Figure","edge":"EXTRACTED_FROM","target":"Document","props":{"ref_id":"ad6790ce-c807-4f87-a3ef-30d64de1050a"}},{"source":"Figure","edge":"EXTRACTED_FROM","target":"Excerpt","props":{"ref_id":"a164bdc3-73a1-414e-8f56-9171173097ed"}},{"source":"Figure","edge":"EXTRACTED_FROM","target":"Section","props":{"ref_id":"da9dd4a0-36a0-41a2-8452-0e111ac18858"}},{"source":"Figure","edge":"GOVERNED_BY","target":"RegulatoryItem","props":{"ref_id":"e87d8393-b9b4-4b7b-bb7e-ef55cb8c7650"}},{"source":"Figure","edge":"QUANTIFIES","target":"PlaybookEntry","props":{"field_name":"?string","value_as_stated":"?string","ref_id":"1aca7996-efdb-4e37-8249-9371a3ee9bbf","notes":"?string"}},{"source":"Figure","edge":"QUANTIFIES","target":"Product","props":{"field_name":"?string","value_as_stated":"?string","ref_id":"e64e4f2a-9517-4569-bbed-03d6738c0f2d","notes":"?string"}},{"source":"File","edge":"CONTAINS","target":"Function","props":{"ref_id":"ef261cb7-ed12-4a93-b3d5-8730928cc908"}},{"source":"File","edge":"CONTAINS","target":"Trait","props":{"ref_id":"1bbab2ab-d8a0-4578-acd3-5795adc5f56f"}},{"source":"File","edge":"CONTAINS","target":"Var","props":{"ref_id":"35c38374-467e-4bce-8615-cb7631e34301"}},{"source":"File","edge":"IMPORTS","target":"File","props":{"ref_id":"581d4393-f5be-4136-9fb8-f0f18acc283e"}},{"source":"File","edge":"IMPORTS","target":"Library","props":{"ref_id":"d861dad0-7e9e-4fbd-8804-12536f8c5f5e"}},{"source":"Fluent","edge":"CURRENT_VALUE","target":"Thing","props":{"volatility":"EVOLVING","ref_id":"f149065f-0bc3-418b-87b6-881a6b185e30","cardinality":"single"}},{"source":"Fluent","edge":"HAS_EVENT","target":"FluentEvent","props":{"volatility":"STATIC","ref_id":"f3b10977-276d-43b6-9c53-f6cb5e4894e0","cardinality":"many"}},{"source":"FormulaComponent","edge":"DERIVED_FROM","target":"Figure","props":{"ref_id":"822fa443-6f83-410b-9507-363fad811dc6"}},{"source":"Function","edge":"CALLS","target":"Function","props":{"ref_id":"f221be8f-3833-4585-813e-52be78b8553f","call_start":"?int","call_end":"?int","operand":"?string"}},{"source":"Function","edge":"CONTAINS","target":"Datamodel","props":{"ref_id":"63cf80fa-6b21-4815-b962-f88767084250"}},{"source":"Function","edge":"CONTAINS","target":"Var","props":{"ref_id":"88155f12-bda8-4fa2-bc14-07b337fcb014"}},{"source":"Function","edge":"USES","target":"Function","props":{"ref_id":"8ced93d6-608a-43d8-9553-1b45c290396a","call_start":"?int","call_end":"?int","operand":"?string"}},{"source":"GapItem","edge":"BLOCKS","target":"AISystem","props":{"ref_id":"cbe92d6a-6c29-4205-9743-3504b8b29878"}},{"source":"GapItem","edge":"FLAGS","target":"Excerpt","props":{"undefined_term":"?string","ref_id":"566ad0c2-e819-476b-88cb-5ce7680c5f86","flag_category":"?string","gap_reason":"?string"}},{"source":"GapItem","edge":"GOVERNED_BY","target":"Regulation","props":{"ref_id":"5318adde-4c7e-4ef5-9041-6b52ada1896a"}},{"source":"GapItem","edge":"PROMPTED","target":"CommentPeriod","props":{"ref_id":"f6e07928-da14-48ec-8f1f-413c0dfbb148"}},{"source":"Generated_step","edge":"NEXT","target":"Generated_step","props":{"ref_id":"032251fd-81a6-4dc0-941a-ce5f7ac49c8c"}},{"source":"GitHubRepo","edge":"HAS","target":"Commits","props":{"ref_id":"976f4f49-aca0-4000-85f4-0906dc7bb39d"}},{"source":"GitHubRepo","edge":"HAS","target":"Issues","props":{"ref_id":"d9962b0d-7863-4d80-8f1d-386c9c5e3d62"}},{"source":"HiveAgent","edge":"HAS_PROMPT","target":"Prompt","props":{"ref_id":"a32530af-3f0e-48ff-8b80-b263ab2ed83f"}},{"source":"HiveFeature","edge":"HAS_MESSAGE","target":"HiveChatMessage","props":{"ref_id":"894aa11c-2e9e-4f7b-a9a8-c4dc65582eb6"}},{"source":"HiveFeature","edge":"HAS_TASK","target":"HiveTask","props":{"ref_id":"7b2e883c-ab57-4239-9994-5976d413ed8d"}},{"source":"HiveInitiative","edge":"HAS_MILESTONE","target":"HiveMilestone","props":{"ref_id":"32ae8f02-5fae-4bfc-9faa-c93a83bb2f02"}},{"source":"HiveInitiative","edge":"HAS_RESEARCH","target":"HiveResearch","props":{"ref_id":"5f04cef4-9209-4ac7-af09-b22c0e8e7b67"}},{"source":"HiveMilestone","edge":"HAS_RESEARCH","target":"HiveResearch","props":{"ref_id":"81453783-6945-4407-8f66-8d97c00f2d48"}},{"source":"HiveTask","edge":"HAS_MESSAGE","target":"HiveChatMessage","props":{"ref_id":"5073c5ec-e2ec-4f45-8f2c-778e46edbf98"}},{"source":"HiveTask","edge":"RESULTED_IN","target":"PullRequest","props":{"ref_id":"b1602bd9-84ca-4789-b2e9-a29e8fa3adf0"}},{"source":"HiveWorkspace","edge":"BEST_PRACTICE","target":"Concept","props":{"ref_id":"cbdeefa9-896a-44d4-9d7d-277c832d9f25"}},{"source":"HiveWorkspace","edge":"GOTCHA","target":"Concept","props":{"ref_id":"39fbd7fc-75bf-4a16-b11a-907f0a4688c6"}},{"source":"HiveWorkspace","edge":"HAS_CONCEPT","target":"Concept","props":{"ref_id":"cac9c2ea-1aec-4745-9895-a28d4faf9246"}},{"source":"HiveWorkspace","edge":"HAS_MEMBER","target":"HiveWorkspaceMember","props":{"ref_id":"0d55155b-a081-4b67-b75b-c444278ad2b4"}},{"source":"HiveWorkspace","edge":"PREFERENCE","target":"Concept","props":{"ref_id":"53fd6375-0d2d-4b73-808f-6825c6c15b2e"}},{"source":"HiveWorkspace","edge":"PROCESS","target":"Concept","props":{"ref_id":"31e7b53b-aaf7-4aa7-b723-5df7b4eaa1ab"}},{"source":"HiveWorkspaceMember","edge":"APPROVED","target":"Concept","props":{"ref_id":"d741793f-f43b-4d3f-ab33-f37dfc899148"}},{"source":"HiveWorkspaceMember","edge":"PREFERENCE","target":"Concept","props":{"ref_id":"03714449-6c25-4410-aca7-eddb21ed6d4e"}},{"source":"IPAsset","edge":"HAS_DEADLINE","target":"Deadline","props":{"ref_id":"434d38a0-3208-4afa-90cb-dcd766158ed9"}},{"source":"IPAsset","edge":"LICENSED_UNDER","target":"ContractClause","props":{"ref_id":"785487da-5911-4dc7-b0cc-ec1da1b2f8cc"}},{"source":"IPAsset","edge":"REGISTERED_IN","target":"Jurisdiction","props":{"filing_status":"?string","ref_id":"46398383-d39b-428a-8995-1b4d0638227a","filing_date":"?string","application_number":"?string"}},{"source":"IPAsset","edge":"RENEWAL_TRACKED_BY","target":"RenewalEntry","props":{"ref_id":"054b4fef-773f-46e7-abec-be3391a6f2d0"}},{"source":"IPAsset","edge":"SUPPORTED_BY","target":"Excerpt","props":{"support_type":"?string","ref_id":"8dc2e601-e309-45b0-b4f6-7957ed2dbd4b","support_reason":"?string"}},{"source":"Import","edge":"IMPORTS","target":"Class","props":{"ref_id":"462dc055-f4a7-402a-9ab4-4fa304ad802c"}},{"source":"Import","edge":"IMPORTS","target":"Datamodel","props":{"ref_id":"de64d06b-9a17-47c0-8953-7c371e54df31"}},{"source":"Import","edge":"IMPORTS","target":"Function","props":{"ref_id":"4e3203d0-3c85-449a-9a43-321013103f15"}},{"source":"Instance","edge":"OF","target":"Class","props":{"ref_id":"745d6921-045e-4e55-a56b-35030f84cfb1","call_start":"?int","call_end":"?int","operand":"?string"}},{"source":"IntegrationTest","edge":"CALLS","target":"Endpoint","props":{"ref_id":"df5b3667-bb00-4ed3-bd8e-607243e8f89e","call_start":"?int","call_end":"?int","operand":"?string"}},{"source":"IntegrationTest","edge":"CALLS","target":"Function","props":{"ref_id":"8a2a316f-e425-489b-b981-8090204ba1c7","call_start":"?int","call_end":"?int","operand":"?string"}},{"source":"Jargon","edge":"DEFINES","target":"Organization","props":{"ref_id":"83728110-7775-45e5-b084-b4a6afa316d6"}},{"source":"Jargon","edge":"DEFINES","target":"Person","props":{"ref_id":"2b1b2e2a-7369-4e60-98bc-87a6d286e0db"}},{"source":"Jargon","edge":"DEFINES","target":"Thing","props":{"ref_id":"24d9b6ab-4346-4bae-9e7d-8f29f15534ae"}},{"source":"Jargon","edge":"DEFINES","target":"Topic","props":{"ref_id":"a8f2d745-11df-4a77-9842-5ad6348ac41d"}},{"source":"Jurisdiction","edge":"PART_OF","target":"Jurisdiction","props":{"ref_id":"4cd96c6b-674f-46f4-b4f7-d61a982226b7"}},{"source":"LeaseAgreement","edge":"HAS_CLAUSE","target":"ContractClause","props":{"ref_id":"6ebb738a-a825-47c9-9b37-ab053de9bfc2"}},{"source":"LegalArgument","edge":"APPLIES_IN","target":"Jurisdiction","props":{"ref_id":"b233c7a1-7a00-4972-a02c-80b90b9e508f"}},{"source":"LegalArgument","edge":"APPLIES_TO","target":"Matter","props":{"ref_id":"1aaf8e7e-0d48-4cf9-8ad0-caae96a0727c"}},{"source":"LegalArgument","edge":"BLOCKS","target":"Deadline","props":{"ref_id":"4309fdc8-da66-4f5b-9294-7f73d72e9929"}},{"source":"LegalArgument","edge":"CONTRADICTS","target":"LegalArgument","props":{"ref_id":"1cfb7e8a-37bb-4870-9149-6504b74a1dff"}},{"source":"LegalArgument","edge":"GROUNDED_IN","target":"Doctrine","props":{"ref_id":"3e79fd6c-f6fd-45f2-95fc-68e71c915829"}},{"source":"LegalArgument","edge":"REFERENCES_DATE","target":"Deadline","props":{"ref_id":"f459b3a8-67fc-4fe5-ac69-7c29ba45cef5"}},{"source":"LegalArgument","edge":"SUPPORTED_BY","target":"Excerpt","props":{"ref_id":"27fba9fc-78b6-467e-8071-189d38d514fc"}},{"source":"LegalArgument","edge":"SUPPORTED_BY","target":"FormulaComponent","props":{"support_type":"?string","ref_id":"4ceb522f-0934-49fb-bd4f-788ad7dce399","support_reason":"?string"}},{"source":"LegalArgument","edge":"SUPPORTED_BY","target":"LegalArgument","props":{"ref_id":"e885870c-a2ec-4f99-a4b3-b6ed12d27163"}},{"source":"LegalArgument","edge":"SUPPORTED_BY","target":"TimelineEntry","props":{"support_type":"?string","ref_id":"8d0e9511-1a0e-4091-840a-ca8e065d97bf","support_reason":"?string"}},{"source":"LegalHold","edge":"IS_CUSTODIAN","target":"Person","props":{"ref_id":"b50c7e74-71b5-4794-8b6c-1d98a9f720fa"}},{"source":"LegalParty","edge":"REPRESENTS_ORG","target":"Organization","props":{"ref_id":"55e3c758-ca01-4c40-a33e-2fcee51e5e2d"}},{"source":"LegalParty","edge":"REPRESENTS_PERSON","target":"Person","props":{"ref_id":"30d73f9b-2b77-4cdc-b65b-a7915d767bd0"}},{"source":"LimitationStatute","edge":"APPLIES_IN","target":"Jurisdiction","props":{"ref_id":"48bb5691-f214-4a5c-bf89-98b867d6f887"}},{"source":"Lingo","edge":"RELATED_TO","target":"Lingo","props":{"ref_id":"8b0603f3-5522-40f4-9376-7ed69569cc2a"}},{"source":"Location","edge":"HAS_WEATHER","target":"Thing","props":{"volatility":"VOLATILE","confidence_score":"?float","invalid_at":"?datetime","temporal":true,"valid_at":"?datetime","ref_id":"41f7b326-4862-4856-81ff-106586cbe0ec","last_confirmed_at":"?datetime","invalidated_by":"?string","expired_at":"?datetime","cardinality":"single"}},{"source":"Location","edge":"MENTIONED_IN","target":"Section","props":{"ref_id":"87bba499-48ff-4a11-8e00-f9cc4d321d9b"}},{"source":"Matter","edge":"ASSIGNED_TO","target":"Person","props":{"ref_id":"0dd0bbae-c1a1-482b-ae3c-395ebba2dc80"}},{"source":"Matter","edge":"CONCERNS","target":"IPAsset","props":{"ref_id":"f0261684-1775-4a9c-9bfc-3c3d422dcca8"}},{"source":"Matter","edge":"CONTAINS_REQUEST","target":"DDRequestItem","props":{"ref_id":"520aeb11-8824-4f1f-bd41-2ab222d3929f"}},{"source":"Matter","edge":"FILED_IN","target":"Jurisdiction","props":{"ref_id":"d5a4e919-3eab-4753-84b8-559d35118c99"}},{"source":"Matter","edge":"HAS_ARGUMENT","target":"LegalArgument","props":{"ref_id":"07736aea-a61d-448b-b6ac-5f4275fcddf6"}},{"source":"Matter","edge":"HAS_CONFLICT_CHECK","target":"ConflictCheck","props":{"ref_id":"cc7343c4-659c-4021-becf-ef0d84c709de"}},{"source":"Matter","edge":"HAS_DEADLINE","target":"Deadline","props":{"ref_id":"d9781ea8-2f36-4621-8c93-4625aa2db362"}},{"source":"Matter","edge":"HAS_DILIGENCE_ISSUE","target":"DiligenceIssue","props":{"ref_id":"f85ab362-b75a-48a9-9b1f-a98b190d418a"}},{"source":"Matter","edge":"HAS_ESCALATION","target":"EscalationRecord","props":{"ref_id":"21158a8c-59cb-4f05-9556-45b2efe11d4e"}},{"source":"Matter","edge":"HAS_INVESTIGATION","target":"InvestigationLog","props":{"ref_id":"9dfb693a-e7b3-433f-8535-1a207be3d09a"}},{"source":"Matter","edge":"HAS_LEGAL_HOLD","target":"LegalHold","props":{"ref_id":"c3174343-8f92-4dd7-9b96-0f4e7a8e1cac"}},{"source":"Matter","edge":"HAS_PARTY","target":"LegalParty","props":{"ref_id":"9ad28e08-adfd-4236-817a-fb42ebf93670"}},{"source":"Matter","edge":"HAS_TIMELINE_ENTRY","target":"TimelineEntry","props":{"volatility":"STABLE","confidence_score":"?float","invalid_at":"?datetime","temporal":true,"valid_at":"?datetime","ref_id":"1c0521db-37e5-430e-bb13-700bdca2ab84","last_confirmed_at":"?datetime","invalidated_by":"?string","expired_at":"?datetime"}},{"source":"Matter","edge":"INVOLVES_PARTY","target":"Organization","props":{"ref_id":"bef4c280-0d8a-49ef-96de-08218024d580"}},{"source":"Matter","edge":"OUTSIDE_COUNSEL","target":"Organization","props":{"ref_id":"2a7381bf-cb01-4b62-843a-a933f5b378f8"}},{"source":"Message","edge":"RELATED_TO","target":"Topic","props":{"ref_id":"c7271484-894a-4b91-8610-7211fb92f833"}},{"source":"Organization","edge":"BACKED_BY","target":"Organization","props":{"ref_id":"563e643a-8d43-4795-9e09-9ab6ea2a0a8c"}},{"source":"Organization","edge":"CONDUCTS","target":"ProcessingActivity","props":{"ref_id":"124a65f7-a774-484c-ba42-3efd839cfe4a"}},{"source":"Organization","edge":"CONFLICTS_WITH","target":"Organization","props":{"doc_b_name":"?string","entity_reference":"?string","doc_a_source":"?string","ref_id":"9d851420-5a01-451e-ad2e-0eea97c77e64","doc_b_source":"?string","conflict_type":"?string","doc_a_name":"?string"}},{"source":"Organization","edge":"FINANCES","target":"Organization","props":{"amount":"?string","financing_type":"?string","ref_id":"46681ac2-7e56-4756-804d-5d12ea37e4b3"}},{"source":"Organization","edge":"HAS","target":"Repository","props":{"ref_id":"a3c4cf30-a912-405b-9d3b-510dc958a64d"}},{"source":"Organization","edge":"HAS_JURISDICTION","target":"Jurisdiction","props":{"ref_id":"97e6147d-9ab7-4477-be9a-a64b8e99cfb2"}},{"source":"Organization","edge":"HAS_OFFICE_IN","target":"Place","props":{"ref_id":"7bb26425-ba31-444e-ad2d-8294b4618ef8"}},{"source":"Organization","edge":"HOLDS_SHARES_IN","target":"Corporation","props":{"ref_id":"6c723ae8-e641-4038-8daa-b716e2723d8d"}},{"source":"Organization","edge":"IDENTIFIED_AS","target":"TwitterAccount","props":{"ref_id":"ebc97663-4168-46b4-bf7d-e8052d168767"}},{"source":"Organization","edge":"INCORPORATED_IN","target":"Country","props":{"ref_id":"89052d86-ed5f-4a4c-8cf2-41ceba749bc2"}},{"source":"Organization","edge":"IS_PARTY_TO","target":"Agreement","props":{"ref_id":"ae9c5bbc-fbbc-4d26-9c23-e05a66e26941"}},{"source":"Organization","edge":"IS_PARTY_TO","target":"LeaseAgreement","props":{"ref_id":"838e5d51-5375-4ace-bd78-8dadc15f1a9c"}},{"source":"Organization","edge":"MENTIONED_IN","target":"Agreement","props":{"ref_id":"6368bed1-66bc-47ec-aa47-da060017e386"}},{"source":"Organization","edge":"MENTIONED_IN","target":"Section","props":{"ref_id":"ca372235-3bcd-43a4-98e2-e34c995a1ade"}},{"source":"Organization","edge":"NOMINEE_FOR","target":"Person","props":{"ref_id":"ad98ad97-4e3a-4db4-a7e9-441ad7aa2fde"}},{"source":"Organization","edge":"OPERATES","target":"AISystem","props":{"ref_id":"b614da8e-be3f-4d67-93d4-c6d215862709"}},{"source":"Organization","edge":"OWNS","target":"IPAsset","props":{"ref_id":"e4d52a6d-1bdd-43bf-a710-30311327129d"}},{"source":"Organization","edge":"REGISTERED_AGENT_OF","target":"Corporation","props":{"ref_id":"93a73a1d-81df-41b0-a808-b78c162e9a2f"}},{"source":"Page","edge":"RENDERS","target":"Function","props":{"ref_id":"86016133-64c8-47ba-9466-487edc177e8e"}},{"source":"Person","edge":"ACCEPTED","target":"EmailInvite","props":{"ref_id":"0cc03b61-a81b-47da-9352-7d0c5bb17751"}},{"source":"Person","edge":"AUTHORED_BY","target":"Document","props":{"ref_id":"1c7e5314-f352-4af2-a687-642e9ce1e7eb"}},{"source":"Person","edge":"BELIEVES","target":"Belief","props":{"ref_id":"0a675e4c-22b7-4f39-adcf-8083ac9f2eaf"}},{"source":"Person","edge":"BENEFICIAL_OWNER_OF","target":"Corporation","props":{"ref_id":"52e0beca-7782-4fdb-8249-8a8560e40a14"}},{"source":"Person","edge":"BORN_ON","target":"Thing","props":{"volatility":"STATIC","confidence_score":"?float","invalid_at":"?datetime","temporal":true,"valid_at":"?datetime","ref_id":"01146b8d-6fba-4568-b44f-fc00c7b50d85","last_confirmed_at":"?datetime","invalidated_by":"?string","expired_at":"?datetime","cardinality":"single"}},{"source":"Person","edge":"CC_ON","target":"Email","props":{"ref_id":"b6178c78-7f1e-45dd-8473-17cee3e78b9e"}},{"source":"Person","edge":"COMPLAINANT_IN","target":"InvestigationLog","props":{"ref_id":"74fd1460-060c-4336-89c9-c4588e56f0a3"}},{"source":"Person","edge":"CONFLICTS_WITH","target":"Person","props":{"doc_b_name":"?string","entity_reference":"?string","doc_a_source":"?string","ref_id":"7580ca16-6934-49d4-88ab-d3fdecca1e64","doc_b_source":"?string","conflict_type":"?string","doc_a_name":"?string"}},{"source":"Person","edge":"DECLINED","target":"EmailInvite","props":{"ref_id":"43fc6f77-fe05-43dc-9182-1d5ae205eca4"}},{"source":"Person","edge":"DIRECTOR_OF","target":"Corporation","props":{"ref_id":"7f35c801-ef6a-48ef-86e6-6e70dfb80094"}},{"source":"Person","edge":"EMPLOYED_BY","target":"Organization","props":{"volatility":"EVOLVING","confidence_score":"?float","invalid_at":"?datetime","temporal":true,"valid_at":"?datetime","ref_id":"808bcf4e-fbc9-412a-9581-9e7c835c7c22","last_confirmed_at":"?datetime","invalidated_by":"?string","expired_at":"?datetime"}},{"source":"Person","edge":"HAS_JURISDICTION","target":"Country","props":{"ref_id":"db515035-9bf3-416c-a1c7-2e21237b16d2"}},{"source":"Person","edge":"HOLDS_SHARES_IN","target":"Corporation","props":{"ref_id":"99827a3d-8207-4356-a959-f3eb1de30688"}},{"source":"Person","edge":"IDENTIFIED_AS","target":"TwitterAccount","props":{"ref_id":"decff953-2493-47aa-ba00-9ca9f38fc42e"}},{"source":"Person","edge":"INVENTED","target":"IPAsset","props":{"ref_id":"3b40b8ca-95ce-4e56-ab28-7ff303026f3a"}},{"source":"Person","edge":"INVITED_TO","target":"EmailInvite","props":{"ref_id":"444810bf-5a78-4d9b-8838-0d7c35fff09b"}},{"source":"Person","edge":"INVOLVED_IN","target":"TimelineEntry","props":{"ref_id":"b5ee66fc-cba4-4cf0-9622-01624f90166e"}},{"source":"Person","edge":"IS_GUEST","target":"Episode","props":{"ref_id":"fd4766a0-082e-4ca5-8a0b-c358a1335257"}},{"source":"Person","edge":"IS_HOST","target":"Episode","props":{"ref_id":"26e7be2d-18de-4cda-ba46-53d8a173de05"}},{"source":"Person","edge":"IS_PARTY_TO","target":"Agreement","props":{"ref_id":"edab5904-037a-4c65-97a8-cb5993c7fe54"}},{"source":"Person","edge":"IS_SPEAKER","target":"Episode","props":{"ref_id":"305b9541-0006-4075-9e88-fd711f4d1671"}},{"source":"Person","edge":"MADE_CLAIM","target":"Claim","props":{"ref_id":"e509bd4e-c33e-472e-a734-fcc42774869f"}},{"source":"Person","edge":"MENTIONED","target":"Episode","props":{"ref_id":"2040360d-78f9-405e-ae86-980042d3f7f9"}},{"source":"Person","edge":"MENTIONED_IN","target":"Agreement","props":{"ref_id":"5ab5df36-8151-403a-9829-08872d45df5d"}},{"source":"Person","edge":"MENTIONED_IN","target":"Section","props":{"ref_id":"5457a4f7-b2b5-45db-b785-60aadb693c74"}},{"source":"Person","edge":"OFFICER_OF","target":"Corporation","props":{"ref_id":"ff4fa570-ee51-456c-a508-66851e9d1b97"}},{"source":"Person","edge":"ORGANIZED","target":"EmailInvite","props":{"ref_id":"bb8820bc-9214-411c-b7b9-e2dfd0c73574"}},{"source":"Person","edge":"POSTED","target":"Tweet","props":{"ref_id":"9c86a75f-bf88-4aa6-bd7f-ba94111af71f"}},{"source":"Person","edge":"RECEIVED","target":"Email","props":{"ref_id":"24a6afbc-535e-4f75-94ee-77dc9c3d3884"}},{"source":"Person","edge":"REFERENCED_IN","target":"Excerpt","props":{"ref_id":"0798600b-1540-4324-8e26-626279598035"}},{"source":"Person","edge":"RESPONDENT_IN","target":"InvestigationLog","props":{"ref_id":"e7f7a0da-fdc7-4d8d-ba71-f48301659749"}},{"source":"Person","edge":"SAME_AS","target":"Person","props":{"basis":"?string","doc_b_name":"?string","ref_id":"9d91368d-1b03-49f0-85b1-00c8cc6676a4","doc_a_name":"?string"}},{"source":"Person","edge":"SENT","target":"Email","props":{"ref_id":"a9e89780-2acb-4e63-a9af-d0f898798fac"}},{"source":"Person","edge":"SENT","target":"Message","props":{"ref_id":"47592886-1f38-443a-b653-2b544a8e5a31"}},{"source":"Person","edge":"TENTATIVE","target":"EmailInvite","props":{"ref_id":"54c96cfa-24e3-4cd8-bc16-e990284220e9"}},{"source":"Person","edge":"WORKS_AT","target":"Organization","props":{"volatility":"EVOLVING","confidence_score":"?float","fluent":true,"invalid_at":"?datetime","temporal":true,"valid_at":"?datetime","ref_id":"e0963238-68fa-46a9-84ca-71a5ea3ea62a","last_confirmed_at":"?datetime","invalidated_by":"?string","expired_at":"?datetime","cardinality":"single"}},{"source":"PlaybookEntry","edge":"APPLIES_IN","target":"Jurisdiction","props":{"ref_id":"06eb7475-d515-4c11-8e15-3ce056c84614"}},{"source":"PlaybookEntry","edge":"CALIBRATED_FOR","target":"Matter","props":{"ref_id":"8c432863-f91b-400a-91c2-51b78fddc86e"}},{"source":"PlaybookEntry","edge":"EVIDENCED_BY","target":"Excerpt","props":{"ref_id":"06b032f8-4d88-409c-aedb-f6c7dc36b41a"}},{"source":"PlaybookEntry","edge":"GOVERNED_BY","target":"Regulation","props":{"ref_id":"1c0d688c-c041-428e-81cc-03904bd11b0a"}},{"source":"PlaybookEntry","edge":"HAS_COMPONENT","target":"FormulaComponent","props":{"ref_id":"edfdd5a8-68ba-4a3b-8090-2f2a89218d1d"}},{"source":"PlaybookEntry","edge":"HAS_DEADLINE","target":"Deadline","props":{"ref_id":"3883f8e3-f19f-48de-8e56-e8b13fcbd00a"}},{"source":"Podcast","edge":"RELATED_TO","target":"Topic","props":{"ref_id":"d7872ab6-abe8-4806-a354-3110c1bd99ea"}},{"source":"Policy","edge":"HAS_CLAUSE","target":"PlaybookEntry","props":{"ref_id":"e2f694a3-8bf8-4fed-99e4-401cdeb228e7"}},{"source":"Policy","edge":"HAS_GAP","target":"GapItem","props":{"ref_id":"22480af1-55d7-4207-b0e0-f66d727cf35e"}},{"source":"Policy","edge":"IMPLEMENTS","target":"Regulation","props":{"ref_id":"aca113aa-cbe4-4e47-a9dd-3698c6d2ff03"}},{"source":"ProcessingActivity","edge":"GOVERNED_BY","target":"Regulation","props":{"ref_id":"b3d4902c-00f5-44eb-bf9f-28fa2b993434"}},{"source":"Product","edge":"MENTIONED_IN","target":"Section","props":{"ref_id":"8d1a2321-9325-4d6b-a5df-b93dbaa65480"}},{"source":"ProposedFix","edge":"DERIVED_FROM","target":"ProposedFix","props":{"ref_id":"f0db92c8-58f8-45c6-ade8-0553cbd0c377"}},{"source":"ProposedFix","edge":"PRODUCED_BY","target":"EvalTriggerOutput","props":{"ref_id":"acbd6127-92c8-4555-a47d-24a402227668"}},{"source":"ProposedFix","edge":"TARGETS_CAUSE","target":"Cause","props":{"ref_id":"40edf25c-96cd-4db9-bc8a-6df92d21ad2b","cardinality":"many"}},{"source":"ProposedFix","edge":"TARGETS_CONCEPT","target":"Concept","props":{"ref_id":"c06ce15f-f067-493e-b733-46fff1c9b200","cardinality":"many"}},{"source":"PullRequest","edge":"CREATED_BY","target":"Contributor","props":{"ref_id":"63b9fc60-3074-4044-95d1-c74d5566732b"}},{"source":"PullRequest","edge":"TOUCHES","target":"Concept","props":{"ref_id":"0f148c19-132a-4a22-8246-34d53db137fc"}},{"source":"PullRequest","edge":"TOUCHES","target":"Feature","props":{"ref_id":"8df5e682-1c79-48fe-93e3-8fc7c251c1bf"}},{"source":"Regulation","edge":"EVIDENCED_BY","target":"Excerpt","props":{"ref_id":"e1302f97-a20b-40fe-a996-4256fa8d0544"}},{"source":"Regulation","edge":"HAS_COMMENT_PERIOD","target":"CommentPeriod","props":{"ref_id":"e4cd5f7e-1675-4ef2-ad49-a0d1af68ea47"}},{"source":"Regulation","edge":"HAS_GAP","target":"GapItem","props":{"ref_id":"db90c7e9-725b-4cf6-bfa6-2c424844380c"}},{"source":"Regulation","edge":"ISSUED_UNDER","target":"RegulatoryItem","props":{"ref_id":"6987d8fd-6040-415b-9a9b-cfeef7a66a2e"}},{"source":"RegulatoryItem","edge":"EVIDENCED_BY","target":"Excerpt","props":{"ref_id":"60e71f30-9d2b-4a01-8f69-765440ef82c8"}},{"source":"Repository","edge":"HAS","target":"PullRequest","props":{"ref_id":"63836bcc-e8ae-4c4d-8b5d-63667657ed69"}},{"source":"Request","edge":"CALLS","target":"Endpoint","props":{"ref_id":"ec466219-824b-48a6-ac77-0e15fe1a51a2"}},{"source":"Run","edge":"START","target":"Run_step","props":{"ref_id":"9ff4f755-8138-4e9b-bbe2-b0810e638ae6"}},{"source":"Run_step","edge":"START","target":"Run_step","props":{"accurate":"?boolean","ref_id":"a684b113-96f1-4eab-a01a-a7c58c1d9322"}},{"source":"Section","edge":"AUTHORED_BY","target":"Person","props":{"ref_id":"c0f4ac4d-0786-42ec-99d2-05b25e7d5a35"}},{"source":"Section","edge":"HAS_COMPONENT","target":"FormulaComponent","props":{"ref_id":"346399d1-6773-46a5-ab10-748acc76cc18"}},{"source":"Section","edge":"RELATED_TO","target":"Topic","props":{"ref_id":"08dbd412-02d3-4ce8-8c2a-472df7f78cd0"}},{"source":"Show","edge":"HAS","target":"Episode","props":{"ref_id":"23541d77-d7f0-4256-88a5-604ce3468a12"}},{"source":"Show","edge":"HAS_CLAIM","target":"Claim","props":{"ref_id":"be64a895-db5e-4ed4-9784-62cc8200004c"}},{"source":"TabularReviewCell","edge":"CELL_OF","target":"Document","props":{"ref_id":"8d8247dd-ebcd-46ac-9e48-eb357663ed74"}},{"source":"TabularReviewCell","edge":"CONFLICTS_WITH","target":"TabularReviewCell","props":{"field_name":"?string","delta":"?string","ref_id":"8cd10718-a111-4f9d-97e5-d3d36cdb15aa","doc_a_value":"?string","doc_b_value":"?string"}},{"source":"Task","edge":"GLOBAL_MEMORY","target":"Memory","props":{"ref_id":"52f96a78-baf6-4402-9d98-a072d282473a"}},{"source":"Thing","edge":"HAS_FLUENT","target":"Fluent","props":{"volatility":"STATIC","ref_id":"50efb113-9403-4af4-ab78-7b1c0d4377da","cardinality":"many"}},{"source":"Thing","edge":"HAS_PRICE","target":"Thing","props":{"volatility":"INSTANTANEOUS","confidence_score":"?float","invalid_at":"?datetime","temporal":true,"valid_at":"?datetime","ref_id":"900689aa-fbaf-4fac-9d4a-5f9f81492059","last_confirmed_at":"?datetime","invalidated_by":"?string","expired_at":"?datetime","cardinality":"single"}},{"source":"TimelineEntry","edge":"CONTRADICTS","target":"TimelineEntry","props":{"contradiction_type":"?string","ref_id":"596a983c-820c-4c99-86d7-203a4453f51f","contradiction_reason":"?string"}},{"source":"Topic","edge":"MENTIONED_IN","target":"Section","props":{"ref_id":"a49ce91c-1414-47a9-b21a-ad0a9bbcc068"}},{"source":"Trait","edge":"OPERAND","target":"Function","props":{"ref_id":"1220754e-8a14-46e2-958f-a72456c4c51b"}},{"source":"Turn","edge":"NEXT","target":"Turn","props":{"ref_id":"fcb0bd8b-47c5-4529-9c61-68b61e51031c"}},{"source":"Tweet","edge":"RELATED_TO","target":"Topic","props":{"ref_id":"360fc4f1-5850-42de-932d-3e70f2cc5ad2"}},{"source":"Tweet","edge":"THREAD_PART","target":"Tweet","props":{"ref_id":"10cac5be-f244-4a69-bc02-be160f5ce66a"}},{"source":"TwitterAccount","edge":"POSTED","target":"Tweet","props":{"ref_id":"46d8fb7a-cbca-43cf-b602-7579202b3303"}},{"source":"UnitTest","edge":"CALLS","target":"Class","props":{"ref_id":"fa7c23cd-c436-4cd3-b76f-4f50b50b731f","call_start":"?int","call_end":"?int","operand":"?string"}},{"source":"UnitTest","edge":"CALLS","target":"Endpoint","props":{"ref_id":"cf9bb952-bd78-4904-b909-8b82096e37e2","call_start":"?int","call_end":"?int","operand":"?string"}},{"source":"UnitTest","edge":"CALLS","target":"Function","props":{"ref_id":"2c966683-8994-4e52-a46d-83555bd87238","call_start":"?int","call_end":"?int","operand":"?string"}},{"source":"Var","edge":"CONTAINS","target":"Var","props":{"ref_id":"7419dc30-047a-460b-9c88-b244b86ff3b7"}},{"source":"Video","edge":"RELATED_TO","target":"Topic","props":{"ref_id":"30369268-5a01-4c90-9c52-b60e002ef609"}},{"source":"Workflow_version","edge":"START","target":"Generated_step","props":{"ref_id":"923240c0-8f03-4571-95cf-74bc58ecb5a8"}}],"hidden_domains":["Scratchpad"]}; diff --git a/src/graph/schema-crud.test.ts b/src/graph/schema-crud.test.ts index 8abf4c9..0776b5f 100644 --- a/src/graph/schema-crud.test.ts +++ b/src/graph/schema-crud.test.ts @@ -80,19 +80,19 @@ describe("createNodeSchema (live Neo4j)", { skip: cfg ? false : "STRUT_TEST_NEO4 it("a new type is writable through the NodeWriter, with constraint + index, CHILD_OF, and inherited attributes", async () => { // Unknown before. - await assert.rejects(nodes.write({ type: "Evidence", data: { name: "e" } }, "create"), (e: any) => e.code === "UNKNOWN_TYPE"); + await assert.rejects(nodes.write({ type: "FieldNote", data: { name: "e" } }, "create"), (e: any) => e.code === "UNKNOWN_TYPE"); const r = await createNodeSchema(bolt, resolver, { - type: "Evidence", + type: "FieldNote", attributes: { description: "string", content: "?string", evidence_status: "?string", strength: "?float" }, node_key: "description", title_key: "description", description_key: "content", - type_description: "A planned or collected piece of evidence", + type_description: "A planned or collected field note", }); assert.equal(r.created, true); - assert.equal(r.type, "Evidence"); + assert.equal(r.type, "FieldNote"); assert.equal(r.parent, "Thing"); - assert.equal(r.node_key, "evidence-description"); + assert.equal(r.node_key, "fieldnote-description"); assert.deepEqual(r.added, []); // `description` is a jarvis dual-use (core) name: the resolver reports it // optional, and its presence is enforced through the node_key instead. @@ -101,55 +101,55 @@ describe("createNodeSchema (live Neo4j)", { skip: cfg ? false : "STRUT_TEST_NEO4 assert.equal(r.attributes.name, "?string", "Thing's name inherited (optional, as for every jarvis type)"); assert.equal(r.attributes.content, "?string"); - const w = await nodes.write({ type: "Evidence", data: { description: "Falkor docs mention vector indexes", evidence_status: "planned" } }, "create"); + const w = await nodes.write({ type: "FieldNote", data: { description: "Falkor docs mention vector indexes", evidence_status: "planned" } }, "create"); assert.equal(w.outcome, "created"); - assert.equal(w.node_key, "evidence-falkordocsmentionvectorindexes"); + assert.equal(w.node_key, "fieldnote-falkordocsmentionvectorindexes"); const got = await reader.getNode(w.ref_id); - assert.equal(got?.node_type, "Evidence"); - await assert.rejects(nodes.write({ type: "Evidence", data: { description: "x", bogus: 1 } }, "create"), (e: any) => e.code === "UNKNOWN_ATTRIBUTE"); - await assert.rejects(nodes.write({ type: "Evidence", data: { content: "no description" } }, "create"), (e: any) => e.code === "MISSING_REQUIRED"); + assert.equal(got?.node_type, "FieldNote"); + await assert.rejects(nodes.write({ type: "FieldNote", data: { description: "x", bogus: 1 } }, "create"), (e: any) => e.code === "UNKNOWN_ATTRIBUTE"); + await assert.rejects(nodes.write({ type: "FieldNote", data: { content: "no description" } }, "create"), (e: any) => e.code === "MISSING_REQUIRED"); const names = await schemaObjectNames(bolt); - assert.ok(names.constraints.includes("unique_evidence_node_key"), names.constraints.join(",")); - const chain = await bolt.run(`MATCH (s:Schema {type: "Evidence"})-[:CHILD_OF]->(p:Schema) RETURN p.type AS p, s.domain AS d, s.index AS i`); + assert.ok(names.constraints.includes("unique_fieldnote_node_key"), names.constraints.join(",")); + const chain = await bolt.run(`MATCH (s:Schema {type: "FieldNote"})-[:CHILD_OF]->(p:Schema) RETURN p.type AS p, s.domain AS d, s.index AS i`); assert.equal(chain[0]!["p"], "Thing"); assert.equal(chain[0]!["d"], "entity"); assert.deepEqual(chain[0]!["i"], ["description"]); // The ontology read surface sees it. const listed = await reader.listSchemas(); - assert.ok(listed.schemas.some((s) => s.type === "Evidence")); + assert.ok(listed.schemas.some((s) => s.type === "FieldNote")); }); it("an edge schema between a new type and a seeded one lets the edge write through", async () => { - const claim = await nodes.write({ type: "Claim", data: { name: "c", claim_text: "Falkor supports vector search", speaker_name: "anon" } }, "create"); - const ev = await nodes.write({ type: "Evidence", data: { description: "release notes" } }, "create"); + const claim = await nodes.write({ type: "Claim", data: { id: "c1", name: "c", claim_text: "Falkor supports vector search", speaker_name: "anon" } }, "create"); + const ev = await nodes.write({ type: "FieldNote", data: { description: "release notes" } }, "create"); await assert.rejects(edges.write({ edge: "EVIDENCED_BY", source_ref_id: claim.ref_id, target_ref_id: ev.ref_id }), (e: any) => e.code === "WRONG_TYPE"); - await resolver.createEdgeSchema("Claim", "EVIDENCED_BY", "Evidence"); + await resolver.createEdgeSchema("Claim", "EVIDENCED_BY", "FieldNote"); const e = await edges.write({ edge: "EVIDENCED_BY", source_ref_id: claim.ref_id, target_ref_id: ev.ref_id, properties: { strength: 0.6 } }); assert.equal(e.created, true); }); it("extends an existing jarvis type add-only, with cache invalidation, and refuses Strut types", async () => { - // Claim (from the seeded ontology) has no verdict. - await assert.rejects(nodes.write({ type: "Claim", data: { name: "c2", claim_text: "t", speaker_name: "s", verdict: "unknown" } }, "create"), (e: any) => e.code === "UNKNOWN_ATTRIBUTE"); + // Claim (from the seeded ontology) has no review_note. + await assert.rejects(nodes.write({ type: "Claim", data: { id: "c2", name: "c2", claim_text: "t", review_note: "unknown" } }, "create"), (e: any) => e.code === "UNKNOWN_ATTRIBUTE"); const before = await bolt.run(`MATCH (s:Schema {type: "Claim"}) RETURN s.ref_id AS r, s.node_key AS k, s.claim_text AS ct`); const r = await createNodeSchema(bolt, resolver, { type: "claim", // case-insensitive, adopts live casing - parent: "Content", - attributes: { verdict: "?string", confidence_score: "?float", claim_text: "?string" /* existing: left alone */ }, + parent: "Thing", + attributes: { review_note: "?string", review_score: "?float", claim_text: "?string" /* existing: left alone */ }, node_key: "name", // ignored on extend }); assert.equal(r.created, false); assert.equal(r.type, "Claim"); assert.equal(r.ref_id, before[0]!["r"]); - assert.deepEqual(r.added, ["confidence_score", "verdict"]); + assert.deepEqual(r.added, ["review_note", "review_score"]); assert.equal(r.node_key, before[0]!["k"], "identity untouched"); assert.equal(r.attributes.claim_text, "string", "existing attribute not downgraded to optional"); - assert.equal(r.attributes.verdict, "?string"); - const w = await nodes.write({ type: "Claim", data: { name: "c2", claim_text: "t", speaker_name: "s", verdict: "unknown", confidence_score: 0.5 } }, "create"); + assert.equal(r.attributes.review_note, "?string"); + const w = await nodes.write({ type: "Claim", data: { id: "c2", name: "c2", claim_text: "t", review_note: "unknown", review_score: 0.5 } }, "create"); assert.equal(w.outcome, "created"); // Nothing to add → still not created, empty added. - const again = await createNodeSchema(bolt, resolver, { type: "Claim", attributes: { verdict: "?string" } }); + const again = await createNodeSchema(bolt, resolver, { type: "Claim", attributes: { review_note: "?string" } }); assert.deepEqual([again.created, again.added], [false, []]); await assert.rejects(createNodeSchema(bolt, resolver, { type: "StrutRun", attributes: { x: "string" } }), (e: any) => e.code === "UNKNOWN_TYPE"); diff --git a/src/graph/schema-resolver.test.ts b/src/graph/schema-resolver.test.ts index 22fd13f..308d3b3 100644 --- a/src/graph/schema-resolver.test.ts +++ b/src/graph/schema-resolver.test.ts @@ -20,7 +20,7 @@ const cfg = testGraphConfig(); describe("ontology fixture (pure)", () => { it("is the jarvis default library with its wildcard sentinel", () => { - assert.equal(JARVIS_ONTOLOGY.schemas.length, 151); + assert.equal(JARVIS_ONTOLOGY.schemas.length, 153); assert.ok(JARVIS_ONTOLOGY.schemas.some((s) => s["type"] === "*")); for (const t of ["Thing", "Document", "Concept", "EvalSet", "EvalRequirement", "EvalTrigger", "EvalTriggerOutput", "CriterionResult", "ScratchpadEntry"]) { assert.ok(JARVIS_ONTOLOGY.schemas.some((s) => s["type"] === t), t); @@ -46,7 +46,7 @@ describe("jarvis ontology + resolver + jarvis-typed writes (live Neo4j)", { skip await bolt.verify(); await wipeGraph(bolt); const r = await seedJarvisOntology(bolt); - assert.equal(r.createdSchemas.length, 151); + assert.equal(r.createdSchemas.length, 153); assert.ok(r.createdEdgeSchemas >= 300, `edge schemas ${r.createdEdgeSchemas}`); assert.ok(r.domains.includes("content") && r.domains.includes("legal")); await seedStrutDomain(bolt); diff --git a/src/graph/schema-seed.test.ts b/src/graph/schema-seed.test.ts index 7e49583..fcb868d 100644 --- a/src/graph/schema-seed.test.ts +++ b/src/graph/schema-seed.test.ts @@ -12,7 +12,7 @@ describe("strut-schemas (pure)", () => { it("library is well-formed", () => { assertLibraryWellFormed(); assert.equal(STRUT_SCHEMAS.length, 9); - assert.equal(STRUT_EDGES.length, 14); + assert.equal(STRUT_EDGES.length, 15); }); it("flattens attributes onto the top level with no attributes blob", () => { diff --git a/src/graph/strut-schemas.ts b/src/graph/strut-schemas.ts index 64d854e..31e4a5a 100644 --- a/src/graph/strut-schemas.ts +++ b/src/graph/strut-schemas.ts @@ -356,6 +356,9 @@ export const STRUT_EDGES: readonly StrutEdgeDef[] = [ { edge: "DEPENDS_ON", source: "StrutWorkflowVersion", target: "StrutWorkflow" }, { edge: "PUBLISHED_BY", source: "StrutStepVersion", target: "Person", note: "jarvis type; seeded only when Person exists" }, { edge: "EXECUTED", source: "StrutRun", target: "StrutWorkflowVersion" }, + // Single-step runs (`run_step`), projected only when evidence attaches + // (plans/claims.md §3). + { edge: "EXECUTED", source: "StrutRun", target: "StrutStepVersion" }, { edge: "PROMOTED_FROM", source: "StrutWorkflowVersion", target: "StrutRun" }, { edge: "IN_RUN", source: "StrutAgentSession", target: "StrutRun" }, { edge: "IN_SESSION", source: "StrutToolCall", target: "StrutAgentSession" }, diff --git a/src/graph/workspace-store.ts b/src/graph/workspace-store.ts index a36b5e2..c4d6480 100644 --- a/src/graph/workspace-store.ts +++ b/src/graph/workspace-store.ts @@ -153,6 +153,11 @@ export class Neo4jWorkspaceStore implements WorkspaceStore { ); } + /** `WorkspaceStore.graph` — what turns the claims layer on. */ + get graph(): GraphBackend { + return this.backend; + } + // ── Reads: workflows ─────────────────────────────────────────────────── private async workflowRow(name: string): Promise { diff --git a/src/index.ts b/src/index.ts index b0749cb..86a227d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -262,6 +262,40 @@ export { } from "./graph/strut-schemas.js"; export { seedStrutDomain, type SeedReport } from "./graph/schema-seed.js"; export { migrateVeinToStrut, VeinMigrationCollision, type VeinMigrationReport } from "./graph/vein-migration.js"; +export { upgradeClaimSchema, CLAIM_SCHEMA_UPGRADE_ID, type ClaimSchemaUpgradeReport } from "./graph/claim-schema-upgrade.js"; +// The truth layer — claims, checks, evidence (plans/claims.md). +export { + ClaimsReader, + claimsReaderFor, + claimStatus, + newEpistemicId, + isEpistemicId, + evidenceId, + isExternalCheck, + subjectName, + CLAIM_TYPE, + CHECK_TYPE, + EVIDENCE_TYPE, + CLAIM_EDGES, + DEFAULT_FRESHNESS_DAYS, + type SubjectRef, + type VersionRef, + type ClaimRow, + type CheckRow, + type EvidenceRow, + type ClaimStatus, + type ClaimStatusValue, + type ClaimStatusInput, + type SubjectLedgerRow, + type RunCheckSubject, + type PublishCheckSubject, + type CheckResult, + type SourceContext, + type RunWhen, + type CheckPolicy, + type EvidenceMode, + type EvidenceStatus, +} from "./graph/claims.js"; export { NodeWriter, GraphValidationError, diff --git a/src/workspace.ts b/src/workspace.ts index f519d8e..76cf591 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -3,6 +3,8 @@ import { dirname, join, relative, sep } from "node:path"; import yaml from "js-yaml"; import { z } from "zod"; import type { Flow } from "./core.js"; +// Type-only: the graph backend stays a lazy, opt-in dependency. +import type { GraphBackend } from "./graph/backend.js"; import type { SubflowResolver } from "./runner.js"; import { readStepSourceFromDisk, type StepSource } from "./steps/registry.js"; import { contentHash, nextVersionLabel } from "./version.js"; @@ -226,6 +228,11 @@ export interface StepListEntry { * importable directory (`materializeCustomSteps`) for the module loader. */ export interface WorkspaceStore extends SubflowResolver { + /** The graph backend behind this store, when workflows/steps live in one + * (`Neo4jWorkspaceStore`). The claims layer hangs off the subjects' graph + * nodes, so it exists only when this is set (plans/claims.md). */ + readonly graph?: GraphBackend; + // ── Workflows ── /** Every workflow with its version list. `lastRunAt` is NOT populated * here (runs belong to the run store — the server composes it). */ From f1d346dae1471e73c2da3ae7ed0f46b2472e2aaf Mon Sep 17 00:00:00 2001 From: Evanfeenstra Date: Thu, 17 Sep 2026 11:53:16 -0700 Subject: [PATCH 02/11] claims step 2: run.start records executed step versions; run_step leaves a record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only runs recorded after this can ever be verified (plans/claims.md §3): evidence is ABOUT the exact version observed, and a workflow version does not pin its steps, so without a record the executed step version is unrecoverable once the step is republished. - run.start gains `stepHashes`, `cassette`, `origin` (RunEvent + RunOptions). stepHashes is re-recorded on `run.resumed`: a resume loads whatever is active THEN - closure.ts: walkSteps / flowClosure (loop+foreach bodies, onError, nested subflows through the workspace, agentTools grants; a templated or missing child makes it unresolvable) and stepHashesFor — the active hash of every workspace step in reach, or EVERY active hash when unresolvable (a superset is still true; a subset would lose a version). validate.ts and the agent step now share its walker and glob. Step 4's check closure reuses it - WorkspaceStore.getActiveStepHashes() on both stores (+ conformance); wired into all four launch sites (HTTP detached, strut.run, the authoring capability, the chat tool) - run stores: `step:` and `check:` keys map to steps//runs/ and checks//runs/ — the prefix is parsed, never written to disk, and path-traversal segments are refused. No workflow listing can see them. MemoryRunStore no longer lists `step:clip/trim` runs under `step:clip` - run-step.ts: runStep behind the chat tool, meta/run-step and POST /steps/:type/run. Still executes in memory; persisted under `step:` only when the step has an active claim or `keep: true`. Results gain `runId` and `kept`. An unreachable claims graph never fails the run - projector: projectRun (one run -> its StrutRun ref) with EXECUTED -> StrutStepVersion for a step key, read from the recorded hash — never "whatever is active". projectRuns is behaviourally unchanged - get_step.recentRuns; meta run reads of a `step:` key are scoped by the step's publisher --- AGENTS.md | 4 +- package.json | 2 +- plans/claims.md | 13 ++- src/ai/tools.ts | 42 +++++--- src/authoring.ts | 53 +++++++--- src/closure.test.ts | 113 ++++++++++++++++++++ src/closure.ts | 132 ++++++++++++++++++++++++ src/core.ts | 14 +++ src/createStrut.test.ts | 49 +++++++++ src/createStrut.ts | 45 +++++--- src/graph/projector.test.ts | 36 ++++++- src/graph/projector.ts | 130 ++++++++++++++--------- src/graph/workspace-store.ts | 13 +++ src/index.ts | 12 +++ src/run-step.test.ts | 137 ++++++++++++++++++++++++- src/run-step.ts | 106 ++++++++++++++++++- src/runner.ts | 17 ++- src/steps/core/agent.ts | 9 +- src/steps/lib/meta/run-step.ts | 4 +- src/store.ts | 63 ++++++++++-- src/test-util/workspace-conformance.ts | 6 ++ src/validate.ts | 10 +- src/workspace.ts | 25 +++++ 23 files changed, 909 insertions(+), 126 deletions(-) create mode 100644 src/closure.test.ts create mode 100644 src/closure.ts diff --git a/AGENTS.md b/AGENTS.md index e4ac0a5..a6d8c8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,9 @@ strut/ │ ├── runner.ts # execution engine: DAG (topological), retry, onError, control flow, journal replay │ ├── run-control.ts # RunController: cooperative cancel/pause/resume for run TREES (RUN_CONTROL_SPEC.md) │ ├── journal.ts # resume journal: step.end outputs → {path→output}; `from` invalidation -│ ├── store.ts # RunStore interface (writes + reads + tail) + FileRunStore + MemoryRunStore + tailJsonl / tailFromPolling +│ ├── store.ts # RunStore interface (writes + reads + tail) + FileRunStore + MemoryRunStore + tailJsonl / tailFromPolling. Keys are workflow names, plus two non-workflow buckets no workflow listing can see: `step:` → steps//runs/ (kept run_step runs), `check:` → checks//runs/ (paid check runs) +│ ├── closure.ts # what a flow can EXECUTE: walkSteps (loop/foreach bodies, onError), flowClosure (nested subflows via the workspace, agentTools grants; templated/missing child → unresolvable), stepHashesFor → run.start.stepHashes +│ ├── run-step.ts # runSingleStep (one step, in memory, optional cassette) + runStep — the run_step surfaces: records stepHashes, then persists the run under `step:` only when the step has claims or `keep: true` (plans/claims.md §3) │ ├── chat-store.ts # ChatStore interface + FileChatStore + MemoryChatStore (chats//: meta.json + messages.jsonl + events.jsonl) + truncateToolMessages │ ├── workspace.ts # WorkspaceStore interface + FileWorkspaceStore (alias WorkspaceManager): versioning, _metadata.json, YAML loading │ ├── storage-conformance.test.ts # the storage boundary's spec: one suite per layer, run over every impl diff --git a/package.json b/package.json index 56bb615..5ef1d8c 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "package:desktop": "node scripts/package-desktop.mjs", "dev": "npm run build:web && tsx --env-file=.env src/server.ts", "start": "node build/server.js", - "test": "tsx --test src/expr.test.ts src/core.test.ts src/runner.test.ts src/run-control.test.ts src/control-flow.test.ts src/store.test.ts src/workspace.test.ts src/storage-conformance.test.ts src/integration.test.ts src/services.test.ts src/cassette.test.ts src/run-step.test.ts src/createStrut.test.ts src/authoring.test.ts src/ai-integration.test.ts src/chat-store.test.ts src/chat-endpoints.test.ts src/llm.test.ts src/pricing.test.ts src/steps/registry.test.ts src/steps/core/agent.test.ts src/steps/core/pack.test.ts src/steps/core/exec.test.ts src/steps/core/llm.test.ts src/auth.test.ts src/secret-store.test.ts src/artifacts.test.ts src/slack.test.ts src/gdrive.test.ts src/html-extract.test.ts src/shell.test.ts src/validate.test.ts src/model-dir.test.ts src/audio/hotwords.test.ts src/audio/stt.test.ts src/audio/ws.test.ts web/src/run-inputs.test.ts", + "test": "tsx --test src/expr.test.ts src/core.test.ts src/runner.test.ts src/run-control.test.ts src/control-flow.test.ts src/store.test.ts src/workspace.test.ts src/storage-conformance.test.ts src/integration.test.ts src/services.test.ts src/cassette.test.ts src/run-step.test.ts src/closure.test.ts src/createStrut.test.ts src/authoring.test.ts src/ai-integration.test.ts src/chat-store.test.ts src/chat-endpoints.test.ts src/llm.test.ts src/pricing.test.ts src/steps/registry.test.ts src/steps/core/agent.test.ts src/steps/core/pack.test.ts src/steps/core/exec.test.ts src/steps/core/llm.test.ts src/auth.test.ts src/secret-store.test.ts src/artifacts.test.ts src/slack.test.ts src/gdrive.test.ts src/html-extract.test.ts src/shell.test.ts src/validate.test.ts src/model-dir.test.ts src/audio/hotwords.test.ts src/audio/stt.test.ts src/audio/ws.test.ts web/src/run-inputs.test.ts", "test:stt": "STRUT_TEST_STT=1 tsx --test src/audio/stt.live.test.ts", "test:graph": "tsx --test --test-concurrency=1 \"src/graph/*.test.ts\" \"src/steps/lib/graph/*.test.ts\"" }, diff --git a/plans/claims.md b/plans/claims.md index 4536b1e..be60340 100644 --- a/plans/claims.md +++ b/plans/claims.md @@ -848,7 +848,18 @@ number exists). already-seeded standalone DBs (§1) + the one `STRUT_EDGES` row; `claims.ts` with `claimStatus()` and read helpers; graph-backend gate in createStrut; unit tests. -2. `run.start` gains `stepHashes` / `cassette` / `origin` (§3) — FIRST, since +2. **Done** — `src/closure.ts` (`flowClosure`, `stepHashesFor`; step 4's + check closure reuses it), `WorkspaceStore.getActiveStepHashes()`, the + three `run.start` fields (`stepHashes` is filtered to the flow's closure, + every active hash when that is unresolvable, and is RE-recorded on + `run.resumed` — a resume loads whatever is active then), `runStep` in + `run-step.ts` behind all three `run_step` surfaces (`keep`, result gains + `runId` + `kept`), the `step:` / `check:` buckets in both run stores, + `projectRun` (one run → its `StrutRun` ref, with `EXECUTED → + StrutStepVersion` for a step key), `get_step.recentRuns`, and + `meta/list-runs` / `meta/get-run` on a `step:` key scoped by the step's + publisher. + `run.start` gains `stepHashes` / `cassette` / `origin` (§3) — FIRST, since only runs recorded after it can ever be verified; then `run_step` persists under `step:` (§3) + the projector pair. 3. Authoring tools + `claims` arg + publish count + prompt section (§2), diff --git a/src/ai/tools.ts b/src/ai/tools.ts index 430a690..4c8efc2 100644 --- a/src/ai/tools.ts +++ b/src/ai/tools.ts @@ -4,8 +4,10 @@ import { runWorkflow } from "../runner.js"; import { AiDeps } from "./prompts.js"; import { lsSteps, searchSteps, readStepSource } from "./stepHelpers.js"; import { stepSchemas } from "./schemaHelpers.js"; -import { runSingleStep, cassettePath } from "../run-step.js"; -import { generateRunId } from "../store.js"; +import { runStep, cassettePath } from "../run-step.js"; +import { stepHashesFor } from "../closure.js"; +import { claimsReaderFor } from "../graph/claims.js"; +import { generateRunId, stepRunKey } from "../store.js"; import { formatValidationErrors, validateWorkflowYaml } from "../validate.js"; // The shared authoring core — the same mechanism the meta/* steps' capability // sits on (see authoring.ts): publish checks + strict load-verification, and @@ -103,7 +105,7 @@ export function buildTools(deps: AiDeps) { get_step: tool({ description: - "Read a step type's docs before using it: `description` (what it does + a YAML example), `input` (JSON Schema of its config — every field's meaning, default, enum and nesting), and `output` (JSON Schema of what it returns, for {{ id.field }} templates; absent when the step's output is untyped — the description then states the shape). Pass source:true ONLY to author or edit a step (read a custom step before edit_step; mirror a lib step's implementation) — it adds the full TypeScript source of a lib/custom step, which you don't need to use the step in a workflow. Core steps have no source.", + "Read a step type's docs before using it: `description` (what it does + a YAML example), `input` (JSON Schema of its config — every field's meaning, default, enum and nesting), and `output` (JSON Schema of what it returns, for {{ id.field }} templates; absent when the step's output is untyped — the description then states the shape). Pass source:true ONLY to author or edit a step (read a custom step before edit_step; mirror a lib step's implementation) — it adds the full TypeScript source of a lib/custom step, which you don't need to use the step in a workflow. Core steps have no source. `recentRuns` (when present) counts this step's KEPT run_step runs — read them with list_runs / get_run using the name `step:`.", inputSchema: z.object({ type: z.string().describe("Step type, e.g. 'http' or 'github/fetch-pr'"), source: z @@ -116,11 +118,14 @@ export function buildTools(deps: AiDeps) { if (!def) { return { error: `Step type "${type}" not found` }; } + const recentRuns = (await deps.store.listRuns(stepRunKey(type))).length; return { type, description: def.description, ...stepSchemas(def), ...(source ? { source: (await readStepSource(type, deps)) ?? null } : {}), + // Kept single-step runs: list_runs / get_run on the key `step:`. + ...(recentRuns ? { recentRuns } : {}), }; }, }), @@ -457,6 +462,7 @@ export function buildTools(deps: AiDeps) { controller: tracked?.controller, workflowHash: (await deps.workspace.getWorkflowHash(name, version)) ?? undefined, + stepHashes: await stepHashesFor(deps.workspace, flow), }).finally(() => tracked?.untrack()); // No detach seam (tests / non-chat embedders) → await as before. @@ -495,7 +501,7 @@ export function buildTools(deps: AiDeps) { description: "Run a SINGLE step in isolation with a given config + input, and return its output + events — WITHOUT wiring it into a workflow. This is the inner loop for authoring an adapter: create_step → run_step → edit_step → run_step until the output is right. " + "Set cassette:'record' to run live AND capture the step's external service calls (http, etc.) to a reusable fixture (secrets are scrubbed); then cassette:'replay' to iterate OFFLINE against that fixture — deterministic, no rate limits, no cost, no side effects (so you don't, e.g., create a real charge on every test). " + - "Returns { status, output?, error?, events, recorded? }.", + "Returns { runId, status, output?, error?, events, recorded?, kept? } — `kept` is the run-store key (`step:`) when the run was persisted.", inputSchema: z.object({ type: z.string().describe("Step type to run, e.g. 'stripe/list-charges' or 'http'."), config: z @@ -518,22 +524,30 @@ export function buildTools(deps: AiDeps) { .string() .optional() .describe("Fixture name (defaults to the step type). Use distinct names to keep multiple scenarios per step."), + keep: z.boolean().optional().describe('Persist this run under the run-store key `step:` (read it back with list_runs / get_run on that key). Runs of a step that has claims are kept automatically — they can become evidence; set this to keep a run of a step that has none.'), }), - execute: async ({ type, config, input, params, cassette, cassetteName }) => { + execute: async ({ type, config, input, params, cassette, cassetteName, keep }) => { const registry = deps.registry; if (!registry[type]) return { error: `Step type "${type}" not found` }; if (cassette && !deps.dataDir) { return { error: "Cassette record/replay is unavailable (no local data dir configured)." }; } - return runSingleStep(type, registry, deps.services, { - config: coerceJsonArg(config) as Record | undefined, - input: coerceJsonArg(input), - params: coerceJsonArg(params) as Record | undefined, - workspace: deps.workspace, - ...(cassette - ? { cassette: { mode: cassette, path: cassettePath(deps.dataDir!, cassetteName ?? type) } } - : {}), - }); + return runStep( + type, + registry, + deps.services, + { + config: coerceJsonArg(config) as Record | undefined, + input: coerceJsonArg(input), + params: coerceJsonArg(params) as Record | undefined, + workspace: deps.workspace, + keep: keep === true, + ...(cassette + ? { cassette: { mode: cassette, path: cassettePath(deps.dataDir!, cassetteName ?? type) } } + : {}), + }, + { store: deps.store, workspace: deps.workspace, claims: claimsReaderFor(deps.workspace) }, + ); }, }), diff --git a/src/authoring.ts b/src/authoring.ts index f9553c1..d9eef5b 100644 --- a/src/authoring.ts +++ b/src/authoring.ts @@ -2,9 +2,11 @@ import { join } from "node:path"; import type { RunEvent, RunResult, RunSummary, StepRegistry } from "./core.js"; import type { WorkspaceStore } from "./workspace.js"; import type { RunStore } from "./store.js"; -import { generateRunId } from "./store.js"; +import { generateRunId, stepRunKey, stepTypeOfRunKey } from "./store.js"; import { runWorkflow } from "./runner.js"; -import { runSingleStep, cassettePath, type RunStepResult } from "./run-step.js"; +import { runStep, cassettePath, type RunStepResult } from "./run-step.js"; +import { stepHashesFor } from "./closure.js"; +import { claimsReaderFor } from "./graph/claims.js"; import { stepLoadError } from "./steps/registry.js"; import type { CassetteMode } from "./cassette.js"; import type { SecretInfo } from "./secret-store.js"; @@ -339,6 +341,8 @@ export interface RunStepArgs { params?: Record; cassette?: CassetteMode; cassetteName?: string; + /** Persist the run under `step:` even when the step has no claims. */ + keep?: boolean; } /** @@ -416,6 +420,13 @@ export function buildAuthoringCapability(deps: AuthoringDeps): AuthoringCapabili * published (EVOLVE_SPEC §6). Returns an error message, or null when the * workflow exists and is stamped. */ const notOwned = async (name: string, verb: string): Promise => { + // `step:` — a step's kept single-step runs (plans/claims.md §3): + // same scoping, on the step's publisher stamp. + const stepType = stepTypeOfRunKey(name); + if (stepType) { + const owned = (await workspace.listSteps({ publisher: AI_PUBLISHER })).some((s) => s.type === stepType); + return owned ? null : `Step "${stepType}" is not agent-authored — the meta surface only ${verb} steps it published.`; + } const entry = await findWorkflow(name); if (!entry) return `Workflow "${name}" not found`; if (entry.publisher !== AI_PUBLISHER) { @@ -441,11 +452,13 @@ export function buildAuthoringCapability(deps: AuthoringDeps): AuthoringCapabili const d = await explorerDeps(); const def = d.registry[type]; if (!def) return { error: `Step type "${type}" not found` }; + const recentRuns = (await store.listRuns(stepRunKey(type))).length; return { type, description: def.description, ...stepSchemas(def), ...(opts?.source ? { source: (await readStepSource(type, d)) ?? null } : {}), + ...(recentRuns ? { recentRuns } : {}), }; }, @@ -486,20 +499,27 @@ export function buildAuthoringCapability(deps: AuthoringDeps): AuthoringCapabili if (args.cassette && !deps.dataDir) { return { error: "Cassette record/replay is unavailable (no local data dir configured)." }; } - return runSingleStep(type, registry, deps.services, { - config: coerceJsonArg(args.config) as Record | undefined, - input: coerceJsonArg(args.input), - params: coerceJsonArg(args.params) as Record | undefined, - workspace, - ...(args.cassette - ? { - cassette: { - mode: args.cassette, - path: cassettePath(deps.dataDir!, args.cassetteName ?? type), - }, - } - : {}), - }); + return runStep( + type, + registry, + deps.services, + { + config: coerceJsonArg(args.config) as Record | undefined, + input: coerceJsonArg(args.input), + params: coerceJsonArg(args.params) as Record | undefined, + workspace, + keep: args.keep === true, + ...(args.cassette + ? { + cassette: { + mode: args.cassette, + path: cassettePath(deps.dataDir!, args.cassetteName ?? type), + }, + } + : {}), + }, + { store, workspace, claims: claimsReaderFor(workspace) }, + ); }, async listWorkflows() { @@ -597,6 +617,7 @@ export function buildAuthoringCapability(deps: AuthoringDeps): AuthoringCapabili controller: tracked?.controller, workflowHash: (await workspace.getWorkflowHash(flow.name, version)) ?? undefined, + stepHashes: await stepHashesFor(workspace, flow), }); } finally { tracked?.untrack(); diff --git a/src/closure.test.ts b/src/closure.test.ts new file mode 100644 index 0000000..fecba1d --- /dev/null +++ b/src/closure.test.ts @@ -0,0 +1,113 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { z } from "zod"; +import type { Flow, Step, RunEvent } from "./core.js"; +import { defineStep } from "./core.js"; +import { closureIncludes, flowClosure, globToRegExp, stepHashesFor, walkSteps } from "./closure.js"; +import { runWorkflow } from "./runner.js"; +import { MemoryRunStore } from "./store.js"; + +const step = (id: string, type: string, config: Record = {}, options?: Step["options"]): Step => ({ id, type, config, ...(options ? { options } : {}) }); +const flow = (name: string, steps: Step[]): Flow => ({ name, input: z.any(), steps }); + +/** A resolver over a fixed set of flows; `wf@v2` keys a specific version. */ +const resolver = (flows: Record) => ({ + getWorkflow: async (name: string) => flows[name] ?? Promise.reject(new Error(`no ${name}`)), + getWorkflowVersion: async (name: string, version: string) => flows[`${name}@${version}`] ?? Promise.reject(new Error(`no ${name}@${version}`)), +}); + +describe("flowClosure", () => { + it("walks loop/foreach bodies and onError handlers", () => { + const seen: string[] = []; + walkSteps( + [ + step("a", "clip/fetch", {}, { onError: step("fix", "log") }), + step("each", "foreach", { body: step("b", "loop", { body: step("c", "clip/trim") }) }), + step("notbody", "exec", { body: step("x", "never/visited") }), + ], + (s) => seen.push(s.type), + ); + assert.deepEqual(seen, ["clip/fetch", "log", "foreach", "loop", "clip/trim", "exec"]); + }); + + it("follows nested subflows through the workspace, pinned versions included, cycles tolerated", async () => { + const flows = { + child: flow("child", [step("s", "stt/transcribe"), step("again", "subflow", { workflow: "parent" })]), + "pinned@v2": flow("pinned", [step("j", "llm")]), + parent: flow("parent", [step("c", "subflow", { workflow: "child" }), step("p", "subflow", { workflow: "pinned", version: "v2" })]), + }; + const c = await flowClosure(flows.parent, resolver(flows)); + assert.deepEqual([...c.types].sort(), ["llm", "stt/transcribe", "subflow"]); + // Depth-first: child, then what child reaches (back to parent — visited once), then pinned. + assert.deepEqual(c.workflows, [{ workflow: "child" }, { workflow: "parent" }, { workflow: "pinned", version: "v2" }]); + assert.equal(c.resolvable, true); + }); + + it("collects agentTools grants verbatim; closureIncludes expands globs", async () => { + const c = await flowClosure(flow("f", [step("a", "agent", { agentTools: ["clip/*", "http"] })])); + assert.deepEqual([...c.agentTools], ["clip/*", "http"]); + assert.ok(closureIncludes(c, "agent") && closureIncludes(c, "http") && closureIncludes(c, "clip/trim")); + assert.ok(!closureIncludes(c, "clipper/trim") && !closureIncludes(c, "gaia/evaluate")); + assert.ok(globToRegExp("meta/*").test("meta/run-step") && !globToRegExp("meta/*").test("xmeta/run")); + }); + + it("a templated or missing child, or templated agentTools, makes it unresolvable — what was found is kept", async () => { + const templated = await flowClosure(flow("f", [step("a", "exec"), step("s", "subflow", { workflow: "{{ input.name }}" })]), resolver({})); + assert.deepEqual([templated.resolvable, [...templated.types].sort()], [false, ["exec", "subflow"]]); + assert.equal((await flowClosure(flow("f", [step("s", "subflow", { workflow: "gone" })]), resolver({}))).resolvable, false); + assert.equal((await flowClosure(flow("f", [step("s", "subflow", { workflow: "child", version: "{{ params.v }}" })]), resolver({}))).resolvable, false); + assert.equal((await flowClosure(flow("f", [step("s", "subflow", { workflow: "child" })]))).resolvable, false, "no workspace to resolve through"); + assert.equal((await flowClosure(flow("f", [step("a", "agent", { agentTools: "{{ params.tools }}" })]))).resolvable, false); + assert.equal((await flowClosure(flow("f", [step("a", "agent", { agentTools: ["{{ params.ns }}/*"] })]))).resolvable, false); + }); +}); + +describe("stepHashesFor", () => { + const active = { "clip/fetch": "aaaaaaaaaaaa", "clip/trim": "bbbbbbbbbbbb", "stt/transcribe": "cccccccccccc", "other/unused": "dddddddddddd" }; + const ws = (flows: Record) => ({ ...resolver(flows), getActiveStepHashes: async () => active }); + + it("records exactly the workspace steps in reach — by name, through a subflow, or granted to an agent", async () => { + const flows = { child: flow("child", [step("s", "stt/transcribe")]) }; + const f = flow("f", [step("a", "clip/fetch"), step("c", "subflow", { workflow: "child" }), step("g", "agent", { agentTools: ["clip/tr*"] }), step("h", "http")]); + assert.deepEqual(await stepHashesFor(ws(flows), f), { "clip/fetch": "aaaaaaaaaaaa", "clip/trim": "bbbbbbbbbbbb", "stt/transcribe": "cccccccccccc" }); + }); + + it("an unresolvable closure records every active hash — a superset is still true, a subset loses a version", async () => { + assert.deepEqual(await stepHashesFor(ws({}), flow("f", [step("s", "subflow", { workflow: "{{ input.wf }}" })])), active); + }); + + it("built-in-only flows, an empty workspace, no workspace, or a failing read → undefined (the run still launches)", async () => { + assert.equal(await stepHashesFor(ws({}), flow("f", [step("h", "http")])), undefined); + assert.equal(await stepHashesFor({ ...resolver({}), getActiveStepHashes: async () => ({}) }, flow("f", [step("a", "clip/fetch")])), undefined); + assert.equal(await stepHashesFor(undefined, flow("f", [step("a", "clip/fetch")])), undefined); + const err = console.error; + console.error = () => {}; + try { + assert.equal(await stepHashesFor({ ...resolver({}), getActiveStepHashes: async () => Promise.reject(new Error("down")) }, flow("f", [step("a", "clip/fetch")])), undefined); + } finally { + console.error = err; + } + }); +}); + +describe("run.start carries stepHashes / cassette / origin", () => { + const noop = defineStep({ type: "noop", input: z.any(), output: z.any(), run: async () => ({ ok: true }) }); + const run = async (opts: Parameters[3]) => { + const events: RunEvent[] = []; + await runWorkflow(flow("wf", [step("a", "noop")]), {}, { noop } as never, { store: new MemoryRunStore(), onEvent: (e) => void events.push(e), ...opts }); + return events; + }; + + it("all three land on run.start, and are absent when not given", async () => { + const start = (await run({ stepHashes: { noop: "abc" }, cassette: "replay", origin: "verify" })).find((e) => e.type === "run.start")!; + assert.deepEqual([start.stepHashes, start.cassette, start.origin], [{ noop: "abc" }, "replay", "verify"]); + const bare = (await run({})).find((e) => e.type === "run.start")!; + assert.ok(!("stepHashes" in bare) && !("cassette" in bare) && !("origin" in bare)); + }); + + it("a resume re-records stepHashes on run.resumed — steps load at relaunch", async () => { + const events = await run({ resume: true, journal: {}, stepHashes: { noop: "def" } }); + assert.equal(events.find((e) => e.type === "run.start"), undefined); + assert.deepEqual(events.find((e) => e.type === "run.resumed")!.stepHashes, { noop: "def" }); + }); +}); diff --git a/src/closure.ts b/src/closure.ts new file mode 100644 index 0000000..56cd88a --- /dev/null +++ b/src/closure.ts @@ -0,0 +1,132 @@ +/** + * What a flow can EXECUTE, decided from its definition rather than its top + * level: the step types it names through `loop` / `foreach` bodies and + * `onError` handlers, the workflows its `subflow` steps reach (resolved + * through the workspace, transitively), and the step types it grants to + * agents as tools (`agentTools`, globs unexpanded). + * + * Two consumers (plans/claims.md): a launch records the content hash of + * every workspace step in the closure on `run.start.stepHashes` (§3), and a + * `subflow` check is opaque by type, so whether it is paid, `observed`, or + * reaches a grader is read off its closure (§4). + * + * A `subflow` whose `workflow` (or `version`) is a template, a child that + * does not resolve, or a templated `agentTools` makes the closure + * UNRESOLVABLE: what was collected is then a lower bound, and each consumer + * takes its own conservative reading. + */ +import type { Flow, Step } from "./core.js"; +import { hasTemplates } from "./expr.js"; +import type { SubflowResolver } from "./runner.js"; + +const BODY_STEPS = new Set(["loop", "foreach"]); + +/** Every step in a list, descending `loop` / `foreach` bodies and `onError` + * handlers — the order `validate.ts` reports types in. */ +export function walkSteps(steps: readonly Step[], fn: (step: Step) => void): void { + const visit = (s: Step | undefined) => { + if (!s || typeof s !== "object" || typeof s.type !== "string") return; + fn(s); + const body = (s.config as Record | undefined)?.["body"] as Step | undefined; + if (BODY_STEPS.has(s.type)) visit(body); + visit(s.options?.onError); + }; + steps.forEach(visit); +} + +/** Compile a glob pattern (`*` = any run of characters) to an anchored + * RegExp — the `agentTools` grammar (`"jarvis/*"`). */ +export function globToRegExp(pattern: string): RegExp { + const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, (c) => (c === "*" ? ".*" : `\\${c}`)); + return new RegExp(`^${escaped}$`); +} + +export interface FlowClosure { + /** Step types named anywhere in the closure (built-in and custom alike). */ + types: Set; + /** `agentTools` entries granted anywhere in the closure, verbatim. */ + agentTools: Set; + /** Child workflows reached through `subflow`, in discovery order. */ + workflows: Array<{ workflow: string; version?: string }>; + /** False = a lower bound (see module doc). */ + resolvable: boolean; +} + +export async function flowClosure(flow: Pick, workspace?: SubflowResolver): Promise { + const out: FlowClosure = { types: new Set(), agentTools: new Set(), workflows: [], resolvable: true }; + const seen = new Set(); + + const visitFlow = async (steps: readonly Step[]): Promise => { + const children: Array<{ workflow: string; version?: string }> = []; + walkSteps(steps, (s) => { + out.types.add(s.type); + const cfg = (s.config ?? {}) as Record; + const tools = cfg["agentTools"]; + if (Array.isArray(tools)) { + for (const t of tools) { + if (typeof t === "string" && !hasTemplates(t)) out.agentTools.add(t); + else out.resolvable = false; + } + } else if (tools !== undefined && tools !== null) out.resolvable = false; + if (s.type !== "subflow") return; + const wf = cfg["workflow"]; + const version = cfg["version"]; + const literal = (v: unknown): v is string => typeof v === "string" && v.length > 0 && !hasTemplates(v); + if (!literal(wf) || (version !== undefined && version !== null && !literal(version))) out.resolvable = false; + else children.push({ workflow: wf, ...(literal(version) ? { version } : {}) }); + }); + for (const c of children) { + const key = `${c.workflow}@${c.version ?? ""}`; + if (seen.has(key)) continue; + seen.add(key); + out.workflows.push(c); + try { + if (!workspace) throw new Error("no workspace"); + const child = c.version ? await workspace.getWorkflowVersion(c.workflow, c.version) : await workspace.getWorkflow(c.workflow); + await visitFlow(child.steps); + } catch { + out.resolvable = false; + } + } + }; + + await visitFlow(flow.steps); + return out; +} + +/** Does the closure reach `type` — by name, or through an `agentTools` grant? */ +export function closureIncludes(closure: FlowClosure, type: string): boolean { + if (closure.types.has(type)) return true; + for (const grant of closure.agentTools) { + if (grant === type || (grant.includes("*") && globToRegExp(grant).test(type))) return true; + } + return false; +} + +/** + * `run.start.stepHashes` for a launch: the active content hash of every + * workspace (custom) step the flow can execute. An unresolvable closure + * records EVERY active hash — a superset is still a true record of what + * was active at launch, a subset would lose the version of a step that ran. + * Undefined when the workspace has no custom steps in reach (built-in steps + * have no versions), or when it cannot be read — a run must still launch. + */ +export async function stepHashesFor( + workspace: (SubflowResolver & { getActiveStepHashes(): Promise> }) | undefined, + flow: Pick, +): Promise | undefined> { + if (!workspace) return undefined; + try { + const active = await workspace.getActiveStepHashes(); + if (Object.keys(active).length === 0) return undefined; + const closure = await flowClosure(flow, workspace); + const out: Record = {}; + for (const [type, hash] of Object.entries(active)) { + if (!closure.resolvable || closureIncludes(closure, type)) out[type] = hash; + } + return Object.keys(out).length ? out : undefined; + } catch (err) { + console.error(`[closure] could not read step hashes — the run will carry none:`, err); + return undefined; + } +} diff --git a/src/core.ts b/src/core.ts index fa0ef88..48de7cf 100644 --- a/src/core.ts +++ b/src/core.ts @@ -170,6 +170,20 @@ export interface RunEvent { * `run.start` — resume refuses to replay a journal into a DIFFERENT DAG * (RUN_CONTROL_SPEC §5, validity guards). */ workflowHash?: string; + /** Content hash of the ACTIVE version of every workspace (custom) step + * this run can execute, keyed by step type — recorded on `run.start`, and + * again on `run.resumed` (a resume loads whatever is active THEN). A + * workflow version does not pin its steps, so this is the ONLY record of + * which step version a run executed; without an entry, the verify pass + * writes no evidence for that step (plans/claims.md §3–§4). */ + stepHashes?: Record; + /** Cassette mode the run executed under, on `run.start`; absent = live. A + * `replay` run is a unit test against a fixture: real evidence, weaker + * than live. */ + cassette?: "record" | "replay"; + /** `"verify"` on a run the verify pass launched (a check). Such runs are + * never themselves verified — the recursion guard. */ + origin?: "verify"; /** Per-run param overrides, recorded on `run.start` so a durable resume * re-executes steps with the SAME knob values the original run used. */ params?: Record; diff --git a/src/createStrut.test.ts b/src/createStrut.test.ts index edbb8bc..ab191ac 100644 --- a/src/createStrut.test.ts +++ b/src/createStrut.test.ts @@ -327,6 +327,55 @@ describe("createStrut", () => { assert.equal(missing.status, 404); }); + it("records stepHashes on every launch path, and keeps a single-step run only under step:", async () => { + const ws = new WorkspaceManager(tempDir); + await ws.publishStep( + "clip/shout", + `import { z, defineStep } from "strut"; + export default defineStep({ type: "clip/shout", input: z.object({ text: z.string() }), output: z.string(), async run(cfg) { return cfg.text.toUpperCase(); } });`, + ); + await ws.publishStep( + "clip/unused", + `import { z, defineStep } from "strut"; + export default defineStep({ type: "clip/unused", input: z.any(), output: z.any(), async run() { return 1; } });`, + ); + await ws.publishWorkflow("shouter", "v1", { steps: [{ id: "s", type: "clip/shout", config: { text: "{{ input.text }}" } }] }); + const store = new MemoryRunStore(); + const strut = await createStrut({ workspace: ws, store, serveUi: false, enableChat: false, stt: false }); + const hash = (await ws.getActiveStepHashes())["clip/shout"]!; + assert.equal(strut.claims, null, "a filesystem workspace has no claims layer"); + + // strut.run() + const direct = await strut.run("shouter", { text: "hi" }); + assert.equal(direct.output, "HI"); + const startOf = async (key: string, runId: string) => (await store.getRunEvents(key, runId)).find((e) => e.type === "run.start")!; + assert.deepEqual((await startOf("shouter", direct.runId)).stepHashes, { "clip/shout": hash }, "only the steps the flow can execute"); + + // POST /workflows/:name/run (detached) + const launched = await strut.app.request("/workflows/shouter/run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ input: { text: "yo" } }), + }); + assert.equal(launched.status, 202); + const { runId } = (await launched.json()) as { runId: string }; + await (await strut.app.request(`/workflows/shouter/runs/${runId}/stream`)).text(); // drain to completion + assert.deepEqual((await startOf("shouter", runId)).stepHashes, { "clip/shout": hash }); + + // POST /steps/:type/run — in memory unless asked (no claims here), then kept under the step key. + const post = (body: unknown) => + strut.app.request("/steps/clip/shout/run", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); + const scratch = (await (await post({ config: { text: "a" } })).json()) as { runId: string; kept?: string; events: Array<{ type: string; stepHashes?: unknown }> }; + assert.equal(scratch.kept, undefined); + assert.deepEqual(scratch.events.find((e) => e.type === "run.start")!.stepHashes, { "clip/shout": hash }); + const kept = (await (await post({ config: { text: "b" }, keep: true })).json()) as { runId: string; kept?: string; output: unknown }; + assert.deepEqual([kept.kept, kept.output], ["step:clip/shout", "B"]); + assert.deepEqual(await store.listRuns("step:clip/shout"), [kept.runId]); + assert.equal((await store.listRuns("shouter")).length, 2, "workflow run history is untouched"); + const listed = (await (await strut.app.request("/workflows")).json()) as Array<{ name: string }>; + assert.deepEqual(listed.map((w) => w.name), ["shouter"], "a step key is never a workflow"); + }); + it("exposes /steps with registered types", async () => { const myStep = defineStep({ type: "custom-thing", diff --git a/src/createStrut.ts b/src/createStrut.ts index 9c02f6c..4615bb2 100644 --- a/src/createStrut.ts +++ b/src/createStrut.ts @@ -34,7 +34,8 @@ import { MemorySecretStore, isValidSecretName, } from "./secret-store.js"; -import { runSingleStep, cassettePath } from "./run-step.js"; +import { runStep, cassettePath } from "./run-step.js"; +import { stepHashesFor } from "./closure.js"; import { buildAuthoringCapability } from "./authoring.js"; import type { CassetteMode } from "./cassette.js"; // Type-only: the graph backend stays a lazy, opt-in dependency. @@ -321,6 +322,8 @@ export async function createStrut( opts: StrutOptions = {}, ): Promise> { const workspace: WorkspaceStore = opts.workspace ?? new FileWorkspaceStore(); + // Null unless the workspace is graph-backed (see `Strut.claims`). + const claims = claimsReaderFor(workspace); // Backend mode, used ONLY to pick unspecified defaults: the run/chat/secret // stores follow the workspace's kind (file-backed → file stores under // dataDir; anything else → in-memory). No capability is gated on it — @@ -1323,10 +1326,12 @@ export async function createStrut( // Run a SINGLE step in isolation (synchronous) — the adapter author's inner // loop. Body: { config?, input?, params?, cassette?: "record"|"replay", - // cassetteName? }. With `cassette`, external `ctx.services` calls are recorded + // cassetteName?, keep? }. With `cassette`, external `ctx.services` calls are recorded // to / replayed from `steps/_cassettes/.json` (secrets scrubbed), so the - // step can be iterated offline. Returns { status, output?, error?, events, - // recorded? }. Unlike workflow runs, this awaits and returns the result. + // step can be iterated offline. Returns { runId, status, output?, error?, + // events, recorded?, kept? }. Unlike workflow runs, this awaits and returns + // the result. The run is persisted under the store key `step:` (never + // a workflow) when the step has claims or `keep` is set — `kept` names it. app.post("/steps/:type{.+}/run", async (c) => { const type = c.req.param("type"); if (!registry[type]) return c.json({ error: `Step type "${type}" not found` }, 404); @@ -1337,6 +1342,7 @@ export async function createStrut( params?: Record; cassette?: CassetteMode; cassetteName?: string; + keep?: boolean; }>() .catch(() => ({}) as Record); @@ -1345,15 +1351,22 @@ export async function createStrut( return c.json({ error: `cassette must be "record" or "replay"` }, 400); } - const result = await runSingleStep(type, registry, services, { - config: body.config, - input: body.input, - params: body.params, - workspace, - ...(mode - ? { cassette: { mode, path: cassettePath(dataDir, body.cassetteName ?? type) } } - : {}), - }); + const result = await runStep( + type, + registry, + services, + { + config: body.config, + input: body.input, + params: body.params, + workspace, + keep: body.keep === true, + ...(mode + ? { cassette: { mode, path: cassettePath(dataDir, body.cassetteName ?? type) } } + : {}), + }, + { store, workspace, claims }, + ); return c.json(result); }); @@ -1408,6 +1421,7 @@ export async function createStrut( void (async () => { const workflowHash = (await workspace.getWorkflowHash(flow.name, extra?.version)) ?? undefined; + const stepHashes = await stepHashesFor(workspace, flow); return runWorkflow(flow, body.input ?? {}, registry, { runId, store, @@ -1417,6 +1431,7 @@ export async function createStrut( paramOverrides: body.paramOverrides, controller, ...(workflowHash ? { workflowHash } : {}), + ...(stepHashes ? { stepHashes } : {}), ...(extra?.journal ? { journal: extra.journal } : {}), ...(extra?.resume ? { resume: true } : {}), }); @@ -1918,6 +1933,7 @@ export async function createStrut( typeof workflow === "string" ? ((await workspace.getWorkflowHash(workflow, runOpts?.version)) ?? undefined) : undefined; + const stepHashes = await stepHashesFor(workspace, flow); return await runWorkflow(flow, input, registry, { runId, store, @@ -1928,6 +1944,7 @@ export async function createStrut( onEvent: runOpts?.onEvent, controller, ...(workflowHash ? { workflowHash } : {}), + ...(stepHashes ? { stepHashes } : {}), }); } finally { untrack(); @@ -2012,7 +2029,7 @@ export async function createStrut( autoResumeStaleRuns, run, stt, - claims: claimsReaderFor(workspace), + claims, listen, close, }; diff --git a/src/graph/projector.test.ts b/src/graph/projector.test.ts index f05f54e..3e59cc4 100644 --- a/src/graph/projector.test.ts +++ b/src/graph/projector.test.ts @@ -7,7 +7,9 @@ import { openGraphBackend, type GraphBackend } from "./backend.js"; import { seedStrutDomain } from "./schema-seed.js"; import { testGraphConfig, wipeGraph } from "./test-util.js"; import { Neo4jWorkspaceStore } from "./workspace-store.js"; -import { messageText, preview, projectAll, projectChats, projectRunEvents, projectRuns, spawnedRunIds } from "./projector.js"; +import { messageText, preview, projectAll, projectChats, projectRun, projectRunEvents, projectRuns, spawnedRunIds } from "./projector.js"; +import { runStep } from "../run-step.js"; +import { buildRegistry } from "../steps/registry.js"; const cfg = testGraphConfig(); let backend: GraphBackend; @@ -192,6 +194,38 @@ describe("projector (live Neo4j)", { skip: cfg ? false : "STRUT_TEST_NEO4J_URI n assert.equal(await edges("ACCESSED"), 2); }); + it("projectRun: a kept single-step run gets EXECUTED → the StrutStepVersion it actually ran, from run.start.stepHashes", async () => { + const src = (tag: string) => + `import { z, defineStep } from "strut";\nexport default defineStep({ type: "clip/compute-times", description: "${tag}", input: z.any(), output: z.any(), run: async () => ({ tag: "${tag}" }) });\n`; + await ws.publishStep("clip/compute-times", src("one"), "one"); + const registry = (await buildRegistry(await ws.materializeCustomSteps())).registry; + const r = await runStep("clip/compute-times", registry, {}, { keep: true }, { store, workspace: ws, claims: null }); + assert.deepEqual([r.status, r.kept], ["success", "step:clip/compute-times"]); + const v1Hash = (await ws.getActiveStepHashes())["clip/compute-times"]!; + assert.deepEqual(r.events.find((e) => e.type === "run.start")!.stepHashes, { "clip/compute-times": v1Hash }); + + // The step is republished BEFORE the run is projected: the edge must + // still name v1 — the version recorded at launch — never "whatever is active". + await ws.publishStep("clip/compute-times", src("two"), "two"); + const runRef = await projectRun(backend, store, r.kept!, r.runId); + assert.ok(runRef); + const rows = await backend.bolt.run( + `MATCH (run:StrutRun {run_id: $id})-[:EXECUTED]->(v:StrutStepVersion) RETURN run.ref_id AS ref, run.workflow_name AS wf, run.log_ref AS log, v.content_hash AS hash, v.description AS d`, + { id: r.runId }, + ); + assert.deepEqual(rows, [{ ref: runRef, wf: "step:clip/compute-times", log: `step:clip/compute-times/${r.runId}`, hash: v1Hash, d: "one" }]); + assert.equal(await projectRun(backend, store, r.kept!, r.runId), runRef, "idempotent: same StrutRun, no second edge"); + assert.equal(await edges("EXECUTED"), 1); + assert.equal(await projectRun(backend, store, r.kept!, "nope"), null); + + // Step runs are invisible to the workflow projection, and a run with no + // recorded hash gets no EXECUTED edge at all (never a guess). + assert.equal((await projectRuns(backend, store, { workflows: [WF, "clip/compute-times"] })).runs, 0); + const blind = await runStep("clip/compute-times", registry, {}, { keep: true }, { store, claims: null }); + await projectRun(backend, store, blind.kept!, blind.runId); + assert.equal(await edges("EXECUTED"), 1); + }); + it("is idempotent, skips settled runs, and re-projects an unsettled run once it finalizes", async () => { const events = sampleEvents("h"); for (const e of events.slice(0, 7)) await store.append(WF, RUN, e); // no terminal event yet diff --git a/src/graph/projector.ts b/src/graph/projector.ts index 1d5560a..56d4761 100644 --- a/src/graph/projector.ts +++ b/src/graph/projector.ts @@ -23,7 +23,7 @@ * recording the source run. */ import type { AccessedNode, RunEvent, RunSummary } from "../core.js"; -import type { RunStore } from "../store.js"; +import { stepTypeOfRunKey, type RunStore } from "../store.js"; import type { ChatStore, StoredMessage } from "../chat-store.js"; import type { GraphBackend } from "./backend.js"; import type { NodeInput } from "./node-writer.js"; @@ -98,6 +98,7 @@ interface RunProjection { sessions: NodeInput[]; toolCalls: Array<{ node: NodeInput; sessionPath: string; accessed: AccessedNode[] }>; workflowHash?: string; + stepHashes?: Record; } /** Pure: the nodes one run contributes (no graph access). */ @@ -208,7 +209,7 @@ export function projectRunEvents(workflow: string, runId: string, events: RunEve }); } - return { run, sessions, toolCalls, workflowHash: start?.workflowHash }; + return { run, sessions, toolCalls, workflowHash: start?.workflowHash, stepHashes: start?.stepHashes }; } /** Project runs from a `RunStore` into the graph. */ @@ -235,58 +236,87 @@ export async function projectRuns(backend: GraphBackend, store: RunStore, opts: report.skipped++; continue; } - const [events, summary] = await Promise.all([store.getRunEvents(workflow, runId), store.getRunSummary(workflow, runId)]); - const p = projectRunEvents(workflow, runId, events, summary); - if (!p) continue; + await projectRun(backend, store, workflow, runId, report); + } + } + return report; +} - const nodes = [p.run, ...p.sessions, ...p.toolCalls.map((t) => t.node)]; - const written = await backend.nodes.writeMany(nodes, "upsert"); - const runRef = written[0]!.ref_id; - const sessionRef = new Map(); - p.sessions.forEach((s, i) => sessionRef.set(String(s.data["path"]).replace(/#\d+$/, ""), written[1 + i]!.ref_id)); - report.runs++; - report.sessions += p.sessions.length; - report.toolCalls += p.toolCalls.length; +/** + * Project ONE run and return its `StrutRun` ref_id (null for an unknown + * run). `workflow` is the run-store key: a workflow name, or `step:` + * for a kept single-step run — which gets `EXECUTED → StrutStepVersion` + * from `run.start.stepHashes` instead of the workflow-version edge. The + * verify pass calls this for the run it is about to attach evidence to + * (plans/claims.md §3): step runs that produced no evidence never reach the + * graph, and `projectRuns` never lists them. + */ +export async function projectRun( + backend: GraphBackend, + store: RunStore, + workflow: string, + runId: string, + report: ProjectReport = emptyReport(), +): Promise { + const ns = backend.cfg.namespace; + const [events, summary] = await Promise.all([store.getRunEvents(workflow, runId), store.getRunSummary(workflow, runId)]); + const p = projectRunEvents(workflow, runId, events, summary); + if (!p) return null; - const edges: EdgeInput[] = []; - p.sessions.forEach((s) => { - const ref = sessionRef.get(String(s.data["path"]).replace(/#\d+$/, ""))!; - edges.push({ edge: "IN_RUN", source_ref_id: ref, target_ref_id: runRef }); - }); - const toolRef = (i: number) => written[1 + p.sessions.length + i]!.ref_id; - p.toolCalls.forEach((t, i) => { - edges.push({ edge: "IN_SESSION", source_ref_id: toolRef(i), target_ref_id: sessionRef.get(t.sessionPath)! }); - }); - // ACCESSED: only toward nodes this graph actually holds (the edge - // writer treats a missing endpoint as an error, and a ref may point - // at another database or a since-deleted node). - const wanted = new Set(p.toolCalls.flatMap((t) => t.accessed.map((n) => n.ref_id))); - if (wanted.size) { - const rows = await backend.bolt.run(`MATCH (n:Data_Bank) WHERE n.ref_id IN $ids RETURN DISTINCT n.ref_id AS ref_id`, { ids: [...wanted] }); - const present = new Set(rows.map((r) => r["ref_id"] as string)); - p.toolCalls.forEach((t, i) => { - for (const n of t.accessed) { - if (present.has(n.ref_id)) { - edges.push({ edge: "ACCESSED", source_ref_id: toolRef(i), target_ref_id: n.ref_id }); - report.accessed++; - } else report.unresolved++; - } - }); - } - if (p.workflowHash) { - const rows = await backend.bolt.run( - `MATCH (v:StrutWorkflowVersion {namespace: $ns, name: $wf, content_hash: $h}) RETURN v.ref_id AS ref_id LIMIT 1`, - { ns, wf: workflow, h: p.workflowHash }, - ); - if (rows.length) edges.push({ edge: "EXECUTED", source_ref_id: runRef, target_ref_id: rows[0]!["ref_id"] as string }); - } - if (edges.length) { - await backend.edges.writeMany(edges); - report.edges += edges.length; + const nodes = [p.run, ...p.sessions, ...p.toolCalls.map((t) => t.node)]; + const written = await backend.nodes.writeMany(nodes, "upsert"); + const runRef = written[0]!.ref_id; + const sessionRef = new Map(); + p.sessions.forEach((s, i) => sessionRef.set(String(s.data["path"]).replace(/#\d+$/, ""), written[1 + i]!.ref_id)); + report.runs++; + report.sessions += p.sessions.length; + report.toolCalls += p.toolCalls.length; + + const edges: EdgeInput[] = []; + p.sessions.forEach((s) => { + const ref = sessionRef.get(String(s.data["path"]).replace(/#\d+$/, ""))!; + edges.push({ edge: "IN_RUN", source_ref_id: ref, target_ref_id: runRef }); + }); + const toolRef = (i: number) => written[1 + p.sessions.length + i]!.ref_id; + p.toolCalls.forEach((t, i) => { + edges.push({ edge: "IN_SESSION", source_ref_id: toolRef(i), target_ref_id: sessionRef.get(t.sessionPath)! }); + }); + // ACCESSED: only toward nodes this graph actually holds (the edge + // writer treats a missing endpoint as an error, and a ref may point + // at another database or a since-deleted node). + const wanted = new Set(p.toolCalls.flatMap((t) => t.accessed.map((n) => n.ref_id))); + if (wanted.size) { + const rows = await backend.bolt.run(`MATCH (n:Data_Bank) WHERE n.ref_id IN $ids RETURN DISTINCT n.ref_id AS ref_id`, { ids: [...wanted] }); + const present = new Set(rows.map((r) => r["ref_id"] as string)); + p.toolCalls.forEach((t, i) => { + for (const n of t.accessed) { + if (present.has(n.ref_id)) { + edges.push({ edge: "ACCESSED", source_ref_id: toolRef(i), target_ref_id: n.ref_id }); + report.accessed++; + } else report.unresolved++; } - } + }); } - return report; + const stepType = stepTypeOfRunKey(workflow); + const stepHash = stepType ? p.stepHashes?.[stepType] : undefined; + if (stepType && stepHash) { + const rows = await backend.bolt.run( + `MATCH (v:StrutStepVersion {namespace: $ns, step_type: $type, content_hash: $h}) RETURN v.ref_id AS ref_id LIMIT 1`, + { ns, type: stepType, h: stepHash }, + ); + if (rows.length) edges.push({ edge: "EXECUTED", source_ref_id: runRef, target_ref_id: rows[0]!["ref_id"] as string }); + } else if (p.workflowHash) { + const rows = await backend.bolt.run( + `MATCH (v:StrutWorkflowVersion {namespace: $ns, name: $wf, content_hash: $h}) RETURN v.ref_id AS ref_id LIMIT 1`, + { ns, wf: workflow, h: p.workflowHash }, + ); + if (rows.length) edges.push({ edge: "EXECUTED", source_ref_id: runRef, target_ref_id: rows[0]!["ref_id"] as string }); + } + if (edges.length) { + await backend.edges.writeMany(edges); + report.edges += edges.length; + } + return runRef; } // ── Chats ───────────────────────────────────────────────────────────────── diff --git a/src/graph/workspace-store.ts b/src/graph/workspace-store.ts index c4d6480..3ebf5bc 100644 --- a/src/graph/workspace-store.ts +++ b/src/graph/workspace-store.ts @@ -524,6 +524,19 @@ export class Neo4jWorkspaceStore implements WorkspaceStore { return out; } + async getActiveStepHashes(): Promise> { + // `active_version` IS the active version's content hash (it mirrors the + // ACTIVE_VERSION edge), so this is one read of the step nodes. + const rows = await this.backend.bolt.run( + `MATCH (s:StrutStep {namespace: $ns}) WHERE ${NOT_DELETED("s")} AND s.active_version IS NOT NULL + RETURN s.step_type AS type, s.active_version AS hash`, + { ns: this.ns }, + ); + const out: Record = {}; + for (const r of rows) if (typeof r["hash"] === "string" && r["hash"]) out[String(r["type"])] = r["hash"] as string; + return out; + } + async listStepVersions(name: string): Promise { validateStepName(name); const s = await this.stepRow(name); diff --git a/src/index.ts b/src/index.ts index 86a227d..3769e5a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,6 +66,9 @@ export { FileRunStore, MemoryRunStore, generateRunId, + stepRunKey, + checkRunKey, + stepTypeOfRunKey, tailJsonl, tailFromPolling, lastRunAtFromIds, @@ -209,11 +212,19 @@ export { // Single-step runner (test one step in isolation, with optional cassette). export { runSingleStep, + runStep, + persistStepRun, cassettePath, type RunStepOptions, type RunStepResult, + type RunStepDeps, + type KeptRunStepResult, } from "./run-step.js"; +// What a flow can execute (nested subflows, agentTools grants) and the step +// hashes a launch records on `run.start` (plans/claims.md §3–§4). +export { walkSteps, flowClosure, closureIncludes, stepHashesFor, globToRegExp, type FlowClosure } from "./closure.js"; + // Authoring — the workspace's author/test/inspect operations as one // injectable service: what the meta/* steps are plumbing over. Auto-provided // by createStrut as `services.authoring`; embedders can build their own. @@ -342,6 +353,7 @@ export { Neo4jWorkspaceStore, type Neo4jWorkspaceStoreOptions } from "./graph/wo export { graphWorkspaceFromEnv, graphWorkspaceRequested, graphMaterializeDir } from "./graph/wiring.js"; export { projectRuns, + projectRun, projectChats, projectAll, projectRunEvents, diff --git a/src/run-step.test.ts b/src/run-step.test.ts index aca94ff..6a5269e 100644 --- a/src/run-step.test.ts +++ b/src/run-step.test.ts @@ -1,12 +1,13 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { mkdtemp, readFile } from "node:fs/promises"; +import { mkdtemp, readFile, readdir, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { z } from "zod"; import { defineStep, type StepRegistry } from "./core.js"; import { coreRegistry } from "./steps/registry.js"; -import { runSingleStep, cassettePath } from "./run-step.js"; +import { runSingleStep, runStep, cassettePath } from "./run-step.js"; +import { FileRunStore, MemoryRunStore, stepRunKey } from "./store.js"; import { standardServices, type FetchLike } from "./capabilities.js"; // A fake fetch returning canned JSON, recording how many times it was called. @@ -140,3 +141,135 @@ describe("runSingleStep", () => { assert.equal(dead.count, 0); }); }); + +// ── run_step leaves a record — only when it matters (plans/claims.md §3) ──── + +const echo = defineStep({ + type: "clip/compute-times", + input: z.object({ start: z.number() }), + output: z.any(), + async run(cfg) { + return { start: cfg.start, end: cfg.start + 19 }; + }, +}); +const echoRegistry = () => ({ "clip/compute-times": echo }) as StepRegistry; +/** Just what `runStep` reads from a workspace. */ +const hashWorkspace = (hashes: Record) => ({ + getActiveStepHashes: async () => hashes, + getWorkflow: async () => { + throw new Error("no workflows here"); + }, + getWorkflowVersion: async () => { + throw new Error("no workflows here"); + }, +}); +const claimsOn = (...types: string[]) => ({ + claimsFor: async (subject: { kind: string; type?: string }) => (subject.kind === "step" && types.includes(subject.type!) ? [{ id: "c1" } as never] : []), +}); + +describe("runStep", () => { + it("records stepHashes / cassette on run.start, and keeps nothing for a scratch step", async () => { + const store = new MemoryRunStore(); + const r = await runStep( + "clip/compute-times", + echoRegistry(), + {}, + { config: { start: 12 } }, + { store, workspace: hashWorkspace({ "clip/compute-times": "aaaabbbbcccc", "other/step": "ffff00001111" }), claims: claimsOn() }, + ); + assert.equal(r.status, "success"); + assert.deepEqual(r.output, { start: 12, end: 31 }); + const start = r.events.find((e) => e.type === "run.start")!; + assert.deepEqual(start.stepHashes, { "clip/compute-times": "aaaabbbbcccc" }, "only the steps the flow can execute"); + assert.equal(start.cassette, undefined); + assert.equal(start.origin, undefined); + assert.equal(r.kept, undefined); + assert.deepEqual(await store.listRuns(stepRunKey("clip/compute-times")), []); + assert.equal(store.events.size, 0, "a step without a contract leaves nothing behind"); + }); + + it("a step with an active claim keeps its run under step: — events + summary, same run id", async () => { + const store = new MemoryRunStore(); + const r = await runStep( + "clip/compute-times", + echoRegistry(), + {}, + { config: { start: 1 } }, + { store, workspace: hashWorkspace({ "clip/compute-times": "aaaabbbbcccc" }), claims: claimsOn("clip/compute-times") }, + ); + assert.equal(r.kept, "step:clip/compute-times"); + assert.deepEqual(await store.listRuns("step:clip/compute-times"), [r.runId]); + const events = await store.getRunEvents("step:clip/compute-times", r.runId); + assert.deepEqual(events, r.events); + assert.ok(events.every((e) => e.runId === r.runId)); + const summary = (await store.getRunSummary("step:clip/compute-times", r.runId))!; + assert.deepEqual( + [summary.workflow, summary.status, summary.output, summary.runId], + ["step:clip/compute-times", "success", { start: 1, end: 20 }, r.runId], + ); + // Absent from every workflow listing, by construction. + assert.deepEqual(await store.listRuns("__run_step__"), []); + assert.deepEqual(await store.listRuns("clip/compute-times"), []); + }); + + it("keep: true persists without claims (and on a filesystem workspace: no claims layer at all)", async () => { + const store = new MemoryRunStore(); + const r = await runStep("clip/compute-times", echoRegistry(), {}, { config: { start: 1 }, keep: true }, { store, claims: null }); + assert.equal(r.kept, "step:clip/compute-times"); + assert.equal(r.events.find((e) => e.type === "run.start")!.stepHashes, undefined, "no workspace → no hashes → never evidence"); + const off = await runStep("clip/compute-times", echoRegistry(), {}, { config: { start: 1 } }, { store, claims: null }); + assert.equal(off.kept, undefined); + assert.equal((await store.listRuns("step:clip/compute-times")).length, 1); + }); + + it("a failed run is kept too (a claim can be about failing loudly); an unknown type keeps nothing", async () => { + const store = new MemoryRunStore(); + const deps = { store, workspace: hashWorkspace({}), claims: claimsOn("clip/compute-times", "nope") }; + const bad = await runStep("clip/compute-times", echoRegistry(), {}, { config: { start: "x" } as never }, deps); + assert.equal(bad.status, "error"); + assert.equal(bad.kept, "step:clip/compute-times"); + assert.equal((await store.getRunSummary("step:clip/compute-times", bad.runId))!.status, "error"); + const unknown = await runStep("nope", echoRegistry(), {}, {}, deps); + assert.deepEqual([unknown.status, unknown.kept, unknown.events.length], ["error", undefined, 0]); + }); + + it("an unreachable claims graph never fails the run — it is just not kept", async () => { + const store = new MemoryRunStore(); + const claims = { + claimsFor: async () => { + throw new Error("bolt down"); + }, + }; + const r = await runStep("clip/compute-times", echoRegistry(), {}, { config: { start: 1 } }, { store, claims }); + assert.deepEqual([r.status, r.kept], ["success", undefined]); + }); + + it("the cassette mode rides on run.start", async () => { + const dir = await mkdtemp(join(tmpdir(), "strut-runstep-")); + const r = await runStep( + "clip/compute-times", + echoRegistry(), + {}, + { config: { start: 1 }, cassette: { mode: "record", path: cassettePath(dir, "clip/compute-times") } }, + { store: new MemoryRunStore(), claims: null }, + ); + assert.equal(r.events.find((e) => e.type === "run.start")!.cassette, "record"); + }); + + it("FileRunStore: step: lands in steps//runs/, the prefix never reaches disk, nested types do not leak", async () => { + const root = await mkdtemp(join(tmpdir(), "strut-steprun-")); + const store = new FileRunStore(root); + const deps = { store, claims: claimsOn("clip/compute-times", "clip") }; + const r = await runStep("clip/compute-times", echoRegistry(), {}, { config: { start: 1 } }, deps); + assert.ok((await stat(join(root, "steps", "clip", "compute-times", "runs", r.runId, "events.jsonl"))).isFile()); + assert.ok((await stat(join(root, "steps", "clip", "compute-times", "runs", r.runId, "run.json"))).isFile()); + assert.deepEqual(await readdir(root), ["steps"], "no workflows/ dir, no `step:` anywhere on disk"); + assert.deepEqual(await store.listRuns("step:clip/compute-times"), [r.runId]); + assert.deepEqual(await store.getRunEvents("step:clip/compute-times", r.runId), r.events); + assert.deepEqual(await store.listRuns("step:clip"), [], "a parent namespace is not a step with runs"); + assert.deepEqual(await store.listRuns("clip/compute-times"), []); + assert.equal(await store.lastRunAt("step:clip/compute-times"), Number(r.runId)); + await assert.rejects(store.listRuns("step:../../etc"), /Invalid run store key/); + await assert.rejects(store.append("check:a/../b", "1", r.events[0]!), /Invalid run store key/); + }); +}); diff --git a/src/run-step.ts b/src/run-step.ts index 68f7c9c..bf8d339 100644 --- a/src/run-step.ts +++ b/src/run-step.ts @@ -1,7 +1,9 @@ import { z } from "zod"; -import type { Flow, StepRegistry, RunEvent, RunResult } from "./core.js"; +import { stepHashesFor } from "./closure.js"; +import type { Flow, StepRegistry, RunEvent, RunResult, RunSummary } from "./core.js"; +import type { ClaimsReader } from "./graph/claims.js"; import { runWorkflow, type SubflowResolver } from "./runner.js"; -import { MemoryRunStore } from "./store.js"; +import { MemoryRunStore, generateRunId, stepRunKey, type RunStore } from "./store.js"; import { withCassette, loadCassette, @@ -36,9 +38,16 @@ export interface RunStepOptions { cassette?: { mode: CassetteMode; path: string }; /** Subflow resolver — only needed if the step itself is a `subflow`. */ workspace?: SubflowResolver; + /** Recorded on `run.start` (see `RunEvent.stepHashes`). `runStep` fills it + * from the workspace; pass it yourself only when calling this directly. */ + stepHashes?: Record; + /** `"verify"` when the verify pass runs a check through here. */ + origin?: "verify"; } export interface RunStepResult { + /** Id of the (in-memory) run — the id it keeps if it is persisted. */ + runId: string; status: "success" | "error" | "cancelled"; output?: unknown; error?: { message: string; stack?: string }; @@ -54,8 +63,10 @@ export async function runSingleStep( services: unknown, opts: RunStepOptions = {}, ): Promise { + const runId = generateRunId(); if (!registry[type]) { return { + runId, status: "error", error: { message: `Step type "${type}" not found` }, events: [], @@ -80,9 +91,13 @@ export async function runSingleStep( const events: RunEvent[] = []; const result: RunResult = await runWorkflow(flow, opts.input ?? {}, registry, { + runId, store: new MemoryRunStore(), services: runServices, workspace: opts.workspace, + ...(opts.stepHashes ? { stepHashes: opts.stepHashes } : {}), + ...(opts.cassette ? { cassette: opts.cassette.mode } : {}), + ...(opts.origin ? { origin: opts.origin } : {}), onEvent: (e) => { events.push(e); }, @@ -94,6 +109,7 @@ export async function runSingleStep( } return { + runId, status: result.status, output: result.output, error: result.error, @@ -102,6 +118,92 @@ export async function runSingleStep( }; } +// ── run_step: single-step runs that leave a record ─────────────────────────── + +export interface RunStepDeps { + /** The REAL run store — never the throwaway one the step executes against. */ + store: RunStore; + /** Source of `run.start.stepHashes`; also the subflow resolver. */ + workspace?: SubflowResolver & { getActiveStepHashes(): Promise> }; + /** The claims layer — null/absent on a filesystem workspace. */ + claims?: Pick | null; +} + +export interface KeptRunStepResult extends RunStepResult { + /** The run-store key the run was persisted under (`step:`) — read it + * back with `list_runs` / `get_run` on that key. Absent = not kept. */ + kept?: string; +} + +/** + * `runSingleStep` for the `run_step` surfaces (chat tool, `meta/run-step`, + * `POST /steps/:type/run`), plus the two things that make a single-step run + * usable as evidence (plans/claims.md §3): + * + * - BEFORE: the active hash of every workspace step in reach goes on + * `run.start.stepHashes` — the only record of which version executed; + * - AFTER: the run still executes in memory exactly as before, and is then + * copied into the real store under `step:` ONLY when it can become + * evidence — the step has an active claim — or the caller asked + * (`keep: true`). A step with a contract keeps its test runs; a scratch + * step leaves nothing behind. One graph read, after the run. + * + * `step:` is a store key, never a workflow: no workflow listing, and no + * workflow's `list_runs` / `search_runs`, can see these runs. + */ +export async function runStep( + type: string, + registry: StepRegistry, + services: unknown, + opts: RunStepOptions & { keep?: boolean }, + deps: RunStepDeps, +): Promise { + const { keep, ...runOpts } = opts; + const flowSteps = [{ id: "step", type, config: runOpts.config ?? {} }]; + const stepHashes = runOpts.stepHashes ?? (await stepHashesFor(deps.workspace, { steps: flowSteps })); + const result = await runSingleStep(type, registry, services, { + ...runOpts, + workspace: runOpts.workspace ?? deps.workspace, + ...(stepHashes ? { stepHashes } : {}), + }); + if (result.events.length === 0) return result; // never ran (unknown type) + + let wanted = keep === true; + if (!wanted && deps.claims) { + try { + wanted = (await deps.claims.claimsFor({ kind: "step", type })).length > 0; + } catch (err) { + // The run's result must not depend on the graph being reachable. + console.error(`[run-step] could not read claims for ${type} — run not kept:`, err); + } + } + if (!wanted) return result; + const kept = await persistStepRun(deps.store, type, result); + return { ...result, kept }; +} + +/** Copy a finished single-step run (events + summary) into `store` under + * `step:`. Returns the key. */ +export async function persistStepRun(store: RunStore, type: string, result: RunStepResult): Promise { + const key = stepRunKey(type); + for (const e of result.events) await store.append(key, result.runId, e); + const first = result.events[0]!; + const last = result.events[result.events.length - 1]!; + const summary: RunSummary = { + runId: result.runId, + workflow: key, + startedAt: first.ts, + finishedAt: last.ts, + durationMs: Date.parse(last.ts) - Date.parse(first.ts), + status: result.status, + input: result.events.find((e) => e.type === "run.start")?.input, + ...(result.output !== undefined ? { output: result.output } : {}), + ...(result.error ? { error: result.error } : {}), + }; + await store.finalize(key, result.runId, summary); + return key; +} + /** Default on-disk location for a step's cassette, under the server's local * data dir (`dataDir` — the workspace root for file-backed deployments). */ export function cassettePath(dataDir: string, name: string): string { diff --git a/src/runner.ts b/src/runner.ts index 7e1d94e..15a2523 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -66,6 +66,13 @@ export interface RunOptions { /** Content hash of the workflow version being run, recorded on `run.start` * so resume can refuse to replay a journal into a different DAG (§5). */ workflowHash?: string; + /** Active content hash of every workspace step the flow can execute + * (`stepHashesFor`), recorded on `run.start` / `run.resumed`. */ + stepHashes?: Record; + /** Cassette mode this run executes under — recorded on `run.start`. */ + cassette?: "record" | "replay"; + /** `"verify"` when the verify pass launches this run (a check). */ + origin?: "verify"; } /** Sentinel returned by steps that were skipped because their `when` didn't match. */ @@ -159,13 +166,21 @@ export async function runWorkflow( if (opts?.resume) { // Continuing an interrupted run: same runId, same log — the marker both // records the gap and reopens tails past an earlier terminal event. - await emit({ type: "run.resumed", path: wfName }); + await emit({ + type: "run.resumed", + path: wfName, + // Steps load at (re)launch: what runs from here on is what is active NOW. + ...(opts.stepHashes ? { stepHashes: opts.stepHashes } : {}), + }); } else { await emit({ type: "run.start", path: wfName, input: parsedInput, ...(opts?.workflowHash ? { workflowHash: opts.workflowHash } : {}), + ...(opts?.stepHashes ? { stepHashes: opts.stepHashes } : {}), + ...(opts?.cassette ? { cassette: opts.cassette } : {}), + ...(opts?.origin ? { origin: opts.origin } : {}), // Tree linkage on disk: a nested run names its parent so boot-time // auto-resume can tell roots from children (§5.3). ...(opts?.controller?.parent ? { parentRunId: opts.controller.parent.runId } : {}), diff --git a/src/steps/core/agent.ts b/src/steps/core/agent.ts index 952bf29..9c723be 100644 --- a/src/steps/core/agent.ts +++ b/src/steps/core/agent.ts @@ -3,6 +3,7 @@ import type { SecretsCapability } from "../../capabilities.js"; import { resolveModel, createWebTools } from "../../llm.js"; import { accessedNodesOf, defineStep, type StepContext, type StepRegistry, withAccessedNodes } from "../../core.js"; import { isCancelledError } from "../../run-control.js"; +import { globToRegExp } from "../../closure.js"; import { usageFromResult, usageForCost, addUsage, emptyUsage, type TokenUsage } from "../../pricing.js"; import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, statSync } from "node:fs"; import { join, resolve, dirname, isAbsolute, sep } from "node:path"; @@ -356,14 +357,6 @@ function toolNameFor(stepType: string): string { return stepType.replace(/[^a-zA-Z0-9_]/g, "_"); } -/** Compile a glob pattern (`*` = any run of characters) to an anchored RegExp. */ -function globToRegExp(pattern: string): RegExp { - const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, (c) => - c === "*" ? ".*" : `\\${c}`, - ); - return new RegExp(`^${escaped}$`); -} - /** * Expand `agentTools` entries against the registry: a name containing `*` is a * glob over registry step types (e.g. `"jarvis/*"` → every jarvis step), so a diff --git a/src/steps/lib/meta/run-step.ts b/src/steps/lib/meta/run-step.ts index 14e215f..5f2f83a 100644 --- a/src/steps/lib/meta/run-step.ts +++ b/src/steps/lib/meta/run-step.ts @@ -5,7 +5,7 @@ import { requireAuthoring } from "./_shared.js"; export default defineStep({ type: "meta/run-step", description: - "Run a SINGLE step in isolation with a given config + input and return its output + events — the inner loop for authoring: meta/create-step → meta/run-step → meta/edit-step → meta/run-step until the output is right. Set cassette:'record' to run live AND capture the step's external service calls to a reusable fixture (secrets scrubbed); then cassette:'replay' to iterate OFFLINE against it — deterministic, no rate limits, no cost, no side effects. Sees steps published earlier in this same run (the registry is re-read fresh). Returns { status, output?, error?, events, recorded? }.", + "Run a SINGLE step in isolation with a given config + input and return its output + events — the inner loop for authoring: meta/create-step → meta/run-step → meta/edit-step → meta/run-step until the output is right. Set cassette:'record' to run live AND capture the step's external service calls to a reusable fixture (secrets scrubbed); then cassette:'replay' to iterate OFFLINE against it — deterministic, no rate limits, no cost, no side effects. Sees steps published earlier in this same run (the registry is re-read fresh). Returns { runId, status, output?, error?, events, recorded?, kept? } — `kept` is the run-store key when the run was persisted.", input: z.object({ type: z.string().describe("Step type to run, e.g. 'candidates/my-fetcher' or 'http'."), config: z @@ -22,6 +22,7 @@ export default defineStep({ .string() .optional() .describe("Fixture name (defaults to the step type). Use distinct names to keep multiple scenarios per step."), + keep: z.boolean().optional().describe('Persist this run under the run-store key `step:` (read it back with list_runs / get_run on that key). Runs of a step that has claims are kept automatically — they can become evidence; set this to keep a run of a step that has none.'), }), output: z.any(), async run(cfg, ctx) { @@ -31,6 +32,7 @@ export default defineStep({ params: cfg.params, cassette: cfg.cassette, cassetteName: cfg.cassetteName, + keep: cfg.keep, }); }, }); diff --git a/src/store.ts b/src/store.ts index 1f1a67a..00b1836 100644 --- a/src/store.ts +++ b/src/store.ts @@ -308,11 +308,45 @@ export function summarizeFromEvents( return summary; } +// ── Run buckets that are not workflows ────────────────────────────────────── + +/** + * Every `RunStore` read takes ONE key and there is no global run list, so a + * run kept under a key that is not a workflow name is invisible to every + * workflow listing by construction (plans/claims.md §3, §4.1): + * + * - `step:` — a single-step run (`run_step`) worth keeping; + * - `check:` — a check run the verify pass kept because it cost money. + * + * The prefix is a STORE KEY only: `FileRunStore` parses it into a sibling + * directory (`steps//runs/`, `checks//runs/`) and never writes it + * to disk. Workflow and step names cannot contain `:`, so keys never collide. + */ +const RUN_BUCKETS = [ + { prefix: "step:", dir: "steps" }, + { prefix: "check:", dir: "checks" }, +] as const; + +export function stepRunKey(stepType: string): string { + return `step:${stepType}`; +} + +export function checkRunKey(checkId: string): string { + return `check:${checkId}`; +} + +/** The step type behind a `step:` key, else null. */ +export function stepTypeOfRunKey(key: string): string | null { + return key.startsWith("step:") && key.length > 5 ? key.slice(5) : null; +} + // ── Filesystem implementation ────────────────────────────────────────────── /** - * Stores runs under `/workflows//runs//`. - * runId is a millisecond timestamp, giving natural sort order and easy pagination. + * Stores runs under `/workflows//runs//` — + * and the two non-workflow buckets (above) under `steps//runs/` and + * `checks//runs/`. runId is a millisecond timestamp, giving natural sort + * order and easy pagination. */ export class FileRunStore implements RunStore { private workspaceRoot: string; @@ -321,8 +355,21 @@ export class FileRunStore implements RunStore { this.workspaceRoot = workspaceRoot; } + private runsDir(key: string): string { + for (const b of RUN_BUCKETS) { + if (!key.startsWith(b.prefix)) continue; + // A namespaced step type nests (`clip/compute-times`); nothing else may. + const segments = key.slice(b.prefix.length).split("/"); + if (segments.some((seg) => !seg || seg === "." || seg === ".." || seg.includes("\\"))) { + throw new Error(`Invalid run store key "${key}"`); + } + return join(this.workspaceRoot, b.dir, ...segments, "runs"); + } + return join(this.workspaceRoot, "workflows", key, "runs"); + } + private runDir(workflow: string, runId: string): string { - return join(this.workspaceRoot, "workflows", workflow, "runs", runId); + return join(this.runsDir(workflow), runId); } async append(workflow: string, runId: string, event: RunEvent): Promise { @@ -344,7 +391,7 @@ export class FileRunStore implements RunStore { /** List runs for a workflow, sorted newest first. Returns dir names (timestamps). */ async listRuns(workflow: string): Promise { - const runsDir = join(this.workspaceRoot, "workflows", workflow, "runs"); + const runsDir = this.runsDir(workflow); try { const entries = await readdir(runsDir); // Sort descending (newest first) — timestamps sort lexicographically @@ -484,8 +531,12 @@ export class MemoryRunStore implements RunStore { async listRuns(workflow: string): Promise { const prefix = `${workflow}/`; const ids = new Set(); - for (const k of this.events.keys()) if (k.startsWith(prefix)) ids.add(k.slice(prefix.length)); - for (const k of this.summaries.keys()) if (k.startsWith(prefix)) ids.add(k.slice(prefix.length)); + // A run id has no `/`: `step:clip` must not list `step:clip/trim`'s runs. + const idOf = (k: string) => (k.startsWith(prefix) && !k.slice(prefix.length).includes("/") ? k.slice(prefix.length) : null); + for (const k of [...this.events.keys(), ...this.summaries.keys()]) { + const id = idOf(k); + if (id) ids.add(id); + } return [...ids].sort((a, b) => b.localeCompare(a)); } diff --git a/src/test-util/workspace-conformance.ts b/src/test-util/workspace-conformance.ts index ea01629..5e97ac6 100644 --- a/src/test-util/workspace-conformance.ts +++ b/src/test-util/workspace-conformance.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; import type { WorkspaceStore } from "../workspace.js"; +import { contentHash } from "../version.js"; /** * The `WorkspaceStore` contract as tests — one behavioral suite every @@ -167,12 +168,17 @@ export function workspaceConformance(impl: WorkspaceImpl): void { assert.equal(versions.active, v2.version); assert.deepEqual(new Set(versions.versions), new Set([v1.version, v2.version])); assert.ok((await ws.getStepVersionSource("my-step", v1.version)).includes('"one"')); + // Active step hashes follow the active pointer (run.start.stepHashes). + const h2 = (await ws.getActiveStepHashes())["my-step"]; + assert.equal(h2, contentHash(STEP_SRC("my-step", "two"))); await ws.setActiveStepVersion("my-step", v1.version); assert.equal((await ws.listStepVersions("my-step")).active, v1.version); + assert.deepEqual(await ws.getActiveStepHashes(), { "my-step": contentHash(STEP_SRC("my-step", "one")) }); assert.equal((await ws.getStepSource("my-step"))?.code.includes('"one"'), true); assert.equal(await ws.deleteStep("my-step"), true); assert.equal(await ws.deleteStep("my-step"), false); assert.deepEqual(await ws.listSteps(), []); + assert.deepEqual(await ws.getActiveStepHashes(), {}); }); it("deleteStepsByPublisher removes exactly that publisher's steps", async () => { diff --git a/src/validate.ts b/src/validate.ts index 926355a..3613d68 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -13,6 +13,7 @@ * config fields are skipped when checking against the step's schema. */ import yaml from "js-yaml"; +import { walkSteps } from "./closure.js"; import type { Step, StepRegistry } from "./core.js"; import { TemplateError, exprRoots, hasTemplates, templateExprs } from "./expr.js"; import { assertValidWorkflowYaml } from "./workspace.js"; @@ -307,14 +308,7 @@ export function validateWorkflowYaml(source: string, opts: ValidateOptions): Val function collectTypes(steps: Step[]): string[] { const out: string[] = []; - const visit = (s: Step | undefined) => { - if (!s || typeof s !== "object" || typeof s.type !== "string") return; - out.push(s.type); - const body = (s.config as Record | undefined)?.["body"] as Step | undefined; - if (BODY_STEPS.has(s.type)) visit(body); - visit(s.options?.onError); - }; - steps.forEach(visit); + walkSteps(steps, (s) => out.push(s.type)); return out; } diff --git a/src/workspace.ts b/src/workspace.ts index 76cf591..0048734 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -287,6 +287,12 @@ export interface WorkspaceStore extends SubflowResolver { opts?: PublishByContentOptions, ): Promise<{ version: string; changed: boolean }>; listStepVersions(name: string): Promise; + /** Content hash of every custom step's ACTIVE version, keyed by step + * type — the sibling of `getWorkflowHash`. A workflow version does not + * pin its steps (the registry loads whatever is active), so a launch + * records this on `run.start.stepHashes`: the only record of which step + * version a run executed (plans/claims.md §3). */ + getActiveStepHashes(): Promise>; getStepVersionSource(name: string, version: string): Promise; setActiveStepVersion(name: string, version: string): Promise; deleteStep(name: string): Promise; @@ -745,6 +751,25 @@ export class FileWorkspaceStore implements WorkspaceStore { return { active: info.active, versions: Object.keys(info.versions) }; } + async getActiveStepHashes(): Promise> { + const meta = await this.readStepMetadata(join(this.root, "steps", "custom")); + const out: Record = {}; + for (const [name, info] of Object.entries(meta?.steps ?? {})) { + const recorded = info.versions[info.active]?.hash; + if (recorded) { + out[name] = recorded; + continue; + } + // Metadata from before hashes were recorded: hash the archived source. + try { + out[name] = contentHash(await readFile(this.stepVersionPath(name, info.active), "utf-8")); + } catch { + // No source on record → no hash → that step's runs carry no evidence. + } + } + return out; + } + /** Get the archived source for a specific step version. */ async getStepVersionSource(name: string, version: string): Promise { validateStepName(name); From 420d4388b3fc179316766fb7b4255b3747253c4a Mon Sep 17 00:00:00 2001 From: Evanfeenstra Date: Thu, 17 Sep 2026 12:07:51 -0700 Subject: [PATCH 03/11] =?UTF-8?q?claims=20step=203:=20claim=20+=20check=20?= =?UTF-8?q?authoring=20=E2=80=94=20both=20doors,=20scoped,=20grader-proof?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claims and checks become writable (plans/claims.md §2). Nothing produces evidence yet — that is step 4 — so every status reads `unknown`. - graph/claims-writer.ts: the graph's invariants, with no opinion about who writes. A claim always has >=1 check (retiring the last is refused). Edits SUPERSEDE: a successor claim carries its ABOUT attachments and its checks (TESTS edges — an instrument is never cloned) and starts unknown; a successor check takes over TESTS and the old one's evidence stops counting. Retire = a timestamp, detach = a muted ABOUT edge (restored by re-attaching); a claim's last subject cannot be detached - claims-authoring.ts: one policy layer behind both doors. Validates check specs before anything is written; applies defaults at write time (run, and `always` for code vs `on_change` for anything presumed paid — an agent/llm step anywhere in the check closure, or an unresolvable one — and for external checks). Fixed point 1: a scoped (meta) author touches only claims/checks it stamped and subjects it published. Fixed point 2: an `ai`-stamped check may not reach a grader (gaia/*, harvey/*, eval/*, meta/*, + STRUT_VERIFY_DENY) by name, through a subflow child, or via an agentTools grant; an unresolvable closure is refused - door one: `claims` on create_step / edit_step / create_workflow / edit_workflow and on meta/create-step / meta/edit-step / meta/publish-workflow. Validated BEFORE the publish, so a broken contract blocks it; additive and idempotent by exact text, never retires. Results carry claims: { count, added, existing } and a warning on zero - door two: add_claim, list_claims (with computed status), edit_claim, retire_claim, attach_claim, detach_claim, add_check, edit_check, retire_check — and nine meta/* twins over the scoped capability. The chat surface is human-supervised, so it is not scoped (like edit_step), but it stamps `ai`, which keeps the deny-list on its checks - ids are now time-sortable (base36 ms + per-process sequence + random): still lowercase alphanumeric and never derived from the text, but a contract lists in the order it was written - prompt: a short claims section (rules 1-4), only when the tools are offered. On a filesystem workspace there is no tool, no arg, no section, and a contract handed to the meta surface is an error, never dropped --- AGENTS.md | 6 +- plans/claims.md | 18 +- src/ai/prompts.ts | 16 +- src/ai/tools.ts | 122 ++++++- src/authoring.test.ts | 10 + src/authoring.ts | 74 +++- src/claims-authoring.ts | 470 +++++++++++++++++++++++++ src/claims-schemas.ts | 63 ++++ src/graph/claims-authoring.test.ts | 392 +++++++++++++++++++++ src/graph/claims-writer.ts | 256 ++++++++++++++ src/graph/claims.test.ts | 6 +- src/graph/claims.ts | 72 +++- src/index.ts | 17 + src/steps/lib/meta/add-check.ts | 15 + src/steps/lib/meta/add-claim.ts | 19 + src/steps/lib/meta/attach-claim.ts | 15 + src/steps/lib/meta/create-step.ts | 4 +- src/steps/lib/meta/detach-claim.ts | 15 + src/steps/lib/meta/edit-check.ts | 15 + src/steps/lib/meta/edit-claim.ts | 14 + src/steps/lib/meta/edit-step.ts | 4 +- src/steps/lib/meta/list-claims.ts | 15 + src/steps/lib/meta/publish-workflow.ts | 3 + src/steps/lib/meta/retire-check.ts | 14 + src/steps/lib/meta/retire-claim.ts | 14 + 25 files changed, 1643 insertions(+), 26 deletions(-) create mode 100644 src/claims-authoring.ts create mode 100644 src/claims-schemas.ts create mode 100644 src/graph/claims-authoring.test.ts create mode 100644 src/graph/claims-writer.ts create mode 100644 src/steps/lib/meta/add-check.ts create mode 100644 src/steps/lib/meta/add-claim.ts create mode 100644 src/steps/lib/meta/attach-claim.ts create mode 100644 src/steps/lib/meta/detach-claim.ts create mode 100644 src/steps/lib/meta/edit-check.ts create mode 100644 src/steps/lib/meta/edit-claim.ts create mode 100644 src/steps/lib/meta/list-claims.ts create mode 100644 src/steps/lib/meta/retire-check.ts create mode 100644 src/steps/lib/meta/retire-claim.ts diff --git a/AGENTS.md b/AGENTS.md index a6d8c8c..0b44b7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,8 @@ strut/ │ ├── run-control.ts # RunController: cooperative cancel/pause/resume for run TREES (RUN_CONTROL_SPEC.md) │ ├── journal.ts # resume journal: step.end outputs → {path→output}; `from` invalidation │ ├── store.ts # RunStore interface (writes + reads + tail) + FileRunStore + MemoryRunStore + tailJsonl / tailFromPolling. Keys are workflow names, plus two non-workflow buckets no workflow listing can see: `step:` → steps//runs/ (kept run_step runs), `check:` → checks//runs/ (paid check runs) +│ ├── claims-authoring.ts # the policy layer behind BOTH claim doors (chat tools + meta/* twins): check-spec validation + write-time defaults (presumed-paid → on_change), the additive `claims` publish arg, publisher scoping (fixed point 1), the grader deny-list over the check closure (fixed point 2; STRUT_VERIFY_DENY) +│ ├── claims-schemas.ts # zod shapes + model-facing docs for subjects / check specs / the `claims` arg, shared by ai/tools.ts and the meta/* claim steps │ ├── closure.ts # what a flow can EXECUTE: walkSteps (loop/foreach bodies, onError), flowClosure (nested subflows via the workspace, agentTools grants; templated/missing child → unresolvable), stepHashesFor → run.start.stepHashes │ ├── run-step.ts # runSingleStep (one step, in memory, optional cassette) + runStep — the run_step surfaces: records stepHashes, then persists the run under `step:` only when the step has claims or `keep: true` (plans/claims.md §3) │ ├── chat-store.ts # ChatStore interface + FileChatStore + MemoryChatStore (chats//: meta.json + messages.jsonl + events.jsonl) + truncateToolMessages @@ -86,6 +88,7 @@ strut/ │ │ ├── edge-writer.ts # edge MERGE by ref_id with IS_ALIAS rewrite (ON CREATE only); closed (source, edge, target) registry; update() = jarvis PATCH /v2/edges/:ref_id (stamps protected) │ │ ├── schema-crud.ts # createNodeSchema(): register a non-Strut node type like jarvis POST /v2/schema (parent, attribute grammar, node_key, CHILD_OF, constraint) or add-only extend an existing one │ │ ├── claims.ts # the truth layer (plans/claims.md): Claim/Check/Evidence contract (ids, check subject + result shapes), claimStatus() — status computed on read per (claim, subject) — and ClaimsReader (claimsFor/checksFor/evidenceFor/statusFor; muted edges invisible). `strut.claims` is null unless the workspace is graph-backed +│ │ ├── claims-writer.ts # ClaimsWriter: the claim graph's invariants — ≥1 check per claim, edits SUPERSEDE (successor claim carries ABOUT + checks; successor check takes over TESTS), retire = timestamp, detach = muted edge, last check / last subject refused │ │ ├── claim-schema-upgrade.ts # one-shot standalone mirror of jarvis migration 124 (Claim re-keyed on id, Epistemic/Thing); runs before the ontology seed, only with STRUT_GRAPH_SEED_ONTOLOGY │ │ ├── embeddings.ts # local all-MiniLM-L6-v2 via transformers.js, tokenized like sentence-transformers (256 incl. specials); NULL-scan backfill │ │ ├── search.ts # the read surface: hybrid search (RRF + title boost + usage tiebreak), get/neighbors/counts, ontology, namespaces @@ -187,7 +190,8 @@ docker compose run --rm --no-deps --service-ports -e STRUT_WORKSPACE_BACKEND=fs | `NEO4J_URI` / `NEO4J_HOST` | (unset) / `localhost:7687` | Graph backend connection — same names and defaults as mcp's own Neo4j client: `NEO4J_URI` wins, else `bolt://`; `NEO4J_USER`/`NEO4J_PASSWORD` default `neo4j`/`testtest`; optional `NEO4J_DATABASE`. The `graph/*` lib steps read these via the secrets capability (secret store → env) and need nothing configured for a local Neo4j; `openGraphBackendFromEnv` stays opt-in (null when neither is set). | | `STRUT_GRAPH_NAMESPACE` | `default` | jarvis namespace every Strut node is written into | | `STRUT_GRAPH_EMBEDDINGS` | (on) | `off` disables the local MiniLM embedder (vectors stay NULL; search is fulltext-only) | -| `STRUT_GRAPH_SEED_ONTOLOGY` | (off) | `1` seeds the bundled jarvis ontology (153 schemas + edge schemas + indexes, add-only) on first open, so a standalone Neo4j can host jarvis-typed data (Document, EvalSet, Concept, …) with no jarvis process. No-op on a jarvis-seeded DB. | +| `STRUT_GRAPH_SEED_ONTOLOGY` | (off) | `1` seeds the bundled jarvis ontology (153 schemas + edge schemas + indexes, add-only) on first open, so a standalone Neo4j can host jarvis-typed data (Document, EvalSet, Concept, …) with no jarvis process. No-op on a jarvis-seeded DB. Also turns on the one-shot `Claim` schema upgrade (the standalone mirror of jarvis migration 124) — never run against a jarvis-hosted graph. | +| `STRUT_VERIFY_DENY` | (none) | Comma-separated step-type globs added to the grader deny-list (`gaia/*`, `harvey/*`, `eval/*`, `meta/*`): an `ai`-stamped check may not reach any of them — by name, through a subflow, or via an `agentTools` grant (plans/claims.md §4.1, fixed point 2). | | `STRUT_MODEL_DIR` | `~/.cache/strut-models` | Local model files: MiniLM's ONNX cache and STT models under `stt//`. `STRUT_MODEL_CACHE` is the older alias. | | `STRUT_STT_MODEL` | `zipformer-en-kroko` | Finals recognizer for `/audio/stream` + `/audio/transcribe` (hotword-capable) | | `STRUT_STT_PARTIAL_MODEL` | `nemo-fast-conformer-en-80ms` | Fast greedy recognizer whose output is shown as live partials; `off` for single-recognizer streams | diff --git a/plans/claims.md b/plans/claims.md index be60340..f9f5a54 100644 --- a/plans/claims.md +++ b/plans/claims.md @@ -70,7 +70,7 @@ behave. Strut writes: | attribute | type | strut writes | | --- | --- | --- | -| `id` | string | its own identity — never derived from the text. **Lowercase alphanumerics only** (`randomUUID()` with the dashes stripped): `node_key` is `claim-` after jarvis's sanitizer lowercases and drops every non-alphanumeric, so `aB-1` and `ab1` would collide. Same rule for `Check.id` and `Evidence.id` | +| `id` | string | its own identity — never derived from the text. **Lowercase alphanumerics only** (time-sortable, ULID-style: base36 epoch ms + a per-process sequence + random, so a contract lists in the order it was written): `node_key` is `claim-` after jarvis's sanitizer lowercases and drops every non-alphanumeric, so `aB-1` and `ab1` would collide. Same rule for `Check.id` and `Evidence.id` | | `name` | string | the sentence, bounded (jarvis's required title) | | `claim_text` | string | the sentence. Behavior, not mechanism; never the output schema restated | | `speaker_name` | ?string | who asserts it: `ai`, a person, a seeder. This IS strut's `publisher` stamp (fixed point 1, §4.1). Later a `Person —MADE_CLAIM→ Claim` edge (existing pair) | @@ -862,7 +862,21 @@ number exists). `run.start` gains `stepHashes` / `cassette` / `origin` (§3) — FIRST, since only runs recorded after it can ever be verified; then `run_step` persists under `step:` (§3) + the projector pair. -3. Authoring tools + `claims` arg + publish count + prompt section (§2), +3. **Done** — `graph/claims-writer.ts` (invariants), `claims-authoring.ts` + (validation, write-time defaults, scoping, the deny-list over the check + closure), `claims-schemas.ts`; the `claims` arg on the four chat publish + tools and on `meta/create-step` / `meta/edit-step` / + `meta/publish-workflow` (validated BEFORE the publish — a broken + contract blocks it; result `claims: { count, added, existing, warning? }`); + the nine chat tools and their nine `meta/*` twins; prompt rules 1–4 + (5–6 land with the ledger, step 5). Decided while building: the chat + surface is NOT publisher-scoped (it is human-supervised, like + `edit_step`) but still stamps `ai`, so the deny-list applies to its + checks; a scoped author may also attach / add claims only to subjects + it published; a successor claim is stamped by its EDITOR; a claim's + last subject cannot be detached (retire it); a contract passed on a + filesystem workspace is an error, never silently dropped. + Authoring tools + `claims` arg + publish count + prompt section (§2), and their `meta/*` twins: `meta/add-claim`, `meta/edit-claim`, `meta/retire-claim`, `meta/list-claims`, `meta/attach-claim`, `meta/detach-claim`, `meta/add-check`, `meta/edit-check`, diff --git a/src/ai/prompts.ts b/src/ai/prompts.ts index 6b243d0..e480224 100644 --- a/src/ai/prompts.ts +++ b/src/ai/prompts.ts @@ -268,10 +268,24 @@ function renderModels(m: AiDeps["models"]): string { return `LLM providers with a key configured on this deployment: ${configured} (default model: ${m.default}). In agent/llm steps only use \`model:\` values from these providers — an alias (sonnet, opus, haiku, gemini, gpt, kimi, glm, grok), a full id, or "provider/id" (OpenRouter models as "openrouter/org/model"). For any other provider, tell the user to add its key under Secrets (${keys}).\n\n`; } +/** + * The claims section (plans/claims.md §2) — appended only when the claim + * tools are offered (a graph-backed workspace). Deliberately short: the + * forcing function is the contract the model reads in its tool results, not + * this instruction. + */ +export const CLAIMS_SECTION = `Claims — state how your work should behave, and let runs prove it: +A claim is ONE plain sentence about how a step or workflow should BEHAVE; a check is an instrument that tests it (a registry step run over the subject, or an external check for what code cannot observe); evidence is what a check observed on one run. A claim's status (supported | refuted | stale | unknown) is COMPUTED from evidence on the active version — you never assert it. "The last run returned success" is not evidence. +1. Author claims BEFORE the first run, in the same call as the code: pass \`claims\` to create_step / edit_step / create_workflow / edit_workflow. A publish result carries \`claims.count\` — zero comes with a warning you must answer. For a subject you are not republishing, use add_claim. +2. Behavior, not mechanism, and never the output schema restated. Claim the thing the user actually cares about ("the clip's audio contains the requested quote"), not what is easy to check ("the clip is 20 seconds long"). +3. Every claim gets at least one check. Prefer code that OBSERVES the output (an \`exec\` script, a custom step, a \`subflow\` for anything bigger than a one-liner — e.g. speech-to-text the clip, then fuzzy-match the quote): it is free, so it runs on every input. Use an \`llm\` / \`agent\` check only for judgment calls — it costs money and is recorded as asserted, not observed. If nothing can check it, give it an EXTERNAL check whose description says what to look at and why code cannot. +4. A failure you fix becomes a claim with a check — the regression move: the 429 on auto-translated captions becomes "fetches only the requested caption languages". Otherwise the next session rediscovers it. +Tools: add_claim, list_claims (claims + checks + computed status), edit_claim / edit_check (immutable nodes: an edit creates a successor and returns ITS id — the claim reads unknown until verified again), retire_claim / retire_check, attach_claim / detach_claim (share one contract across subjects instead of copying it), add_check.`; + export async function buildSystem(deps: AiDeps): Promise { const tree = await renderStepsTree(deps); return `${BASE_SYSTEM} - +${deps.workspace.graph ? `\n${CLAIMS_SECTION}\n` : ""} ${renderModels(deps.models)}Available steps: ${tree} `; diff --git a/src/ai/tools.ts b/src/ai/tools.ts index 4c8efc2..89217fa 100644 --- a/src/ai/tools.ts +++ b/src/ai/tools.ts @@ -7,6 +7,8 @@ import { stepSchemas } from "./schemaHelpers.js"; import { runStep, cassettePath } from "../run-step.js"; import { stepHashesFor } from "../closure.js"; import { claimsReaderFor } from "../graph/claims.js"; +import { buildClaimsAuthoring, type ClaimActor } from "../claims-authoring.js"; +import { checkSpecSchema, claimsArgSchema, subjectSchema } from "../claims-schemas.js"; import { generateRunId, stepRunKey } from "../store.js"; import { formatValidationErrors, validateWorkflowYaml } from "../validate.js"; // The shared authoring core — the same mechanism the meta/* steps' capability @@ -73,6 +75,18 @@ export function buildTools(deps: AiDeps) { name, }); }; + // The claims layer (plans/claims.md) — only on a graph-backed workspace: + // none of the claim tools, and no `claims` arg, are offered without it. + // This surface is human-supervised, so it is NOT publisher-scoped (like + // edit_step); what it writes is still stamped `ai`, which keeps the grader + // deny-list on its checks. + const claims = deps.workspace.graph + ? buildClaimsAuthoring({ graph: deps.workspace.graph, workspace: deps.workspace, getRegistry: deps.getRegistry }) + : null; + const actor: ClaimActor = { publisher: AI_PUBLISHER, scoped: false }; + const claimsArg = claims ? { claims: claimsArgSchema } : {}; + type ClaimsArg = z.infer; + /** Non-blocking: warnings ride along on a successful publish. */ const withWarnings = (result: T, v: { warnings: Array<{ path: string; message: string }> }) => v.warnings.length ? { ...result, warnings: v.warnings } : result; @@ -158,15 +172,20 @@ export function buildTools(deps: AiDeps) { "Full TypeScript source. Shape: import { z, defineStep } from \"strut\"; export default defineStep({ type: \"\", input: z.object({...}), output: z.any(), async run(cfg, ctx) { /* use ctx.services for capabilities */ } });", ), description: z.string().optional(), + ...claimsArg, }), - execute: async ({ name, code, description }) => { + execute: async ({ name, code, description, ...rest }) => { + const contract = (rest as { claims?: ClaimsArg }).claims; + const invalid = await claims?.validateClaimsArg(contract, actor); + if (invalid) return { error: `Nothing was published — fix the claims first. ${invalid.error}` }; const result = await publishNewStep(deps, name, code, description, AI_PUBLISHER); deps.registry = await deps.getRegistry(); + const ledger = result.ok && claims ? { claims: await claims.applyClaimsArg({ kind: "step", name }, contract, actor) } : {}; if (result.ok && result.loaded === false) { - const { loadError, ...rest } = result; - return { ...rest, warning: `Published but failed to load into the registry: ${loadError}` }; + const { loadError, ...ok } = result; + return { ...ok, ...ledger, warning: `Published but failed to load into the registry: ${loadError}` }; } - return result; + return { ...result, ...ledger }; }, }), @@ -179,15 +198,20 @@ export function buildTools(deps: AiDeps) { .string() .describe("Full updated TypeScript source (same self-contained shape as create_step)."), description: z.string().optional(), + ...claimsArg, }), - execute: async ({ type, code, description }) => { + execute: async ({ type, code, description, ...rest }) => { + const contract = (rest as { claims?: ClaimsArg }).claims; + const invalid = await claims?.validateClaimsArg(contract, actor); + if (invalid) return { error: `Nothing was published — fix the claims first. ${invalid.error}` }; const result = await publishStepVersion(deps, type, code, description); deps.registry = await deps.getRegistry(); + const ledger = result.ok && claims ? { claims: await claims.applyClaimsArg({ kind: "step", name: type }, contract, actor) } : {}; if (result.ok && result.loaded === false) { - const { loadError, ...rest } = result; - return { ...rest, warning: `Published but failed to load into the registry: ${loadError}` }; + const { loadError, ...ok } = result; + return { ...ok, ...ledger, warning: `Published but failed to load into the registry: ${loadError}` }; } - return result; + return { ...result, ...ledger }; }, }), @@ -227,10 +251,14 @@ export function buildTools(deps: AiDeps) { .describe( "Optional sidebar grouping label (kebab-case, e.g. an experiment name). Omit to leave uncategorized.", ), + ...claimsArg, }), - execute: async ({ name, yaml, description, category }) => { + execute: async ({ name, yaml, description, category, ...rest }) => { const v = await validate(yaml, name); if (!v.ok) return { error: formatValidationErrors(v), validation: v }; + const contract = (rest as { claims?: ClaimsArg }).claims; + const invalid = await claims?.validateClaimsArg(contract, actor); + if (invalid) return { error: `Nothing was published — fix the claims first. ${invalid.error}` }; const { name: finalName, version } = await deps.workspace.createWorkflow( name, yaml, @@ -246,6 +274,7 @@ export function buildTools(deps: AiDeps) { version, renamed: finalName !== name, requested: name, + ...(claims ? { claims: await claims.applyClaimsArg({ kind: "workflow", name: finalName }, contract, actor) } : {}), }, v, ); @@ -275,8 +304,9 @@ export function buildTools(deps: AiDeps) { .describe( "Optional sidebar grouping label. Only pass to CHANGE the category (to merely re-categorize without editing YAML, use set_workflow_category).", ), + ...claimsArg, }), - execute: async ({ name, yaml, description, category }) => { + execute: async ({ name, yaml, description, category, ...rest }) => { const exists = (await deps.workspace.listWorkflows()).some( (w) => w.name === name, ); @@ -287,6 +317,9 @@ export function buildTools(deps: AiDeps) { } const v = await validate(yaml, name); if (!v.ok) return { error: formatValidationErrors(v), validation: v }; + const contract = (rest as { claims?: ClaimsArg }).claims; + const invalid = await claims?.validateClaimsArg(contract, actor); + if (invalid) return { error: `Nothing was published — fix the claims first. ${invalid.error}` }; let result; try { result = await deps.workspace.publishWorkflowByContent( @@ -305,12 +338,81 @@ export function buildTools(deps: AiDeps) { name, version: result.version, changed: result.changed, + ...(claims ? { claims: await claims.applyClaimsArg({ kind: "workflow", name }, contract, actor) } : {}), }, v, ); }, }), + // ── Claims (plans/claims.md §2, door two) — graph-backed workspaces only. + ...(claims + ? { + add_claim: tool({ + description: + "State how a step or workflow SHOULD behave, with the check(s) that test it. One claim may be about SEVERAL subjects (a contract two steps share) — attach it rather than writing it twice. Use this for a subject you are not republishing; when you ARE publishing, pass `claims` to create_step / edit_step / create_workflow / edit_workflow instead. Every claim needs at least one check; evidence is produced by verifying runs, never by this call. Returns { id, checks: [ids] }.", + inputSchema: z.object({ + subjects: z.array(subjectSchema).min(1), + text: z.string().describe("ONE plain sentence: behavior, not mechanism; never the output schema restated."), + checks: z.array(checkSpecSchema).min(1), + }), + execute: async ({ subjects, text, checks }) => claims.addClaim({ subjects, text, checks }, actor), + }), + + list_claims: tool({ + description: + "A subject's active claims, each with its checks (id, step type + config or external description, when/policy) and its status COMPUTED from evidence: supported | refuted | stale (evidence is about an older version) | unknown (never checked). `assertedOnly` = the verdict rests on a model's or person's word, nothing observed; `unverified` = active checks with no evidence about the active version; `openSlot` = an external check is waiting on someone.", + inputSchema: z.object({ subject: subjectSchema }), + execute: async ({ subject }) => claims.listClaims(subject), + }), + + edit_claim: tool({ + description: + "Reword a claim. Claims are immutable once written, so this creates a SUCCESSOR that supersedes it and returns the successor's id: attachments and checks carry over, the old evidence stays on the old node, and the successor starts `unknown` until a run is verified again. Never publishes a workflow/step version.", + inputSchema: z.object({ id: z.string().describe("Claim id (from list_claims)"), text: z.string() }), + execute: async ({ id, text }) => claims.editClaim(id, text, actor), + }), + + retire_claim: tool({ + description: "Retire a claim that no longer holds as a requirement. It is never deleted — its evidence and history stay — it just stops being part of the contract.", + inputSchema: z.object({ id: z.string() }), + execute: async ({ id }) => claims.retireClaim(id, actor), + }), + + attach_claim: tool({ + description: "Attach an EXISTING claim to another subject — how a contract is shared (its checks come along), never by copying it. Each subject gets its own status. Attaching twice is a no-op.", + inputSchema: z.object({ id: z.string(), subject: subjectSchema }), + execute: async ({ id, subject }) => claims.attachClaim(id, subject, actor), + }), + + detach_claim: tool({ + description: "Detach a claim from ONE subject (it stays on its others). A claim's last subject cannot be detached — retire the claim instead.", + inputSchema: z.object({ id: z.string(), subject: subjectSchema }), + execute: async ({ id, subject }) => claims.detachClaim(id, subject, actor), + }), + + add_check: tool({ + description: + "Add another instrument to an existing claim — e.g. a free `exec` on every run beside an `llm` judge on change. Each check keeps its own policy, cost and evidence stream; a refutation from ANY check on the active version makes the claim refuted.", + inputSchema: z.object({ claim: z.string().describe("Claim id"), check: checkSpecSchema }), + execute: async ({ claim, check }) => claims.addCheck(claim, check, actor), + }), + + edit_check: tool({ + description: + "Change a check (its step, config, when, policy…). Pass only the fields to change. Checks are immutable once written: this creates a SUCCESSOR and returns its id; the old check's evidence stops counting — a changed instrument has measured nothing yet — so the claim reads `unknown` until it runs again.", + inputSchema: z.object({ id: z.string().describe("Check id (from list_claims)"), patch: checkSpecSchema }), + execute: async ({ id, patch }) => claims.editCheck(id, patch, actor), + }), + + retire_check: tool({ + description: "Retire a check. Refused when it is the claim's LAST active check — add the replacement first (add_check), or retire the claim.", + inputSchema: z.object({ id: z.string() }), + execute: async ({ id }) => claims.retireCheck(id, actor), + }), + } + : {}), + set_workflow_category: tool({ description: "Set or clear an existing workflow's sidebar category (the grouping " + diff --git a/src/authoring.test.ts b/src/authoring.test.ts index 778d9fc..dfa19a9 100644 --- a/src/authoring.test.ts +++ b/src/authoring.test.ts @@ -72,6 +72,16 @@ describe("authoring capability (the meta surface)", () => { "meta/get-run", "meta/search-runs", "meta/list-secrets", + // the claims surface (plans/claims.md §2) + "meta/add-claim", + "meta/list-claims", + "meta/edit-claim", + "meta/retire-claim", + "meta/attach-claim", + "meta/detach-claim", + "meta/add-check", + "meta/edit-check", + "meta/retire-check", ]) { assert.ok(registry[type], `expected "${type}" in the registry`); } diff --git a/src/authoring.ts b/src/authoring.ts index d9eef5b..2f3556e 100644 --- a/src/authoring.ts +++ b/src/authoring.ts @@ -7,6 +7,14 @@ import { runWorkflow } from "./runner.js"; import { runStep, cassettePath, type RunStepResult } from "./run-step.js"; import { stepHashesFor } from "./closure.js"; import { claimsReaderFor } from "./graph/claims.js"; +import { + buildClaimsAuthoring, + type CheckSpecInput, + type ClaimActor, + type ClaimSpecInput, + type SubjectInput, +} from "./claims-authoring.js"; +import { CLAIMS_OFF } from "./claims-schemas.js"; import { stepLoadError } from "./steps/registry.js"; import type { CassetteMode } from "./cassette.js"; import type { SecretInfo } from "./secret-store.js"; @@ -356,8 +364,11 @@ export interface AuthoringCapability { /** Description + JSON Schema of config/result; `source: true` adds a * lib/custom step's TypeScript (for editing or mirroring it). */ getStep(type: string, opts?: { source?: boolean }): Promise; - createStep(name: string, code: string, description?: string): Promise; - editStep(type: string, code: string, description?: string): Promise; + /** `claims` (here and on `editStep` / `publishWorkflow`): the contract, + * authored with the code. Additive and idempotent by exact text; invalid + * claims block the publish (plans/claims.md §2, door one). */ + createStep(name: string, code: string, description?: string, claims?: ClaimSpecInput[]): Promise; + editStep(type: string, code: string, description?: string, claims?: ClaimSpecInput[]): Promise; runStep(type: string, args?: RunStepArgs): Promise; listWorkflows(): Promise; getWorkflow(name: string, version?: string): Promise; @@ -369,6 +380,7 @@ export interface AuthoringCapability { yaml: string, description?: string, category?: string, + claims?: ClaimSpecInput[], ): Promise; runWorkflow( name: string, @@ -383,6 +395,21 @@ export interface AuthoringCapability { getRun(name: string, runId: string, fullEvents?: boolean): Promise; searchRuns(name: string, pattern: string, opts?: RunSearchOptions): Promise; listSecrets(): Promise; + + // ── Claims (plans/claims.md §2, door two) ── + // Publisher-scoped like everything else on this surface (fixed point 1): + // only claims / checks stamped `ai`, only subjects it published; its + // checks may never reach a grader (fixed point 2). On a filesystem + // workspace each returns `{ error }`. + addClaim(input: { subjects: SubjectInput[]; text: string; checks: CheckSpecInput[] }): Promise; + editClaim(id: string, text: string): Promise; + retireClaim(id: string): Promise; + listClaims(subject: SubjectInput): Promise; + attachClaim(id: string, subject: SubjectInput): Promise; + detachClaim(id: string, subject: SubjectInput): Promise; + addCheck(claimId: string, check: CheckSpecInput): Promise; + editCheck(id: string, patch: CheckSpecInput): Promise; + retireCheck(id: string): Promise; } export interface AuthoringDeps extends StepPublishDeps { @@ -413,6 +440,19 @@ export function buildAuthoringCapability(deps: AuthoringDeps): AuthoringCapabili const explorerDeps = async () => ({ workspace, registry: await deps.getRegistry() }); + // The claims layer exists only on a graph-backed workspace. + const claims = workspace.graph ? buildClaimsAuthoring({ graph: workspace.graph, workspace, getRegistry: deps.getRegistry }) : null; + const actor: ClaimActor = { publisher: AI_PUBLISHER, scoped: true }; + const claimsOff = { error: CLAIMS_OFF }; + /** Blocks a publish on an invalid contract; a contract passed where there + * is no claims layer is an error too — silently dropping it would read as + * "contract recorded". */ + const claimsGate = async (arg: ClaimSpecInput[] | undefined): Promise => { + if (!arg || arg.length === 0) return null; + if (!claims) return CLAIMS_OFF; + return (await claims.validateClaimsArg(arg, actor))?.error ?? null; + }; + const findWorkflow = async (name: string) => (await workspace.listWorkflows()).find((w) => w.name === name); @@ -462,8 +502,11 @@ export function buildAuthoringCapability(deps: AuthoringDeps): AuthoringCapabili }; }, - async createStep(name, code, description) { - const result = await publishNewStep(deps, name, code, description, AI_PUBLISHER); + async createStep(name, code, description, contract) { + const invalid = await claimsGate(contract); + if (invalid) return { error: `Nothing was published — fix the claims first. ${invalid}` }; + const published = await publishNewStep(deps, name, code, description, AI_PUBLISHER); + const result = published.ok && claims ? { ...published, claims: await claims.applyClaimsArg({ kind: "step", name }, contract, actor) } : published; // For the in-workflow author a broken publish is a FAILURE, not a // warning — §5.3.4: hand the import error back loudly. if (result.ok && result.loaded === false) { @@ -476,10 +519,13 @@ export function buildAuthoringCapability(deps: AuthoringDeps): AuthoringCapabili return result; }, - async editStep(type, code, description) { - const result = await publishStepVersion(deps, type, code, description, { + async editStep(type, code, description, contract) { + const invalid = await claimsGate(contract); + if (invalid) return { error: `Nothing was published — fix the claims first. ${invalid}` }; + const published = await publishStepVersion(deps, type, code, description, { requirePublisher: AI_PUBLISHER, }); + const result = published.ok && claims ? { ...published, claims: await claims.applyClaimsArg({ kind: "step", name: type }, contract, actor) } : published; if (result.ok && result.loaded === false) { return { ...result, @@ -558,7 +604,9 @@ export function buildAuthoringCapability(deps: AuthoringDeps): AuthoringCapabili }); }, - async publishWorkflow(name, yaml, description, category) { + async publishWorkflow(name, yaml, description, category, contract) { + const invalid = await claimsGate(contract); + if (invalid) return { error: `Nothing was published — fix the claims first. ${invalid}` }; const entry = await findWorkflow(name); if (entry && entry.publisher !== AI_PUBLISHER) { return { @@ -582,6 +630,7 @@ export function buildAuthoringCapability(deps: AuthoringDeps): AuthoringCapabili version: result.version, changed: result.changed, created: !entry, + ...(claims ? { claims: await claims.applyClaimsArg({ kind: "workflow", name }, contract, actor) } : {}), }; } catch (err) { return { error: err instanceof Error ? err.message : String(err) }; @@ -649,5 +698,16 @@ export function buildAuthoringCapability(deps: AuthoringDeps): AuthoringCapabili const secrets = await deps.secrets.list(); return { secrets: secrets.map((s) => ({ name: s.name, updatedAt: s.updatedAt })) }; }, + + // ── Claims — every call goes through the SCOPED actor ──────────────── + addClaim: async (input) => (claims ? claims.addClaim(input, actor) : claimsOff), + editClaim: async (id, text) => (claims ? claims.editClaim(id, text, actor) : claimsOff), + retireClaim: async (id) => (claims ? claims.retireClaim(id, actor) : claimsOff), + listClaims: async (subject) => (claims ? claims.listClaims(subject) : claimsOff), + attachClaim: async (id, subject) => (claims ? claims.attachClaim(id, subject, actor) : claimsOff), + detachClaim: async (id, subject) => (claims ? claims.detachClaim(id, subject, actor) : claimsOff), + addCheck: async (claimId, check) => (claims ? claims.addCheck(claimId, check, actor) : claimsOff), + editCheck: async (id, patch) => (claims ? claims.editCheck(id, patch, actor) : claimsOff), + retireCheck: async (id) => (claims ? claims.retireCheck(id, actor) : claimsOff), }; } diff --git a/src/claims-authoring.ts b/src/claims-authoring.ts new file mode 100644 index 0000000..219ea68 --- /dev/null +++ b/src/claims-authoring.ts @@ -0,0 +1,470 @@ +/** + * Claim + check authoring — one policy layer behind BOTH doors + * (`plans/claims.md` §2): the chat builder's tools and the `meta/*` twins an + * in-workflow author gets. `graph/claims-writer.ts` keeps the graph's + * invariants; this decides what a given AUTHOR may write: + * + * - a check spec is validated before anything is written: the step type + * exists, the config is JSON, enums and ranges hold, an external check + * says what to look at; + * - defaults are applied at write time, so the node is self-describing: + * `run_when: run`; `policy: always` for a code check, `on_change` for + * one PRESUMED PAID (an `agent` / `llm` step anywhere in the check + * closure, or a closure that cannot be resolved) and for an external + * check, where it paces the question (a person's time is the cost); + * - FIXED POINT 1 — a `scoped` actor (the meta surface) edits, retires and + * detaches only claims it stamped (`Claim.speaker_name`) and checks it + * stamped (`Check.publisher`), may ADD a check only to a claim it + * stamped, and may attach claims only to subjects it published. An + * always-passing check hung on a seeded contract line would otherwise + * read as `supported` whenever the real check was skipped; + * - FIXED POINT 2 — a check stamped `ai` may not reach a grader: no + * harness-only step type (`gaia/*`, `harvey/*`, `eval/*`, `meta/*`, plus + * `STRUT_VERIFY_DENY`) anywhere in the check CLOSURE — named, nested in + * a subflow child, or granted to an agent — and an unresolvable closure + * is refused. Enforced here at write; the verify pass enforces it again + * at run time, because a subflow child can be republished afterwards. + * + * Every method returns a plain result (`{ ok: true, … }` or `{ error }`) — + * the shape the tool layer hands to a model. + */ +import { closureIncludes, flowClosure, globToRegExp, type FlowClosure } from "./closure.js"; +import type { StepRegistry } from "./core.js"; +import type { GraphBackend } from "./graph/backend.js"; +import { ClaimsReader, isExternalCheck, type CheckPolicy, type CheckRow, type ClaimStatus, type RunWhen, type SubjectRef } from "./graph/claims.js"; +import { ClaimsError, ClaimsWriter, boundedName, type CheckData } from "./graph/claims-writer.js"; +import type { WorkspaceStore } from "./workspace.js"; + +// ── Inputs (the tool-facing shapes) ───────────────────────────────────────── + +/** A subject as a tool names it. */ +export interface SubjectInput { + kind: "step" | "workflow"; + /** Workflow name, or custom step type. */ + name: string; +} + +/** + * One check. A STEP check names a registry step (`type` + `config`); the + * subject is that step's run input, so config templates read + * `{{ input.output.* }}` / `{{ input.input.* }}`. An EXTERNAL check has no + * `type` — only a `description` of what to look at and why code cannot. + */ +export interface CheckSpecInput { + type?: string; + config?: Record; + name?: string; + description?: string; + when?: RunWhen; + policy?: CheckPolicy; + freshnessDays?: number; + sampleRate?: number; +} + +export interface ClaimSpecInput { + text: string; + checks: CheckSpecInput[]; +} + +/** Who is writing. `scoped` = the meta surface: only what it stamped. */ +export interface ClaimActor { + publisher: string; + scoped: boolean; +} + +export type ClaimsResult = ({ ok: true } & T) | { error: string }; + +export interface ClaimsAuthoringDeps { + graph: GraphBackend; + workspace: WorkspaceStore; + /** FRESH registry — a check may name a step published this turn. */ + getRegistry(): Promise; + env?: Record; +} + +/** The stamp fixed point 2 keys on (`authoring.ts` `AI_PUBLISHER`). */ +const AI_STAMP = "ai"; +const RUN_WHENS: readonly RunWhen[] = ["run", "publish"]; +const POLICIES: readonly CheckPolicy[] = ["always", "on_change", "sample", "manual"]; +const PAID_STEP_TYPES = ["agent", "llm"]; + +/** Harness-only namespaces a producer-visible check must never reach. */ +export const DEFAULT_VERIFY_DENY = ["gaia/*", "harvey/*", "eval/*", "meta/*"]; + +export function verifyDenyPatterns(env: Record = process.env): string[] { + const extra = (env["STRUT_VERIFY_DENY"] ?? "").split(",").map((s) => s.trim()).filter(Boolean); + return [...new Set([...DEFAULT_VERIFY_DENY, ...extra])]; +} + +/** + * The first grader a check closure reaches, or null. A step type is checked + * by name; an `agentTools` grant is checked both literally (`"gaia/*"`) and + * expanded over the registry (`"*"` reaches `gaia/evaluate`). + */ +export function deniedInClosure(closure: FlowClosure, deny: readonly string[], registryTypes: readonly string[]): string | null { + const res = deny.map((p) => ({ p, re: globToRegExp(p) })); + const hit = (type: string) => res.find((d) => d.re.test(type)); + for (const t of closure.types) if (hit(t)) return t; + for (const grant of closure.agentTools) { + if (hit(grant) || res.some((d) => d.p === grant)) return grant; + if (!grant.includes("*")) continue; + const re = globToRegExp(grant); + const reached = registryTypes.find((t) => re.test(t) && hit(t)); + if (reached) return `${grant} (reaches ${reached})`; + } + return null; +} + +export function toSubjectRef(s: SubjectInput): SubjectRef { + return s.kind === "workflow" ? { kind: "workflow", name: s.name } : { kind: "step", type: s.name }; +} + +const fail = (e: unknown): { error: string } => { + if (e instanceof ClaimsError) return { error: e.message }; + return { error: e instanceof Error ? e.message : String(e) }; +}; + +export interface ClaimListing { + id: string; + text: string; + speaker?: string; + status: ClaimStatus["status"]; + assertedOnly: boolean; + unverified: number; + openSlot: boolean; + checks: Array<{ + id: string; + name: string; + external: boolean; + type?: string; + config?: unknown; + description?: string; + when?: string; + policy?: string; + freshnessDays?: number; + sampleRate?: number; + publisher?: string; + }>; +} + +export type ClaimsAuthoring = ReturnType; + +export function buildClaimsAuthoring(deps: ClaimsAuthoringDeps) { + const reader = new ClaimsReader(deps.graph); + const writer = new ClaimsWriter(deps.graph, reader); + const deny = () => verifyDenyPatterns(deps.env ?? process.env); + + /** Validate one spec and apply the write-time defaults. Throws ClaimsError. */ + async function normalizeCheck(spec: CheckSpecInput, actor: ClaimActor, where: string): Promise { + if (!spec || typeof spec !== "object") throw new ClaimsError("INVALID", `${where}: a check is an object — { type, config } for a step check, { description } for an external one`); + const when = spec.when ?? "run"; + if (!RUN_WHENS.includes(when)) throw new ClaimsError("INVALID", `${where}: when must be one of ${RUN_WHENS.join(" | ")}`); + if (spec.policy !== undefined && !POLICIES.includes(spec.policy)) throw new ClaimsError("INVALID", `${where}: policy must be one of ${POLICIES.join(" | ")}`); + if (spec.freshnessDays !== undefined && !(Number.isInteger(spec.freshnessDays) && spec.freshnessDays > 0)) { + throw new ClaimsError("INVALID", `${where}: freshnessDays must be a positive integer`); + } + if (spec.sampleRate !== undefined && !(typeof spec.sampleRate === "number" && spec.sampleRate > 0 && spec.sampleRate <= 1)) { + throw new ClaimsError("INVALID", `${where}: sampleRate must be in (0, 1]`); + } + if (spec.policy === "sample" && spec.sampleRate === undefined) throw new ClaimsError("INVALID", `${where}: policy "sample" needs a sampleRate`); + const common = { + run_when: when, + ...(spec.freshnessDays !== undefined ? { freshness_days: spec.freshnessDays } : {}), + ...(spec.sampleRate !== undefined ? { sample_rate: spec.sampleRate } : {}), + publisher: actor.publisher, + }; + + // External: answered by a person or an outside system through a slot. + if (!spec.type) { + const description = spec.description?.trim(); + if (!description) { + throw new ClaimsError("INVALID", `${where}: give a step check a \`type\` (+ config), or an external check a \`description\` saying what to look at and why code cannot check it`); + } + if (spec.config !== undefined) throw new ClaimsError("INVALID", `${where}: \`config\` without a \`type\` — name the step that runs it`); + if (when === "publish") throw new ClaimsError("INVALID", `${where}: an external check cannot run at publish (there is no run to look at)`); + return { ...common, name: spec.name?.trim() || boundedName(description).slice(0, 60), description, policy: spec.policy ?? "on_change" }; + } + + const registry = await deps.getRegistry(); + if (!registry[spec.type]) throw new ClaimsError("INVALID", `${where}: step type "${spec.type}" not found — a check names a registry step (exec, llm, agent, subflow, or a custom step)`); + const config = spec.config ?? {}; + if (typeof config !== "object" || Array.isArray(config)) throw new ClaimsError("INVALID", `${where}: config must be an object`); + let step_config: string; + try { + step_config = JSON.stringify(config); + } catch { + throw new ClaimsError("INVALID", `${where}: config is not JSON-serializable`); + } + + const closure = await flowClosure({ steps: [{ id: "check", type: spec.type, config }] }, deps.workspace); + if (actor.publisher === AI_STAMP) { + if (!closure.resolvable) { + throw new ClaimsError("REFUSED", `${where}: this check's closure cannot be resolved (a subflow with a templated or missing \`workflow\`/\`version\`, or templated agentTools) — a check must name exactly what it runs`); + } + const grader = deniedInClosure(closure, deny(), Object.keys(registry)); + if (grader) { + throw new ClaimsError("REFUSED", `${where}: a check may not reach a harness-only step (${grader}) — a contract the producer can see must never embed its grader`); + } + } + const presumedPaid = !closure.resolvable || PAID_STEP_TYPES.some((t) => closureIncludes(closure, t)); + return { + ...common, + name: spec.name?.trim() || spec.type, + ...(spec.description?.trim() ? { description: spec.description.trim() } : {}), + step_type: spec.type, + step_config, + policy: spec.policy ?? (presumedPaid ? "on_change" : "always"), + }; + } + + async function normalizeClaims(claims: readonly ClaimSpecInput[], actor: ClaimActor): Promise> { + const out: Array<{ text: string; checks: CheckData[] }> = []; + const seen = new Set(); + for (const [i, c] of claims.entries()) { + const text = typeof c?.text === "string" ? c.text.trim() : ""; + if (!text) throw new ClaimsError("INVALID", `claims[${i}]: text is empty`); + if (seen.has(text)) throw new ClaimsError("INVALID", `claims[${i}]: the same text appears twice`); + seen.add(text); + if (!Array.isArray(c.checks) || c.checks.length === 0) { + throw new ClaimsError("INVALID", `claims[${i}] ("${boundedName(text).slice(0, 60)}"): every claim needs at least one check — if code cannot check it, give it an external check ({ description })`); + } + const checks: CheckData[] = []; + for (const [j, k] of c.checks.entries()) checks.push(await normalizeCheck(k, actor, `claims[${i}].checks[${j}]`)); + out.push({ text, checks }); + } + return out; + } + + /** Did `actor` publish this subject? (The meta surface's ownership rule.) */ + async function ownsSubject(subject: SubjectRef, actor: ClaimActor): Promise { + if (subject.kind === "step") return (await deps.workspace.listSteps({ publisher: actor.publisher })).some((s) => s.type === subject.type); + return (await deps.workspace.getWorkflowMetadata(subject.name))?.publisher === actor.publisher; + } + + async function requireOwnedSubjects(subjects: readonly SubjectRef[], actor: ClaimActor, verb: string): Promise { + if (!actor.scoped) return; + for (const s of subjects) { + if (!(await ownsSubject(s, actor))) { + throw new ClaimsError("REFUSED", `${s.kind} "${s.kind === "step" ? s.type : s.name}" was not published by "${actor.publisher}" — the meta surface only ${verb} subjects it authored`); + } + } + } + + async function requireOwnClaim(id: string, actor: ClaimActor, verb: string): Promise { + if (!actor.scoped) return; + const claim = await reader.getClaim(id); + if (claim && claim.speaker_name !== actor.publisher) { + throw new ClaimsError("REFUSED", `claim "${id}" was written by "${claim.speaker_name ?? "someone else"}" — the meta surface only ${verb} claims it wrote. Add your own claim instead.`); + } + } + + async function requireOwnCheck(id: string, actor: ClaimActor, verb: string): Promise { + const check = await reader.getCheck(id); + if (actor.scoped && check && check.publisher !== actor.publisher) { + throw new ClaimsError("REFUSED", `check "${id}" was written by "${check.publisher ?? "someone else"}" — the meta surface only ${verb} checks it wrote`); + } + return check; + } + + const listingOf = (k: CheckRow): ClaimListing["checks"][number] => { + let config: unknown; + if (k.step_config) { + try { + config = JSON.parse(k.step_config); + } catch { + config = k.step_config; + } + } + return { + id: k.id, + name: k.name, + external: isExternalCheck(k), + ...(k.step_type ? { type: k.step_type } : {}), + ...(config !== undefined ? { config } : {}), + ...(k.description ? { description: k.description } : {}), + ...(k.run_when ? { when: k.run_when } : {}), + ...(k.policy ? { policy: k.policy } : {}), + ...(k.freshness_days !== undefined ? { freshnessDays: k.freshness_days } : {}), + ...(k.sample_rate !== undefined ? { sampleRate: k.sample_rate } : {}), + ...(k.publisher ? { publisher: k.publisher } : {}), + }; + }; + + return { + reader, + writer, + + /** Door one, part 1 — validate a publish tool's `claims` arg BEFORE the + * publish, so a broken contract blocks it the way a YAML error does. */ + async validateClaimsArg(claims: readonly ClaimSpecInput[] | undefined, actor: ClaimActor): Promise<{ error: string } | null> { + if (!claims || claims.length === 0) return null; + try { + await normalizeClaims(claims, actor); + return null; + } catch (e) { + return fail(e); + } + }, + + /** + * Door one, part 2 — after the publish. The arg only ever ADDS: a claim + * whose text exactly matches an ACTIVE claim already about the subject + * is skipped (a republish with the same arg is a no-op); it never edits, + * retires or detaches. `count` is the subject's active claims afterwards + * — zero carries a warning the author has to answer. + */ + async applyClaimsArg( + subject: SubjectInput, + claims: readonly ClaimSpecInput[] | undefined, + actor: ClaimActor, + ): Promise<{ count: number; added: number; existing: number; warning?: string; error?: string }> { + const ref = toSubjectRef(subject); + let added = 0; + let existing = 0; + let error: string | undefined; + try { + const have = new Set((await reader.claimsFor(ref)).map((c) => c.claim_text)); + for (const c of await normalizeClaims(claims ?? [], actor)) { + if (have.has(c.text)) { + existing++; + continue; + } + await writer.addClaim({ subjects: [ref], text: c.text, speaker: actor.publisher, checks: c.checks }); + added++; + } + } catch (e) { + error = fail(e).error; + } + const count = (await reader.claimsFor(ref).catch(() => [])).length; + return { + count, + added, + existing, + ...(error ? { error: `published, but writing claims failed: ${error}` } : {}), + ...(count === 0 && !error + ? { warning: `This ${subject.kind} has NO claims. State how it should behave — pass \`claims\` when publishing, or call add_claim — before you run it; a ${subject.kind} with no contract cannot be verified.` } + : {}), + }; + }, + + async addClaim(input: { subjects: SubjectInput[]; text: string; checks: CheckSpecInput[] }, actor: ClaimActor): Promise> { + try { + const subjects = (input.subjects ?? []).map(toSubjectRef); + await requireOwnedSubjects(subjects, actor, "adds claims to"); + const [claim] = await normalizeClaims([{ text: input.text, checks: input.checks }], actor); + return { ok: true, ...(await writer.addClaim({ subjects, text: claim!.text, speaker: actor.publisher, checks: claim!.checks })) }; + } catch (e) { + return fail(e); + } + }, + + async editClaim(id: string, text: string, actor: ClaimActor): Promise> { + try { + await requireOwnClaim(id, actor, "edits"); + return { ok: true, ...(await writer.editClaim(id, text, actor.publisher)) }; + } catch (e) { + return fail(e); + } + }, + + async retireClaim(id: string, actor: ClaimActor): Promise> { + try { + await requireOwnClaim(id, actor, "retires"); + return { ok: true, ...(await writer.retireClaim(id)) }; + } catch (e) { + return fail(e); + } + }, + + async attachClaim(id: string, subject: SubjectInput, actor: ClaimActor): Promise> { + try { + const ref = toSubjectRef(subject); + await requireOwnedSubjects([ref], actor, "attaches claims to"); + return { ok: true, ...(await writer.attachClaim(id, ref)) }; + } catch (e) { + return fail(e); + } + }, + + async detachClaim(id: string, subject: SubjectInput, actor: ClaimActor): Promise> { + try { + await requireOwnClaim(id, actor, "detaches"); + return { ok: true, ...(await writer.detachClaim(id, toSubjectRef(subject))) }; + } catch (e) { + return fail(e); + } + }, + + async addCheck(claimId: string, spec: CheckSpecInput, actor: ClaimActor): Promise> { + try { + await requireOwnClaim(claimId, actor, "adds checks to"); + return { ok: true, ...(await writer.addCheck(claimId, await normalizeCheck(spec, actor, "check"))) }; + } catch (e) { + return fail(e); + } + }, + + /** `patch` is merged over the check as it stands; the result is + * re-validated as a whole and written as a SUCCESSOR. */ + async editCheck(id: string, patch: CheckSpecInput, actor: ClaimActor): Promise> { + try { + const old = await requireOwnCheck(id, actor, "edits"); + if (!old) throw new ClaimsError("NOT_FOUND", `check "${id}" not found`); + const current = listingOf(old); + const merged: CheckSpecInput = { + type: current.type, + config: current.config as Record | undefined, + name: current.name, + description: current.description, + when: current.when as RunWhen | undefined, + policy: current.policy as CheckPolicy | undefined, + freshnessDays: current.freshnessDays, + sampleRate: current.sampleRate, + ...Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined)), + }; + const next = await normalizeCheck(merged, actor, "check"); + const same = (["name", "description", "step_type", "step_config", "run_when", "policy", "freshness_days", "sample_rate"] as const).every( + (f) => (next[f] ?? undefined) === (old[f] ?? undefined), + ); + if (same) return { ok: true, id, unchanged: true }; + return { ok: true, ...(await writer.editCheck(id, next)) }; + } catch (e) { + return fail(e); + } + }, + + async retireCheck(id: string, actor: ClaimActor): Promise> { + try { + await requireOwnCheck(id, actor, "retires"); + return { ok: true, ...(await writer.retireCheck(id)) }; + } catch (e) { + return fail(e); + } + }, + + /** A subject's active claims, each with its checks and computed status. */ + async listClaims(subject: SubjectInput): Promise> { + try { + const ref = toSubjectRef(subject); + if (!(await reader.subjectRefId(ref))) throw new ClaimsError("NOT_FOUND", `${subject.kind} "${subject.name}" is not in the workspace`); + const rows = await reader.statusFor(ref); + return { + ok: true, + subject, + claims: rows.map((r) => ({ + id: r.claim.id, + text: r.claim.claim_text, + ...(r.claim.speaker_name ? { speaker: r.claim.speaker_name } : {}), + status: r.status.status, + assertedOnly: r.status.assertedOnly, + unverified: r.status.unverified, + openSlot: r.status.openSlot, + checks: r.checks.map(listingOf), + })), + }; + } catch (e) { + return fail(e); + } + }, + }; +} diff --git a/src/claims-schemas.ts b/src/claims-schemas.ts new file mode 100644 index 0000000..a689593 --- /dev/null +++ b/src/claims-schemas.ts @@ -0,0 +1,63 @@ +/** + * Zod shapes for the claim / check authoring surfaces — shared by the chat + * builder's tools (`ai/tools.ts`) and their `meta/*` twins, so both doors + * describe a check the same way. The descriptions are the model-facing docs. + */ +import { z } from "zod"; + +export const subjectSchema = z + .object({ + kind: z.enum(["step", "workflow"]).describe("What the claim is about: a custom step type, or a published workflow."), + name: z.string().describe("Workflow name, or custom step type (e.g. 'clip/compute-times')."), + }) + .describe("A claim's subject — the STABLE identity, never a version: the claim applies to whatever version is active when a run is verified."); + +export const checkSpecSchema = z + .object({ + type: z + .string() + .optional() + .describe( + "Registry step that RUNS the check: `exec` (a script — free, observed; prefer it), a custom step, `llm` / `agent` (a judgment — costs money, recorded as asserted), or `subflow` (a whole workflow, for anything bigger than a one-liner: config = { workflow, version?, input }). OMIT for an EXTERNAL check — one code cannot run — and give `description` instead.", + ), + config: z + .record(z.string(), z.any()) + .optional() + .describe( + "That step's config. The observed subject IS the check's input: `{{ input.output.* }}` is the step's output, `{{ input.input.* }}` its resolved config (for a workflow: its params + run input), `{{ input.error.message }}` when it failed, plus `{{ input.runId }}`, `{{ input.path }}`, `{{ input.artifactsDir }}`. A `publish` check reads `{{ input.source }}` (step) / `{{ input.yaml }}` (workflow). The check must RETURN { supports: boolean, content: string, locator?: { path?, start_time?, end_time?, url? } } — never throw on a failed assertion, return supports:false. A bare `exec` with no JSON on stdout maps from its exit code (0 = supports) with the output tail as content.", + ), + name: z.string().optional().describe("Short label, e.g. 'stt fuzzy-match'. Defaults to the step type."), + description: z + .string() + .optional() + .describe("What the check observes. REQUIRED for an external check: what to look at, and why code cannot check it."), + when: z + .enum(["run", "publish"]) + .optional() + .describe("`run` (default): fires on an execution of the subject. `publish`: fires when a new version is published, over its source — for lints."), + policy: z + .enum(["always", "on_change", "sample", "manual"]) + .optional() + .describe( + "When a run check fires. `always`: every verified run (default for free code checks — coverage comes from inputs). `on_change`: when the subject's version (or the check's own code) changed, it has no evidence yet, or its evidence is older than freshnessDays (default for llm/agent checks and external checks). `sample`: a sampleRate fraction of runs. `manual`: only on verify_run.", + ), + freshnessDays: z.number().int().positive().optional().describe("For on_change: re-run when the latest evidence is older than this (default 7) — catches environment drift."), + sampleRate: z.number().gt(0).lte(1).optional().describe("For sample: fraction of runs, in (0, 1]."), + }) + .describe("One instrument that can test the claim: a step check ({ type, config }) or an external check ({ description })."); + +export const claimSpecSchema = z.object({ + text: z + .string() + .describe("ONE plain sentence about how the subject should BEHAVE — not its mechanism, and never its output schema restated (e.g. 'the clip's audio contains the requested quote', 'fetches only the requested caption languages')."), + checks: z.array(checkSpecSchema).min(1).describe("At least one check. If code cannot check it, one external check saying what to look at and why."), +}); + +export const claimsArgSchema = z + .array(claimSpecSchema) + .optional() + .describe( + "The contract: claims about how this should behave, each with its checks. Author them IN THE SAME CALL as the code, before the first run. This arg only ever ADDS — a claim whose text exactly matches an active one is skipped, so republishing with the same list is a no-op; reword with edit_claim, remove with retire_claim.", + ); + +export const CLAIMS_OFF = "Claims need the graph-backed workspace (this deployment keeps workflows and steps on the filesystem)."; diff --git a/src/graph/claims-authoring.test.ts b/src/graph/claims-authoring.test.ts new file mode 100644 index 0000000..d2ba3b6 --- /dev/null +++ b/src/graph/claims-authoring.test.ts @@ -0,0 +1,392 @@ +/** + * Claim + check authoring (plans/claims.md §2): the graph writer's + * invariants, the policy layer (defaults, publisher scoping, the grader + * deny-list), and both doors — the `claims` arg on the publish tools and + * the standalone claim / check tools — over a live graph-backed workspace. + */ +import { describe, it, before, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { z } from "zod"; +import { defineStep, type StepRegistry } from "../core.js"; +import { coreRegistry } from "../steps/registry.js"; +import { MemoryRunStore } from "../store.js"; +import { WorkspaceManager } from "../workspace.js"; +import { AI_PUBLISHER, buildAuthoringCapability, type AuthoringCapability } from "../authoring.js"; +import { buildTools } from "../ai/tools.js"; +import { CLAIMS_SECTION, buildSystem } from "../ai/prompts.js"; +import { DEFAULT_VERIFY_DENY, buildClaimsAuthoring, deniedInClosure, verifyDenyPatterns, type ClaimActor, type ClaimsAuthoring } from "../claims-authoring.js"; +import { claimSpecSchema } from "../claims-schemas.js"; +import { flowClosure } from "../closure.js"; +import { openGraphBackend, type GraphBackend } from "./backend.js"; +import { evidenceId } from "./claims.js"; +import { Neo4jWorkspaceStore } from "./workspace-store.js"; +import { testGraphConfig, wipeGraph } from "./test-util.js"; + +const cfg = testGraphConfig(); + +// ── Pure ──────────────────────────────────────────────────────────────────── + +describe("grader deny-list (pure)", () => { + const types = ["exec", "llm", "clip/trim", "gaia/evaluate", "harvey/score"]; + const closureOf = (steps: Array<{ type: string; config?: Record }>) => + flowClosure({ steps: steps.map((s, i) => ({ id: `s${i}`, type: s.type, config: s.config ?? {} })) }); + + it("defaults + STRUT_VERIFY_DENY", () => { + assert.deepEqual(verifyDenyPatterns({}), DEFAULT_VERIFY_DENY); + assert.deepEqual(verifyDenyPatterns({ STRUT_VERIFY_DENY: " secret/*, gaia/* ,," }), [...DEFAULT_VERIFY_DENY, "secret/*"]); + }); + + it("a named grader, a literal grant, and a glob grant that REACHES one are all caught", async () => { + const deny = DEFAULT_VERIFY_DENY; + assert.equal(deniedInClosure(await closureOf([{ type: "exec" }, { type: "clip/trim" }]), deny, types), null); + assert.equal(deniedInClosure(await closureOf([{ type: "gaia/evaluate" }]), deny, types), "gaia/evaluate"); + assert.equal(deniedInClosure(await closureOf([{ type: "agent", config: { agentTools: ["meta/*"] } }]), deny, types), "meta/*"); + assert.equal(deniedInClosure(await closureOf([{ type: "agent", config: { agentTools: ["harvey/score"] } }]), deny, types), "harvey/score"); + assert.equal(deniedInClosure(await closureOf([{ type: "agent", config: { agentTools: ["*"] } }]), deny, types), "* (reaches gaia/evaluate)"); + assert.equal(deniedInClosure(await closureOf([{ type: "agent", config: { agentTools: ["clip/*"] } }]), deny, types), null); + }); + + it("the tool schema requires text and at least one check", () => { + assert.ok(claimSpecSchema.safeParse({ text: "t", checks: [{ type: "exec", config: { command: "true" } }] }).success); + assert.ok(claimSpecSchema.safeParse({ text: "t", checks: [{ description: "listen to the cut" }] }).success); + assert.ok(!claimSpecSchema.safeParse({ text: "t", checks: [] }).success); + assert.ok(!claimSpecSchema.safeParse({ text: "t", checks: [{ type: "exec", sampleRate: 2 }] }).success); + }); +}); + +describe("filesystem workspace: no claims layer (pure)", () => { + let dir: string; + before(async () => { + dir = await mkdtemp(join(tmpdir(), "strut-claims-fs-")); + }); + after(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("no claim tool, no `claims` arg, no prompt section; the meta twins answer with an error", async () => { + const workspace = new WorkspaceManager(dir); + const deps = { workspace, registry: {} as StepRegistry, store: new MemoryRunStore(), getRegistry: async () => ({}) as StepRegistry }; + const tools = buildTools(deps) as Record; + for (const t of ["add_claim", "list_claims", "edit_claim", "retire_claim", "attach_claim", "detach_claim", "add_check", "edit_check", "retire_check"]) { + assert.equal(tools[t], undefined, t); + } + assert.ok(!("claims" in tools["create_step"]!.inputSchema.shape) && !("claims" in tools["edit_workflow"]!.inputSchema.shape)); + assert.ok(!(await buildSystem(deps)).includes(CLAIMS_SECTION)); + + const authoring = buildAuthoringCapability({ workspace, store: new MemoryRunStore(), getRegistry: async () => ({}) as StepRegistry }); + assert.match(String(((await authoring.listClaims({ kind: "step", name: "x" })) as { error: string }).error), /graph-backed workspace/); + // A contract that cannot be recorded must not be silently dropped. + const refused = await authoring.createStep("my/step", "export default 1;", undefined, [{ text: "t", checks: [{ description: "look" }] }]); + assert.match(String(refused.error), /Nothing was published/); + assert.deepEqual(await workspace.listSteps(), []); + }); +}); + +// ── Live graph ────────────────────────────────────────────────────────────── + +const STEP_SRC = (type: string, tag = "one") => + `import { z, defineStep } from "strut";\nexport default defineStep({ type: "${type}", description: "${tag}", input: z.any(), output: z.any(), run: async () => ({ tag: "${tag}" }) });\n`; +const EXEC = { type: "exec", config: { command: "true" } }; + +describe("claims authoring (live Neo4j)", { skip: cfg ? false : "STRUT_TEST_NEO4J_URI not set" }, () => { + let backend: GraphBackend; + let ws: Neo4jWorkspaceStore; + let dir: string; + let registry: StepRegistry; + let claims: ClaimsAuthoring; + let authoring: AuthoringCapability; + let getRegistry: () => Promise; + const human: ClaimActor = { publisher: "evan", scoped: false }; + const chat: ClaimActor = { publisher: AI_PUBLISHER, scoped: false }; + const meta: ClaimActor = { publisher: AI_PUBLISHER, scoped: true }; + const STEP = { kind: "step" as const, name: "clip/compute-times" }; + const SEEDED = { kind: "workflow" as const, name: "gaia-produce" }; + const CANDIDATE = { kind: "workflow" as const, name: "candidate-1" }; + + const idOf = (r: unknown): string => { + const x = r as { ok?: true; id?: string; error?: string }; + assert.equal(x.error, undefined, x.error); + return x.id!; + }; + const errOf = (r: unknown): string => String((r as { error?: string }).error ?? ""); + const listed = async (subject: { kind: "step" | "workflow"; name: string }) => { + const r = await claims.listClaims(subject); + assert.ok("ok" in r, errOf(r)); + return (r as Extract).claims; + }; + + before(async () => { + backend = await openGraphBackend(cfg!, { embeddings: false, skipBoot: true }); + dir = await mkdtemp(join(tmpdir(), "strut-claims-")); + // Boot obligations by hand (skipBoot), ONCE: the ontology (Claim / Check / + // Evidence) + the Strut domain. Each test then clears data, not schemas. + await wipeGraph(backend.bolt); + const { seedJarvisOntology } = await import("./ontology-seed.js"); + const { seedStrutDomain } = await import("./schema-seed.js"); + await seedJarvisOntology(backend.bolt); + await seedStrutDomain(backend.bolt); + backend.schemas.invalidate(); + }); + after(async () => { + await backend.close(); + await rm(dir, { recursive: true, force: true }); + }); + beforeEach(async () => { + await backend.bolt.run(`MATCH (n) WHERE NOT n:Schema AND NOT n:Migration DETACH DELETE n`); + ws = new Neo4jWorkspaceStore(backend, { materializeDir: join(dir, "steps") }); + // A registry the checks can name: core + a stand-in grader and an llm-ish judge. + const grader = defineStep({ type: "gaia/evaluate", input: z.any(), output: z.any(), run: async () => ({}) }); + registry = { ...(await coreRegistry()), "gaia/evaluate": grader } as StepRegistry; + // Like the real one: whatever the workspace holds is loadable (stubbed — + // these tests are about claims, not module loading). + getRegistry = async () => ({ + ...registry, + ...Object.fromEntries((await ws.listSteps()).map((s) => [s.type, defineStep({ type: s.type, input: z.any(), output: z.any(), run: async () => ({}) })])), + }); + claims = buildClaimsAuthoring({ graph: backend, workspace: ws, getRegistry, env: {} }); + authoring = buildAuthoringCapability({ workspace: ws, store: new MemoryRunStore(), getRegistry }); + + await ws.publishStep("clip/compute-times", STEP_SRC("clip/compute-times"), "one", AI_PUBLISHER); + await ws.publishWorkflowByContent("gaia-produce", "name: gaia-produce\nsteps:\n - id: a\n type: log\n config: { message: hi }\n", "seeded", undefined, "seeder"); + await ws.publishWorkflowByContent("candidate-1", "name: candidate-1\nsteps:\n - id: a\n type: log\n config: { message: hi }\n", "candidate", undefined, AI_PUBLISHER); + }); + + it("door one: the `claims` arg adds, is idempotent by exact text, never retires, and warns on zero", async () => { + const none = await claims.applyClaimsArg(STEP, undefined, chat); + assert.deepEqual([none.count, none.added, none.existing], [0, 0, 0]); + assert.match(none.warning!, /NO claims/); + + const contract = [ + { text: "computes start/end inside the video's duration", checks: [EXEC, { description: "scrub to the cut and look — code cannot see framing" }] }, + { text: "fails loudly on a private video", checks: [{ type: "exec", config: { command: "test -n '{{ input.error.message }}'" }, name: "error surfaced" }] }, + ]; + const first = await claims.applyClaimsArg(STEP, contract, chat); + assert.deepEqual([first.count, first.added, first.existing, first.warning], [2, 2, 0, undefined]); + const again = await claims.applyClaimsArg(STEP, contract, chat); + assert.deepEqual([again.count, again.added, again.existing], [2, 0, 2], "a republish with the same arg is a no-op"); + const grown = await claims.applyClaimsArg(STEP, [contract[0]!, { text: "fetches only the requested caption languages", checks: [EXEC] }], chat); + assert.deepEqual([grown.count, grown.added, grown.existing], [3, 1, 1], "a new text is added; the omitted claim is NOT retired"); + + const rows = await listed(STEP); + assert.deepEqual(rows.map((r) => [r.text.slice(0, 8), r.status, r.speaker, r.checks.length]), [ + ["computes", "unknown", "ai", 2], + ["fails lo", "unknown", "ai", 1], + ["fetches ", "unknown", "ai", 1], + ]); + const [code, external] = rows[0]!.checks; + assert.deepEqual([code!.type, code!.config, code!.name, code!.when, code!.policy, code!.external, code!.publisher], ["exec", { command: "true" }, "exec", "run", "always", false, "ai"]); + assert.deepEqual([external!.external, external!.type, external!.policy, external!.when], [true, undefined, "on_change", "run"]); + // The graph shape: Claim —ABOUT→ the STABLE step, Check —TESTS→ Claim. + const shape = await backend.bolt.run( + `MATCH (k:Check)-[:TESTS]->(c:Claim)-[:ABOUT]->(s:StrutStep {step_type: "clip/compute-times"}) RETURN count(DISTINCT c) AS claims, count(k) AS checks`, + ); + assert.deepEqual(shape, [{ claims: 3, checks: 4 }]); + const ids = await backend.bolt.run(`MATCH (n) WHERE n:Claim OR n:Check RETURN collect(n.id) AS ids`); + for (const id of ids[0]!["ids"] as string[]) assert.match(id, /^[a-z0-9]{32}$/); + }); + + it("defaults: free code checks fire always; anything presumed paid — llm, agent, a subflow hiding one — fires on_change", async () => { + await ws.publishWorkflowByContent("judge", "name: judge\nsteps:\n - id: j\n type: llm\n config: { prompt: ok }\n"); + await ws.publishWorkflowByContent("matcher", "name: matcher\nsteps:\n - id: m\n type: exec\n config: { command: 'true' }\n"); + const id = idOf( + await claims.addClaim( + { + subjects: [STEP], + text: "the answer is bare", + checks: [ + EXEC, + { type: "llm", config: { prompt: "is it bare? {{ input.output.answer }}" } }, + { type: "subflow", config: { workflow: "judge", input: {} } }, + { type: "subflow", config: { workflow: "matcher", input: {} }, name: "fuzzy" }, + { type: "exec", config: { command: "true" }, policy: "manual", when: "publish", freshnessDays: 3 }, + ], + }, + human, + ), + ); + const [row] = (await listed(STEP)).filter((r) => r.id === id); + assert.deepEqual( + row!.checks.map((k) => [k.name, k.policy, k.when, k.publisher]), + [["exec", "always", "run", "evan"], ["llm", "on_change", "run", "evan"], ["subflow", "on_change", "run", "evan"], ["fuzzy", "always", "run", "evan"], ["exec", "manual", "publish", "evan"]], + ); + assert.equal(row!.checks[4]!.freshnessDays, 3); + }); + + it("validation: bad specs are refused with nothing written", async () => { + const bad = async (checks: unknown[], re: RegExp) => assert.match(errOf(await claims.addClaim({ subjects: [STEP], text: "t", checks: checks as never }, chat)), re); + await bad([], /at least one check/); + await bad([{}], /`type`.*or.*`description`/s); + await bad([{ type: "nope/missing" }], /not found/); + await bad([{ description: "look", config: { a: 1 } }], /without a `type`/); + await bad([{ description: "look", when: "publish" }], /external check cannot run at publish/); + await bad([{ type: "exec", policy: "sample" }], /needs a sampleRate/); + await bad([{ type: "exec", policy: "hourly" }], /policy must be one of/); + assert.match(errOf(await claims.addClaim({ subjects: [STEP], text: " ", checks: [EXEC] }, chat)), /text is empty/); + assert.match(errOf(await claims.addClaim({ subjects: [{ kind: "step", name: "exec" }], text: "t", checks: [EXEC] }, chat)), /built-in steps cannot/); + assert.match(errOf(await claims.addClaim({ subjects: [], text: "t", checks: [EXEC] }, chat)), /at least one subject/); + assert.equal((await backend.bolt.run(`MATCH (n) WHERE n:Claim OR n:Check RETURN count(n) AS c`))[0]!["c"], 0); + assert.deepEqual(await claims.validateClaimsArg([{ text: "a", checks: [EXEC] }, { text: "a", checks: [EXEC] }], chat), { error: "claims[1]: the same text appears twice" }); + }); + + it("edit_claim: a successor SUPERSEDES it, carries attachments and checks, and starts unknown; evidence stays behind", async () => { + const old = idOf(await claims.addClaim({ subjects: [STEP, CANDIDATE], text: "the clip contians the quote", checks: [EXEC] }, chat)); + const [check] = (await listed(STEP))[0]!.checks; + // Evidence on the OLD claim, about the active version. + const v = (await backend.bolt.run(`MATCH (v:StrutStepVersion {step_type: "clip/compute-times"}) RETURN v.ref_id AS r`))[0]!["r"] as string; + const oldRef = (await claims.reader.getClaim(old))!.ref_id; + const checkRef = (await claims.reader.getCheck(check!.id))!.ref_id; + const e = await backend.nodes.write({ type: "Evidence", data: { id: evidenceId(check!.id, "run-1", "p"), name: "n", content: "ok", evidence_mode: "observed", evidence_status: "collected", observed_at: 100 } }); + await backend.edges.writeMany([ + { edge: "EVIDENCED_BY", source_ref_id: oldRef, target_ref_id: e.ref_id, properties: { strength: 1 } }, + { edge: "PRODUCED_BY", source_ref_id: e.ref_id, target_ref_id: checkRef }, + { edge: "ABOUT", source_ref_id: e.ref_id, target_ref_id: v }, + ]); + assert.equal((await listed(STEP))[0]!.status, "supported"); + + assert.deepEqual(await claims.editClaim(old, "the clip contians the quote", chat), { ok: true, id: old, unchanged: true }); + const edited = await claims.editClaim(old, "the clip contains the quote", chat); + const successor = idOf(edited); + assert.notEqual(successor, old); + assert.equal((edited as { superseded?: string }).superseded, old); + + for (const subject of [STEP, CANDIDATE]) { + const rows = await listed(subject); + assert.deepEqual(rows.map((r) => [r.id, r.text, r.status]), [[successor, "the clip contains the quote", "unknown"]], subject.name); + assert.deepEqual(rows[0]!.checks.map((k) => k.id), [check!.id], "the instrument is carried, never cloned"); + } + const predecessor = (await claims.reader.getClaim(old))!; + assert.equal(typeof predecessor.belief_valid_to, "number"); + assert.deepEqual(await backend.bolt.run(`MATCH (a:Claim {id: $a})-[:SUPERSEDES]->(b:Claim {id: $b}) RETURN count(*) AS c`, { a: successor, b: old }), [{ c: 1 }]); + assert.equal((await claims.reader.evidenceFor(old)).length, 1, "old evidence stays on the old node"); + assert.deepEqual((await claims.reader.claimsTestedBy(check!.id)).map((c) => c.id), [successor], "a check tests exactly ONE active claim"); + assert.match(errOf(await claims.editClaim(old, "again", chat)), /retired or superseded/); + + idOf(await claims.retireClaim(successor, chat)); + assert.deepEqual(await listed(STEP), []); + assert.equal((await claims.reader.claimsFor({ kind: "step", type: STEP.name }, { includeRetired: true })).length, 2, "retired, never deleted"); + }); + + it("edit_check: a successor takes over TESTS and the old check's evidence stops counting; the last check cannot be retired", async () => { + const claim = idOf(await claims.addClaim({ subjects: [STEP], text: "bounds hold", checks: [EXEC] }, chat)); + const k1 = (await listed(STEP))[0]!.checks[0]!.id; + const v = (await backend.bolt.run(`MATCH (v:StrutStepVersion {step_type: "clip/compute-times"}) RETURN v.ref_id AS r`))[0]!["r"] as string; + const e = await backend.nodes.write({ type: "Evidence", data: { id: evidenceId(k1, "run-1", "p"), name: "n", content: "nope", evidence_mode: "observed", evidence_status: "collected", observed_at: 100 } }); + await backend.edges.writeMany([ + { edge: "EVIDENCED_BY", source_ref_id: (await claims.reader.getClaim(claim))!.ref_id, target_ref_id: e.ref_id, properties: { strength: -1 } }, + { edge: "PRODUCED_BY", source_ref_id: e.ref_id, target_ref_id: (await claims.reader.getCheck(k1))!.ref_id }, + { edge: "ABOUT", source_ref_id: e.ref_id, target_ref_id: v }, + ]); + assert.equal((await listed(STEP))[0]!.status, "refuted"); + + assert.deepEqual(await claims.editCheck(k1, { policy: "always" }, chat), { ok: true, id: k1, unchanged: true }); + const k2 = idOf(await claims.editCheck(k1, { config: { command: "test 1 -lt 2" }, name: "bounds" }, chat)); + const [row] = await listed(STEP); + assert.deepEqual([row!.status, row!.unverified], ["unknown", 1], "a changed instrument has measured nothing yet"); + assert.deepEqual(row!.checks.map((k) => [k.id, k.name, k.type, k.config, k.policy]), [[k2, "bounds", "exec", { command: "test 1 -lt 2" }, "always"]]); + assert.equal(typeof (await claims.reader.getCheck(k1))!.retired_at, "number"); + assert.deepEqual(await backend.bolt.run(`MATCH (:Check {id: $a})-[:SUPERSEDES]->(:Check {id: $b}) RETURN count(*) AS c`, { a: k2, b: k1 }), [{ c: 1 }]); + + assert.match(errOf(await claims.retireCheck(k2, chat)), /last active check/); + const k3 = idOf(await claims.addCheck(claim, { description: "listen to the cut — code cannot hear" }, chat)); + idOf(await claims.retireCheck(k2, chat)); + assert.deepEqual((await listed(STEP))[0]!.checks.map((k) => k.id), [k3]); + assert.match(errOf(await claims.retireCheck(k1, chat)), /retired or superseded/); + }); + + it("attach / detach: one node shared across subjects, idempotent, restorable, and never orphaned", async () => { + const id = idOf(await claims.addClaim({ subjects: [SEEDED], text: "answer is a bare string", checks: [EXEC] }, human)); + assert.deepEqual(await claims.attachClaim(id, CANDIDATE, chat), { ok: true, id, attached: true }); + assert.deepEqual(await claims.attachClaim(id, CANDIDATE, chat), { ok: true, id, attached: false }, "idempotent"); + assert.deepEqual((await listed(CANDIDATE)).map((r) => r.id), [id]); + assert.equal((await backend.bolt.run(`MATCH (c:Claim) RETURN count(c) AS c`))[0]!["c"], 1, "attached, never copied"); + + assert.deepEqual(await claims.detachClaim(id, CANDIDATE, chat), { ok: true, id, detached: true }); + assert.deepEqual(await listed(CANDIDATE), []); + assert.deepEqual(await claims.detachClaim(id, CANDIDATE, chat), { ok: true, id, detached: false }); + assert.match(errOf(await claims.detachClaim(id, SEEDED, chat)), /only subject — retire the claim/); + assert.deepEqual(await claims.attachClaim(id, CANDIDATE, chat), { ok: true, id, attached: true }, "a detached (muted) edge is restored"); + assert.deepEqual((await listed(CANDIDATE)).map((r) => r.id), [id]); + }); + + it("fixed point 1 — the meta surface only touches what it stamped, on subjects it published", async () => { + const seeded = idOf(await claims.addClaim({ subjects: [SEEDED], text: "answer is a bare string", checks: [EXEC] }, human)); + const seededCheck = (await listed(SEEDED))[0]!.checks[0]!.id; + + // The lineage move works: attach the seeded contract to an ai-published candidate… + assert.deepEqual(await authoring.attachClaim(seeded, CANDIDATE), { ok: true, id: seeded, attached: true }); + // …but the contract cannot be dropped, softened, or papered over. + assert.match(errOf(await authoring.detachClaim(seeded, CANDIDATE)), /only detaches claims it wrote/); + assert.match(errOf(await authoring.editClaim(seeded, "answer is anything")), /only edits claims it wrote/); + assert.match(errOf(await authoring.retireClaim(seeded)), /only retires claims it wrote/); + assert.match(errOf(await authoring.addCheck(seeded, EXEC)), /only adds checks to claims it wrote/); + assert.match(errOf(await authoring.editCheck(seededCheck, { config: { command: "true" } })), /only edits checks it wrote/); + assert.match(errOf(await authoring.retireCheck(seededCheck)), /only retires checks it wrote/); + // Nor may it write claims onto a subject it did not publish. + assert.match(errOf(await authoring.addClaim({ subjects: [SEEDED], text: "mine", checks: [EXEC] })), /only adds claims to subjects it authored/); + assert.match(errOf(await authoring.attachClaim(seeded, SEEDED)), /only attaches claims to subjects it authored/); + + // Its OWN claims on its own candidate: the full surface. + const own = idOf(await authoring.addClaim({ subjects: [CANDIDATE], text: "cites its sources", checks: [EXEC] })); + const ownCheck = ((await authoring.listClaims(CANDIDATE)) as { claims: Array<{ id: string; checks: Array<{ id: string }> }> }).claims.find((c) => c.id === own)!.checks[0]!.id; + idOf(await authoring.editCheck(ownCheck, { name: "cites" })); + const reworded = idOf(await authoring.editClaim(own, "cites every source it used")); + idOf(await authoring.retireClaim(reworded)); + // Reading a seeded contract is allowed — the producer is MEANT to see it. + assert.equal(((await authoring.listClaims(SEEDED)) as { claims: unknown[] }).claims.length, 1); + // The human-supervised chat surface is not scoped. + idOf(await claims.addCheck(seeded, EXEC, chat)); + }); + + it("fixed point 2 — an ai-stamped check may never reach a grader: by name, through a subflow, via a grant, or unresolvably", async () => { + await ws.publishWorkflowByContent("sneaky", "name: sneaky\nsteps:\n - id: g\n type: gaia/evaluate\n config: {}\n"); + await ws.publishWorkflowByContent("granting", "name: granting\nsteps:\n - id: a\n type: agent\n config: { prompt: go, agentTools: ['gaia/*'] }\n"); + const refuse = async (check: Record, re: RegExp) => { + for (const actor of [chat, meta]) assert.match(errOf(await claims.addClaim({ subjects: [CANDIDATE], text: "scores well", checks: [check as never] }, actor)), re); + }; + await refuse({ type: "gaia/evaluate", config: {} }, /harness-only step \(gaia\/evaluate\)/); + await refuse({ type: "subflow", config: { workflow: "sneaky", input: {} } }, /harness-only step \(gaia\/evaluate\)/); + await refuse({ type: "subflow", config: { workflow: "granting", input: {} } }, /harness-only step \(gaia\/\*\)/); + await refuse({ type: "agent", config: { prompt: "grade", agentTools: ["meta/*"] } }, /harness-only step \(meta\/\*\)/); + await refuse({ type: "subflow", config: { workflow: "{{ input.output.wf }}", input: {} } }, /closure cannot be resolved/); + assert.match(errOf(await authoring.createStep("cand/x", STEP_SRC("cand/x"), "d", [{ text: "t", checks: [{ type: "gaia/evaluate" }] }])), /Nothing was published.*harness-only/s); + assert.ok(!(await ws.listSteps()).some((s) => s.type === "cand/x"), "an invalid contract blocks the publish"); + + // A person (or a seeder) wiring the harness as a check is not the producer. + idOf(await claims.addClaim({ subjects: [SEEDED], text: "accuracy ≥ baseline", checks: [{ type: "subflow", config: { workflow: "sneaky", input: {} } }] }, human)); + // STRUT_VERIFY_DENY extends the list per deployment. + const strict = buildClaimsAuthoring({ graph: backend, workspace: ws, getRegistry, env: { STRUT_VERIFY_DENY: "log" } }); + assert.match(errOf(await strict.addClaim({ subjects: [CANDIDATE], text: "t", checks: [{ type: "log", config: { message: "x" } }] }, chat)), /harness-only step \(log\)/); + }); + + it("both doors, end to end: the capability publishes with a contract; the chat tools offer the claims surface", async () => { + const contract = [{ text: "returns a tag", checks: [{ type: "exec", config: { command: "test -n '{{ input.output.tag }}'" } }] }]; + const created = (await authoring.createStep("cand/step", STEP_SRC("cand/step"), "d", contract)) as { ok?: true; claims?: { count: number; added: number } }; + assert.deepEqual([created.ok, created.claims?.count, created.claims?.added], [true, 1, 1]); + const edited = (await authoring.editStep("cand/step", STEP_SRC("cand/step", "two"), "d", contract)) as { claims?: { count: number; added: number; existing: number } }; + assert.deepEqual(edited.claims, { count: 1, added: 0, existing: 1 }); + const bare = (await authoring.publishWorkflow("candidate-2", "name: candidate-2\nsteps:\n - id: a\n type: log\n config: { message: hi }\n")) as { claims?: { count: number; warning?: string } }; + assert.equal(bare.claims?.count, 0); + assert.match(bare.claims!.warning!, /NO claims/); + + const deps = { workspace: ws, registry, store: new MemoryRunStore(), getRegistry }; + const tools = buildTools(deps) as Record Promise> }>; + for (const t of ["add_claim", "list_claims", "edit_claim", "retire_claim", "attach_claim", "detach_claim", "add_check", "edit_check", "retire_check"]) assert.ok(tools[t], t); + for (const t of ["create_step", "edit_step", "create_workflow", "edit_workflow"]) assert.ok("claims" in tools[t]!.inputSchema.shape, t); + assert.ok((await buildSystem(deps)).includes(CLAIMS_SECTION)); + + const wf = await tools["create_workflow"]!.execute({ + name: "youtube-clip", + yaml: "name: youtube-clip\nsteps:\n - id: a\n type: log\n config: { message: hi }\n", + claims: [{ text: "the clip's audio contains the requested quote", checks: [{ description: "play the clip — no STT step is wired yet" }] }], + }); + assert.deepEqual([wf["ok"], (wf["claims"] as { count: number }).count], [true, 1]); + const listing = await tools["list_claims"]!.execute({ subject: { kind: "workflow", name: "youtube-clip" } }); + assert.deepEqual((listing["claims"] as Array<{ status: string; checks: Array<{ external: boolean }> }>).map((c) => [c.status, c.checks[0]!.external]), [["unknown", true]]); + const blocked = await tools["edit_workflow"]!.execute({ name: "youtube-clip", yaml: "name: youtube-clip\nsteps:\n - id: b\n type: log\n config: { message: v2 }\n", claims: [{ text: "x", checks: [{ type: "nope" }] }] }); + assert.match(String(blocked["error"]), /Nothing was published/); + assert.deepEqual((await ws.getWorkflowMetadata("youtube-clip"))!.active, "v1", "no version was published"); + }); +}); diff --git a/src/graph/claims-writer.ts b/src/graph/claims-writer.ts new file mode 100644 index 0000000..a2f4b8e --- /dev/null +++ b/src/graph/claims-writer.ts @@ -0,0 +1,256 @@ +/** + * Writes over the claim graph (`plans/claims.md` §2) — the invariants, with + * no opinion about WHO may write (publisher scoping and the grader deny-list + * live one layer up, in `claims-authoring.ts`): + * + * - a claim has at least one check, always: `addClaim` requires one, and + * retiring a claim's LAST active check is refused; + * - claims and checks are IMMUTABLE once written. An edit creates a + * successor that `SUPERSEDES` the node and closes the old one + * (`belief_valid_to` / `retired_at`). A superseded claim's evidence + * stays on the old node and the successor starts `unknown`; a retired + * check's evidence stays in the graph and never counts again; + * - a successor claim carries its `ABOUT` attachments AND its active + * checks across — each check gains a `TESTS` edge to the successor (an + * instrument is not a statement, so it is never cloned). A successor + * check takes over the `TESTS` edge(s) to the active claim; + * - nothing is ever deleted: retiring sets a timestamp, detaching mutes + * the `ABOUT` edge (jarvis's soft delete). + * + * Nodes go through the ordinary `NodeWriter` / `EdgeWriter`, which resolve + * Claim / Check from the `:Schema` meta-graph. Node and edge writes are + * separate transactions, ordered so that a crash between them leaves only + * an unreachable node (no `ABOUT` / `TESTS` edge), never a half-visible one. + * + * Type-only imports: never loads neo4j-driver by itself. + */ +import type { GraphBackend } from "./backend.js"; +import type { EdgeInput } from "./edge-writer.js"; +import { + CHECK_TYPE, + CLAIM_EDGES, + CLAIM_TYPE, + ClaimsReader, + newEpistemicId, + subjectName, + type CheckPolicy, + type CheckRow, + type ClaimRow, + type RunWhen, + type SubjectRef, +} from "./claims.js"; + +export type ClaimsErrorCode = "NOT_FOUND" | "INVALID" | "REFUSED"; + +/** A write the claim graph's rules do not allow. `message` is written for + * the tool result the model (or a person) reads. */ +export class ClaimsError extends Error { + constructor( + readonly code: ClaimsErrorCode, + message: string, + ) { + super(message); + this.name = "ClaimsError"; + } +} + +/** A check as stored — every default already applied by the caller. */ +export interface CheckData { + name: string; + description?: string; + /** Absent = an external check. */ + step_type?: string; + /** JSON. */ + step_config?: string; + run_when: RunWhen; + policy: CheckPolicy; + freshness_days?: number; + sample_rate?: number; + publisher?: string; +} + +const NAME_MAX = 200; +const nowSeconds = () => Math.trunc(Date.now() / 1000); + +/** jarvis's required title: the sentence, bounded. */ +export function boundedName(text: string): string { + const t = text.trim().replace(/\s+/g, " "); + return t.length > NAME_MAX ? `${t.slice(0, NAME_MAX - 1)}…` : t; +} + +function checkNode(id: string, data: CheckData, at: number) { + const out: Record = { id, created_at: at }; + for (const [k, v] of Object.entries(data)) if (v !== undefined && v !== null && v !== "") out[k] = v; + return { type: CHECK_TYPE, data: out }; +} + +const describeSubject = (s: SubjectRef) => `${s.kind} "${subjectName(s)}"`; + +export class ClaimsWriter { + readonly reader: ClaimsReader; + + constructor( + private readonly graph: Pick, + reader?: ClaimsReader, + ) { + this.reader = reader ?? new ClaimsReader(graph); + } + + private async subjectRefs(subjects: readonly SubjectRef[]): Promise { + if (subjects.length === 0) throw new ClaimsError("INVALID", "a claim needs at least one subject"); + const refs: string[] = []; + for (const s of subjects) { + const ref = await this.reader.subjectRefId(s); + if (!ref) { + throw new ClaimsError( + "NOT_FOUND", + `${describeSubject(s)} is not in the workspace — only published workflows and custom steps can carry claims (built-in steps cannot)`, + ); + } + if (!refs.includes(ref)) refs.push(ref); + } + return refs; + } + + private async activeClaim(id: string): Promise { + const claim = await this.reader.getClaim(id); + if (!claim) throw new ClaimsError("NOT_FOUND", `claim "${id}" not found`); + if (claim.belief_valid_to !== undefined) { + throw new ClaimsError("REFUSED", `claim "${id}" is retired or superseded — list_claims shows the active ones`); + } + return claim; + } + + private async activeCheck(id: string): Promise { + const check = await this.reader.getCheck(id); + if (!check) throw new ClaimsError("NOT_FOUND", `check "${id}" not found`); + if (check.retired_at !== undefined) throw new ClaimsError("REFUSED", `check "${id}" is retired or superseded`); + return check; + } + + /** One `Claim`, `ABOUT` every subject, with one `Check —TESTS→` it per spec. */ + async addClaim(input: { subjects: readonly SubjectRef[]; text: string; speaker?: string; checks: readonly CheckData[] }): Promise<{ id: string; checks: string[] }> { + const text = input.text.trim(); + if (!text) throw new ClaimsError("INVALID", "claim text is empty"); + if (input.checks.length === 0) { + throw new ClaimsError("INVALID", "a claim needs at least one check — if code cannot check it, give it an external check whose description says what to look at and why"); + } + const subjectRefs = await this.subjectRefs(input.subjects); + const at = nowSeconds(); + const id = newEpistemicId(); + const checkIds = input.checks.map(() => newEpistemicId()); + const written = await this.graph.nodes.writeMany( + [ + { type: CLAIM_TYPE, data: { id, name: boundedName(text), claim_text: text, belief_valid_from: at, ...(input.speaker ? { speaker_name: input.speaker } : {}) } }, + ...input.checks.map((c, i) => checkNode(checkIds[i]!, c, at)), + ], + "create", + ); + const claimRef = written[0]!.ref_id; + const edges: EdgeInput[] = [ + ...written.slice(1).map((k) => ({ edge: CLAIM_EDGES.TESTS, source_ref_id: k.ref_id, target_ref_id: claimRef })), + // ABOUT last: it is what makes the claim visible at all. + ...subjectRefs.map((s) => ({ edge: CLAIM_EDGES.ABOUT, source_ref_id: claimRef, target_ref_id: s })), + ]; + await this.graph.edges.writeMany(edges); + return { id, checks: checkIds }; + } + + /** Reword a claim: a successor that `SUPERSEDES` it. Returns the + * SUCCESSOR's id (the same id, `unchanged`, when the text is identical). */ + async editClaim(id: string, text: string, speaker?: string): Promise<{ id: string; superseded?: string; unchanged?: true }> { + const old = await this.activeClaim(id); + const next = text.trim(); + if (!next) throw new ClaimsError("INVALID", "claim text is empty"); + if (next === old.claim_text) return { id, unchanged: true }; + const [subjects, checks] = await Promise.all([this.reader.subjectsOf(id), this.reader.checksFor(id)]); + const at = nowSeconds(); + const successor = newEpistemicId(); + const node = await this.graph.nodes.write( + { type: CLAIM_TYPE, data: { id: successor, name: boundedName(next), claim_text: next, belief_valid_from: at, ...(speaker ? { speaker_name: speaker } : {}) } }, + "create", + ); + await this.graph.edges.writeMany([ + { edge: CLAIM_EDGES.SUPERSEDES, source_ref_id: node.ref_id, target_ref_id: old.ref_id }, + ...checks.map((k) => ({ edge: CLAIM_EDGES.TESTS, source_ref_id: k.ref_id, target_ref_id: node.ref_id })), + ...subjects.map((s) => ({ edge: CLAIM_EDGES.ABOUT, source_ref_id: node.ref_id, target_ref_id: s.ref_id })), + ]); + // Closed last: until then the old claim is still the active one. + await this.graph.nodes.update(old.ref_id, { set: { belief_valid_to: at } }); + return { id: successor, superseded: id }; + } + + /** Retire a claim. Never deleted: its evidence and history stay. */ + async retireClaim(id: string): Promise<{ id: string }> { + const claim = await this.activeClaim(id); + await this.graph.nodes.update(claim.ref_id, { set: { belief_valid_to: nowSeconds() } }); + return { id }; + } + + /** Share a claim with another subject — never by copying the node. An + * existing edge is a no-op; a detached (muted) one is restored. */ + async attachClaim(id: string, subject: SubjectRef): Promise<{ id: string; attached: boolean }> { + const claim = await this.activeClaim(id); + const [subjectRef] = await this.subjectRefs([subject]); + const written = await this.graph.edges.write({ edge: CLAIM_EDGES.ABOUT, source_ref_id: claim.ref_id, target_ref_id: subjectRef! }); + if (written.created) return { id, attached: true }; + const rows = await this.graph.bolt.run(`MATCH ()-[r {ref_id: $r}]->() RETURN coalesce(r.is_muted, false) AS muted`, { r: written.ref_id }); + if (rows[0]?.["muted"] !== true) return { id, attached: false }; + await this.graph.edges.update({ ref_id: written.ref_id }, { set: { is_muted: false } }); + return { id, attached: true }; + } + + /** Detach a claim from ONE subject. Its last subject cannot be detached — + * a claim about nothing is a retired claim; say so with `retireClaim`. */ + async detachClaim(id: string, subject: SubjectRef): Promise<{ id: string; detached: boolean }> { + await this.activeClaim(id); + const attached = await this.reader.subjectsOf(id); + const hit = attached.find((a) => a.subject.kind === subject.kind && subjectName(a.subject) === subjectName(subject)); + if (!hit) return { id, detached: false }; + if (attached.length === 1) { + throw new ClaimsError("REFUSED", `${describeSubject(subject)} is this claim's only subject — retire the claim instead of detaching it`); + } + await this.graph.edges.mute(hit.edge_ref_id); + return { id, detached: true }; + } + + /** Add an instrument to an active claim. */ + async addCheck(claimId: string, data: CheckData): Promise<{ id: string }> { + const claim = await this.activeClaim(claimId); + const id = newEpistemicId(); + const node = await this.graph.nodes.write(checkNode(id, data, nowSeconds()), "create"); + await this.graph.edges.write({ edge: CLAIM_EDGES.TESTS, source_ref_id: node.ref_id, target_ref_id: claim.ref_id }); + return { id }; + } + + /** Replace a check: a successor that `SUPERSEDES` it and takes over its + * `TESTS` edge; the old node is retired. `data` is the FULL new check. */ + async editCheck(id: string, data: CheckData): Promise<{ id: string; superseded: string }> { + const old = await this.activeCheck(id); + const claims = await this.reader.claimsTestedBy(id); + if (claims.length === 0) throw new ClaimsError("REFUSED", `check "${id}" tests no active claim — add a check to the claim you mean instead`); + const at = nowSeconds(); + const successor = newEpistemicId(); + const node = await this.graph.nodes.write(checkNode(successor, data, at), "create"); + await this.graph.edges.writeMany([ + { edge: CLAIM_EDGES.SUPERSEDES, source_ref_id: node.ref_id, target_ref_id: old.ref_id }, + ...claims.map((c) => ({ edge: CLAIM_EDGES.TESTS, source_ref_id: node.ref_id, target_ref_id: c.ref_id })), + ]); + await this.graph.nodes.update(old.ref_id, { set: { retired_at: at } }); + return { id: successor, superseded: id }; + } + + /** Retire a check. Refused when it is a claim's LAST active check: add + * the replacement first, or retire the claim. */ + async retireCheck(id: string): Promise<{ id: string }> { + const check = await this.activeCheck(id); + for (const claim of await this.reader.claimsTestedBy(id)) { + const others = (await this.reader.checksFor(claim.id)).filter((k) => k.id !== id); + if (others.length === 0) { + throw new ClaimsError("REFUSED", `check "${id}" is the last active check on claim "${claim.id}" — add its replacement first (add_check), or retire the claim`); + } + } + await this.graph.nodes.update(check.ref_id, { set: { retired_at: nowSeconds() } }); + return { id }; + } +} diff --git a/src/graph/claims.test.ts b/src/graph/claims.test.ts index 4d5be04..16caf75 100644 --- a/src/graph/claims.test.ts +++ b/src/graph/claims.test.ts @@ -164,9 +164,11 @@ describe("claimStatus (pure)", () => { describe("claim ids (pure)", () => { it("claim/check ids are lowercase alphanumeric, so node_key sanitizing cannot collide two", () => { - const ids = new Set(Array.from({ length: 50 }, () => newEpistemicId())); - assert.equal(ids.size, 50); + const made = Array.from({ length: 500 }, () => newEpistemicId()); + const ids = new Set(made); + assert.equal(ids.size, 500); for (const id of ids) assert.match(id, /^[a-z0-9]{32}$/); + assert.deepEqual([...made].sort(), made, "time-sortable: id order is creation order, even within one millisecond"); assert.ok(isEpistemicId("ab1") && !isEpistemicId("aB-1") && !isEpistemicId("") && !isEpistemicId(7)); }); diff --git a/src/graph/claims.ts b/src/graph/claims.ts index cb85b0e..c2f889a 100644 --- a/src/graph/claims.ts +++ b/src/graph/claims.ts @@ -58,14 +58,26 @@ export const DEFAULT_FRESHNESS_DAYS = 7; const EPISTEMIC_ID = /^[a-z0-9]+$/; +let lastIdMs = 0; +let lastIdSeq = 0; + /** * Identity for a `Claim` / `Check` — never derived from the text. Lowercase * alphanumerics ONLY: `node_key` is `claim-` after jarvis's sanitizer * lowercases and drops every non-alphanumeric, so `aB-1` and `ab1` would * collide on one node. + * + * Time-sortable (ULID-style): 9 base36 chars of epoch ms, 3 of a per-process + * sequence within that ms, 20 random — so ordering by id is ordering by + * creation, and a contract lists in the order it was written even when its + * claims share one `belief_valid_from` second. */ export function newEpistemicId(): string { - return randomUUID().replace(/-/g, ""); + const ms = Math.max(Date.now(), lastIdMs); // never backwards, even if the clock is + lastIdSeq = ms === lastIdMs ? lastIdSeq + 1 : 0; + lastIdMs = ms; + const random = randomUUID().replace(/-/g, "").slice(0, 20); + return `${ms.toString(36).padStart(9, "0")}${lastIdSeq.toString(36).padStart(3, "0")}${random}`; } export function isEpistemicId(id: unknown): id is string { @@ -407,6 +419,64 @@ export class ClaimsReader { return typeof v === "string" && v ? v : null; } + /** `ref_id` of a subject's STABLE node, or null when the workspace has no + * such step / workflow (built-in steps have no node). */ + async subjectRefId(subject: SubjectRef): Promise { + const n = SUBJECT_NODE[subject.kind]; + const rows = await this.graph.bolt.run( + `MATCH (s:\`${n.label}\` {namespace: $ns, \`${n.key}\`: $name}) WHERE ${NOT_DELETED("s")} RETURN s.ref_id AS r LIMIT 1`, + { ns: this.ns, name: subjectName(subject) }, + ); + return typeof rows[0]?.["r"] === "string" ? (rows[0]!["r"] as string) : null; + } + + /** One claim by id (active or not), or null. */ + async getClaim(id: string): Promise { + const rows = await this.graph.bolt.run( + `MATCH (c:\`${CLAIM_TYPE}\` {namespace: $ns, id: $id}) WHERE ${NOT_DELETED("c")} RETURN ${project("c", CLAIM_FIELDS)} AS claim LIMIT 1`, + { ns: this.ns, id }, + ); + return rows.length ? compact(rows[0]!["claim"] as Record) : null; + } + + /** One check by id (active or not), or null. */ + async getCheck(id: string): Promise { + const rows = await this.graph.bolt.run( + `MATCH (k:\`${CHECK_TYPE}\` {namespace: $ns, id: $id}) WHERE ${NOT_DELETED("k")} RETURN ${project("k", CHECK_FIELDS)} AS chk LIMIT 1`, + { ns: this.ns, id }, + ); + return rows.length ? compact(rows[0]!["chk"] as Record) : null; + } + + /** The subjects a claim is attached to (live `ABOUT` edges), with the + * edge's ref_id — what `detach` mutes. */ + async subjectsOf(claimId: string): Promise> { + const rows = await this.graph.bolt.run( + `MATCH (c:\`${CLAIM_TYPE}\` {namespace: $ns, id: $id})-[a:\`${CLAIM_EDGES.ABOUT}\`]->(s) + WHERE ${LIVE("a")} AND ${NOT_DELETED("s")} AND (s:StrutStep OR s:StrutWorkflow) + RETURN s:StrutStep AS is_step, s.step_type AS step_type, s.name AS name, s.ref_id AS ref_id, a.ref_id AS edge_ref_id + ORDER BY is_step, name, step_type`, + { ns: this.ns, id: claimId }, + ); + return rows.map((r) => ({ + subject: r["is_step"] === true ? { kind: "step" as const, type: String(r["step_type"]) } : { kind: "workflow" as const, name: String(r["name"]) }, + ref_id: String(r["ref_id"]), + edge_ref_id: String(r["edge_ref_id"]), + })); + } + + /** The ACTIVE claims a check `TESTS` — exactly one, by the writer's + * invariant; more only transiently. */ + async claimsTestedBy(checkId: string): Promise { + const rows = await this.graph.bolt.run( + `MATCH (k:\`${CHECK_TYPE}\` {namespace: $ns, id: $id})-[t:\`${CLAIM_EDGES.TESTS}\`]->(c:\`${CLAIM_TYPE}\`) + WHERE ${LIVE("t")} AND ${NOT_DELETED("c")} AND c.belief_valid_to IS NULL + RETURN ${project("c", CLAIM_FIELDS)} AS claim ORDER BY c.belief_valid_from, c.id`, + { ns: this.ns, id: checkId }, + ); + return rows.map((r) => compact(r["claim"] as Record)); + } + /** Claims `ABOUT` a subject — active ones unless `includeRetired`. */ async claimsFor(subject: SubjectRef, opts: { includeRetired?: boolean } = {}): Promise { const n = SUBJECT_NODE[subject.kind]; diff --git a/src/index.ts b/src/index.ts index 3769e5a..25e4892 100644 --- a/src/index.ts +++ b/src/index.ts @@ -307,6 +307,23 @@ export { type EvidenceMode, type EvidenceStatus, } from "./graph/claims.js"; +export { ClaimsWriter, ClaimsError, boundedName, type CheckData, type ClaimsErrorCode } from "./graph/claims-writer.js"; +export { + buildClaimsAuthoring, + deniedInClosure, + verifyDenyPatterns, + toSubjectRef, + DEFAULT_VERIFY_DENY, + type ClaimsAuthoring, + type ClaimsAuthoringDeps, + type ClaimActor, + type ClaimSpecInput, + type CheckSpecInput, + type SubjectInput, + type ClaimListing, + type ClaimsResult, +} from "./claims-authoring.js"; +export { subjectSchema, checkSpecSchema, claimSpecSchema, claimsArgSchema } from "./claims-schemas.js"; export { NodeWriter, GraphValidationError, diff --git a/src/steps/lib/meta/add-check.ts b/src/steps/lib/meta/add-check.ts new file mode 100644 index 0000000..733bf26 --- /dev/null +++ b/src/steps/lib/meta/add-check.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; +import { defineStep } from "../../../core.js"; +import { requireAuthoring } from "./_shared.js"; +import { checkSpecSchema } from "../../../claims-schemas.js"; + +export default defineStep({ + type: "meta/add-check", + description: + "Add another instrument to an existing claim — e.g. a free `exec` on every run beside an `llm` judge on change. Each check keeps its own policy, cost and evidence stream; a refutation from ANY check on the active version makes the claim refuted. Publisher-scoped like the rest of meta/*: it acts only on claims and checks stamped 'ai' and on subjects this surface published, and a check may never reach a harness-only step (gaia/*, harvey/*, eval/*, meta/*) — by name, inside a subflow, or granted to an agent.", + input: z.object({ claim: z.string().describe("Claim id"), check: checkSpecSchema }), + output: z.any(), + async run(cfg, ctx) { + return requireAuthoring(ctx.services).addCheck(cfg.claim, cfg.check); + }, +}); diff --git a/src/steps/lib/meta/add-claim.ts b/src/steps/lib/meta/add-claim.ts new file mode 100644 index 0000000..ddbd1f8 --- /dev/null +++ b/src/steps/lib/meta/add-claim.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; +import { defineStep } from "../../../core.js"; +import { requireAuthoring } from "./_shared.js"; +import { checkSpecSchema, subjectSchema } from "../../../claims-schemas.js"; + +export default defineStep({ + type: "meta/add-claim", + description: + "State how a step or workflow SHOULD behave, with the check(s) that test it — for a subject you are NOT republishing (when you are, pass `claims` to meta/create-step / meta/edit-step / meta/publish-workflow instead). The regression move: a failure you fixed becomes a claim with a check, so it cannot come back silently. One claim may be about several subjects. Every claim needs at least one check; evidence comes from verifying runs, never from this call. Returns { id, checks: [ids] }. Publisher-scoped like the rest of meta/*: it acts only on claims and checks stamped 'ai' and on subjects this surface published, and a check may never reach a harness-only step (gaia/*, harvey/*, eval/*, meta/*) — by name, inside a subflow, or granted to an agent.", + input: z.object({ + subjects: z.array(subjectSchema).min(1), + text: z.string().describe("ONE plain sentence: behavior, not mechanism; never the output schema restated."), + checks: z.array(checkSpecSchema).min(1), + }), + output: z.any(), + async run(cfg, ctx) { + return requireAuthoring(ctx.services).addClaim({ subjects: cfg.subjects, text: cfg.text, checks: cfg.checks }); + }, +}); diff --git a/src/steps/lib/meta/attach-claim.ts b/src/steps/lib/meta/attach-claim.ts new file mode 100644 index 0000000..44ec907 --- /dev/null +++ b/src/steps/lib/meta/attach-claim.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; +import { defineStep } from "../../../core.js"; +import { requireAuthoring } from "./_shared.js"; +import { subjectSchema } from "../../../claims-schemas.js"; + +export default defineStep({ + type: "meta/attach-claim", + description: + "Attach an EXISTING claim — anyone's — to a subject this surface published: how a contract is shared across a lineage (its checks come along), never by copying it. Each subject gets its own status. Idempotent: an existing attachment is a no-op.", + input: z.object({ id: z.string().describe("Claim id"), subject: subjectSchema }), + output: z.any(), + async run(cfg, ctx) { + return requireAuthoring(ctx.services).attachClaim(cfg.id, cfg.subject); + }, +}); diff --git a/src/steps/lib/meta/create-step.ts b/src/steps/lib/meta/create-step.ts index 19b2799..9d18409 100644 --- a/src/steps/lib/meta/create-step.ts +++ b/src/steps/lib/meta/create-step.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { defineStep } from "../../../core.js"; import { requireAuthoring } from "./_shared.js"; +import { claimsArgSchema } from "../../../claims-schemas.js"; export default defineStep({ type: "meta/create-step", @@ -16,9 +17,10 @@ export default defineStep({ 'Full TypeScript source. Shape: import { z, defineStep } from "strut"; export default defineStep({ type: "", input: z.object({...}), output: z.any(), async run(cfg, ctx) {...} });', ), description: z.string().optional().describe("one-line summary stored with the step, shown in step listings"), + claims: claimsArgSchema, }), output: z.any(), async run(cfg, ctx) { - return requireAuthoring(ctx.services).createStep(cfg.name, cfg.code, cfg.description); + return requireAuthoring(ctx.services).createStep(cfg.name, cfg.code, cfg.description, cfg.claims); }, }); diff --git a/src/steps/lib/meta/detach-claim.ts b/src/steps/lib/meta/detach-claim.ts new file mode 100644 index 0000000..fb3ccd9 --- /dev/null +++ b/src/steps/lib/meta/detach-claim.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; +import { defineStep } from "../../../core.js"; +import { requireAuthoring } from "./_shared.js"; +import { subjectSchema } from "../../../claims-schemas.js"; + +export default defineStep({ + type: "meta/detach-claim", + description: + "Detach a claim from ONE subject (it stays on its others). A claim's last subject cannot be detached — retire the claim instead. Publisher-scoped like the rest of meta/*: it acts only on claims and checks stamped 'ai' and on subjects this surface published, and a check may never reach a harness-only step (gaia/*, harvey/*, eval/*, meta/*) — by name, inside a subflow, or granted to an agent.", + input: z.object({ id: z.string(), subject: subjectSchema }), + output: z.any(), + async run(cfg, ctx) { + return requireAuthoring(ctx.services).detachClaim(cfg.id, cfg.subject); + }, +}); diff --git a/src/steps/lib/meta/edit-check.ts b/src/steps/lib/meta/edit-check.ts new file mode 100644 index 0000000..691b6d5 --- /dev/null +++ b/src/steps/lib/meta/edit-check.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; +import { defineStep } from "../../../core.js"; +import { requireAuthoring } from "./_shared.js"; +import { checkSpecSchema } from "../../../claims-schemas.js"; + +export default defineStep({ + type: "meta/edit-check", + description: + "Change a check — pass only the fields to change. Checks are immutable: this creates a SUCCESSOR and returns its id; the old check's evidence stops counting (a changed instrument has measured nothing yet), so the claim reads `unknown` until it runs again. Publisher-scoped like the rest of meta/*: it acts only on claims and checks stamped 'ai' and on subjects this surface published, and a check may never reach a harness-only step (gaia/*, harvey/*, eval/*, meta/*) — by name, inside a subflow, or granted to an agent.", + input: z.object({ id: z.string().describe("Check id (from meta/list-claims)"), patch: checkSpecSchema }), + output: z.any(), + async run(cfg, ctx) { + return requireAuthoring(ctx.services).editCheck(cfg.id, cfg.patch); + }, +}); diff --git a/src/steps/lib/meta/edit-claim.ts b/src/steps/lib/meta/edit-claim.ts new file mode 100644 index 0000000..6bd45f5 --- /dev/null +++ b/src/steps/lib/meta/edit-claim.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { defineStep } from "../../../core.js"; +import { requireAuthoring } from "./_shared.js"; + +export default defineStep({ + type: "meta/edit-claim", + description: + "Reword a claim. Claims are immutable, so this creates a SUCCESSOR that supersedes it and returns the successor's id: attachments and checks carry over, old evidence stays on the old node, and the successor starts `unknown` until a run is verified again. Publisher-scoped like the rest of meta/*: it acts only on claims and checks stamped 'ai' and on subjects this surface published, and a check may never reach a harness-only step (gaia/*, harvey/*, eval/*, meta/*) — by name, inside a subflow, or granted to an agent.", + input: z.object({ id: z.string().describe("Claim id (from meta/list-claims)"), text: z.string() }), + output: z.any(), + async run(cfg, ctx) { + return requireAuthoring(ctx.services).editClaim(cfg.id, cfg.text); + }, +}); diff --git a/src/steps/lib/meta/edit-step.ts b/src/steps/lib/meta/edit-step.ts index a813dad..ce6bdf7 100644 --- a/src/steps/lib/meta/edit-step.ts +++ b/src/steps/lib/meta/edit-step.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { defineStep } from "../../../core.js"; import { requireAuthoring } from "./_shared.js"; +import { claimsArgSchema } from "../../../claims-schemas.js"; export default defineStep({ type: "meta/edit-step", @@ -10,9 +11,10 @@ export default defineStep({ type: z.string().describe("Existing custom step type to edit, e.g. 'candidates/my-fetcher'."), code: z.string().describe("Full updated TypeScript source (same self-contained shape as meta/create-step)."), description: z.string().optional().describe("replaces the one-line summary stored with the step"), + claims: claimsArgSchema, }), output: z.any(), async run(cfg, ctx) { - return requireAuthoring(ctx.services).editStep(cfg.type, cfg.code, cfg.description); + return requireAuthoring(ctx.services).editStep(cfg.type, cfg.code, cfg.description, cfg.claims); }, }); diff --git a/src/steps/lib/meta/list-claims.ts b/src/steps/lib/meta/list-claims.ts new file mode 100644 index 0000000..3b13ae9 --- /dev/null +++ b/src/steps/lib/meta/list-claims.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; +import { defineStep } from "../../../core.js"; +import { requireAuthoring } from "./_shared.js"; +import { subjectSchema } from "../../../claims-schemas.js"; + +export default defineStep({ + type: "meta/list-claims", + description: + "A subject's active claims, each with its checks and its status COMPUTED from evidence: supported | refuted | stale (evidence is about an older version) | unknown (never checked). `assertedOnly` = nothing observed, only a model's or person's word; `unverified` = active checks with no evidence about the active version; `openSlot` = an external check is waiting on someone. Reads ANY subject — a contract is meant to be seen by the agent that has to meet it.", + input: z.object({ subject: subjectSchema }), + output: z.any(), + async run(cfg, ctx) { + return requireAuthoring(ctx.services).listClaims(cfg.subject); + }, +}); diff --git a/src/steps/lib/meta/publish-workflow.ts b/src/steps/lib/meta/publish-workflow.ts index 77bfbcb..defc6f9 100644 --- a/src/steps/lib/meta/publish-workflow.ts +++ b/src/steps/lib/meta/publish-workflow.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { defineStep } from "../../../core.js"; import { requireAuthoring } from "./_shared.js"; +import { claimsArgSchema } from "../../../claims-schemas.js"; export default defineStep({ type: "meta/publish-workflow", @@ -14,6 +15,7 @@ export default defineStep({ .string() .optional() .describe("Optional sidebar grouping label (e.g. an experiment name). Omit to leave unchanged."), + claims: claimsArgSchema, }), output: z.any(), async run(cfg, ctx) { @@ -22,6 +24,7 @@ export default defineStep({ cfg.yaml, cfg.description, cfg.category, + cfg.claims, ); }, }); diff --git a/src/steps/lib/meta/retire-check.ts b/src/steps/lib/meta/retire-check.ts new file mode 100644 index 0000000..69d4e4a --- /dev/null +++ b/src/steps/lib/meta/retire-check.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { defineStep } from "../../../core.js"; +import { requireAuthoring } from "./_shared.js"; + +export default defineStep({ + type: "meta/retire-check", + description: + "Retire a check. Refused when it is the claim's LAST active check — add the replacement first (meta/add-check), or retire the claim. Publisher-scoped like the rest of meta/*: it acts only on claims and checks stamped 'ai' and on subjects this surface published, and a check may never reach a harness-only step (gaia/*, harvey/*, eval/*, meta/*) — by name, inside a subflow, or granted to an agent.", + input: z.object({ id: z.string() }), + output: z.any(), + async run(cfg, ctx) { + return requireAuthoring(ctx.services).retireCheck(cfg.id); + }, +}); diff --git a/src/steps/lib/meta/retire-claim.ts b/src/steps/lib/meta/retire-claim.ts new file mode 100644 index 0000000..97b87ca --- /dev/null +++ b/src/steps/lib/meta/retire-claim.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { defineStep } from "../../../core.js"; +import { requireAuthoring } from "./_shared.js"; + +export default defineStep({ + type: "meta/retire-claim", + description: + "Retire a claim that no longer holds as a requirement. Never deleted — its evidence and history stay — it just leaves the contract. Publisher-scoped like the rest of meta/*: it acts only on claims and checks stamped 'ai' and on subjects this surface published, and a check may never reach a harness-only step (gaia/*, harvey/*, eval/*, meta/*) — by name, inside a subflow, or granted to an agent.", + input: z.object({ id: z.string() }), + output: z.any(), + async run(cfg, ctx) { + return requireAuthoring(ctx.services).retireClaim(cfg.id); + }, +}); From 6a206487fd2328344ef3c443243bc83c6beb3783 Mon Sep 17 00:00:00 2001 From: Evanfeenstra Date: Thu, 17 Sep 2026 12:30:56 -0700 Subject: [PATCH 04/11] =?UTF-8?q?claims=20step=204:=20the=20verify=20pass?= =?UTF-8?q?=20=E2=80=94=20runs=20produce=20evidence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A post-run consumer in the projector's mould (plans/claims.md §4): it reads a finished run's event log, rebuilds every subject the run observed, and for each active claim runs each check whose policy fires — one Evidence per (check, run, path), or a planned slot for an external check. - subjectsOfRun: a step at a path (one per foreach iteration), a nested subflow as an execution of the CHILD workflow, the workflow itself, and { input, error } for a failure so "fails loudly" is checkable. Replayed steps and agent tool calls yield nothing - versions are never guessed: stepHashes / workflowHash from run.start, and the runner now records the child a subflow step resolves ON its step.start (workflow, version, hash) — it resolves at execution, not launch. No record -> no evidence (skipped: unknown-version), never "the active version" - the check contract (mapCheckResult): { supports, content, locator? } as the output, under `object` / `json`, or as JSON on stdout; a bare exec maps from its exit code (run with allowFailure: 0 supports, 126/127/killed cannot-run, else refutes), by shape so a workflow ending in an exec reads the same. A check that cannot run writes NOTHING — a broken check is never a pass - policy: always / on_change (subject version, the check's own resolved version, no evidence, or older than freshness_days) / sample (deterministic per check|run|path) / manual (verify_run only) - budget: presumed-paid checks skip at STRUT_VERIFY_BUDGET_USD (per run) or STRUT_VERIFY_BUDGET_USD_PER_DAY (per subject, computed from the store). A check that REPORTS cost is persisted under check: with run.start.verify and counted — which is how a presumed-free check that costs money is caught - guards: check runs carry origin "verify" and are never verified; the grader deny-list is re-applied to the check closure at run time; one verifier per deployment single-flights passes on a run id; Evidence.id is deterministic and written in create mode, so a second pass writes nothing and a re-verify runs only checks with no evidence for that (run, path) - planned slots for external checks: EVIDENCED_BY with no strength, at most one open per (check, subject) — a newer run mutes and replaces the question - add_evidence: asserted, check-less, about the version the run executed; fills the open slot in place when there is one. meta/add-evidence records `observed` ONLY for a DAG step of a workflow the meta surface did not publish; an agent tool call (StepContext.agentTool) is always `asserted` - run_when: publish checks fire on every publish path over { source | yaml }; their evidence is ABOUT the new version and sourced to that version node - triggers: services.onRunEnd(runId, info) for every top-level run (so a candidate launched by meta/run-workflow is verified too), and runStep once a single-step run reaches the real store. Always detached - evidence ordering: observed_at is whole seconds, so same-second verdicts order by the source run's ms id, then the write stamp — a regression run right after a pass must win - check configs are validated against the step's schema at write time - surface: strut.verifier, POST /workflows/:name/runs/:runId/verify, verify_run / add_evidence, meta/verify-run / meta/add-evidence --- AGENTS.md | 3 + package.json | 2 +- plans/claims.md | 32 +- src/ai/prompts.ts | 5 + src/ai/tools.ts | 63 ++- src/authoring.test.ts | 2 + src/authoring.ts | 56 +- src/claims-authoring.ts | 16 + src/claims-schemas.ts | 4 +- src/core.ts | 17 + src/createStrut.ts | 62 ++- src/graph/claims-authoring.test.ts | 26 +- src/graph/claims.test.ts | 17 +- src/graph/claims.ts | 46 +- src/graph/verify.test.ts | 443 ++++++++++++++++ src/index.ts | 20 + src/run-step.ts | 21 +- src/runner.ts | 37 +- src/steps/core/agent.ts | 4 +- src/steps/lib/meta/add-evidence.ts | 27 + src/steps/lib/meta/verify-run.ts | 17 + src/verify.test.ts | 229 ++++++++ src/verify.ts | 820 +++++++++++++++++++++++++++++ 23 files changed, 1927 insertions(+), 42 deletions(-) create mode 100644 src/graph/verify.test.ts create mode 100644 src/steps/lib/meta/add-evidence.ts create mode 100644 src/steps/lib/meta/verify-run.ts create mode 100644 src/verify.test.ts create mode 100644 src/verify.ts diff --git a/AGENTS.md b/AGENTS.md index 0b44b7f..f921493 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,7 @@ strut/ │ ├── store.ts # RunStore interface (writes + reads + tail) + FileRunStore + MemoryRunStore + tailJsonl / tailFromPolling. Keys are workflow names, plus two non-workflow buckets no workflow listing can see: `step:` → steps//runs/ (kept run_step runs), `check:` → checks//runs/ (paid check runs) │ ├── claims-authoring.ts # the policy layer behind BOTH claim doors (chat tools + meta/* twins): check-spec validation + write-time defaults (presumed-paid → on_change), the additive `claims` publish arg, publisher scoping (fixed point 1), the grader deny-list over the check closure (fixed point 2; STRUT_VERIFY_DENY) │ ├── claims-schemas.ts # zod shapes + model-facing docs for subjects / check specs / the `claims` arg, shared by ai/tools.ts and the meta/* claim steps +│ ├── verify.ts # the verify pass (plans/claims.md §4): subjectsOfRun (a run's event log → observed subjects + the version each executed), mapCheckResult (the check contract; a check that cannot run writes NOTHING), policyFires (always / on_change / sample / manual), budget (presumed-paid skipped at a cap; reported cost persisted under `check:` and counted), planned slots for external checks, addEvidence, verifyPublish. Triggered from `services.onRunEnd` for every top-level run and after a kept run_step; check runs (`origin: "verify"`) are never verified │ ├── closure.ts # what a flow can EXECUTE: walkSteps (loop/foreach bodies, onError), flowClosure (nested subflows via the workspace, agentTools grants; templated/missing child → unresolvable), stepHashesFor → run.start.stepHashes │ ├── run-step.ts # runSingleStep (one step, in memory, optional cassette) + runStep — the run_step surfaces: records stepHashes, then persists the run under `step:` only when the step has claims or `keep: true` (plans/claims.md §3) │ ├── chat-store.ts # ChatStore interface + FileChatStore + MemoryChatStore (chats//: meta.json + messages.jsonl + events.jsonl) + truncateToolMessages @@ -191,6 +192,8 @@ docker compose run --rm --no-deps --service-ports -e STRUT_WORKSPACE_BACKEND=fs | `STRUT_GRAPH_NAMESPACE` | `default` | jarvis namespace every Strut node is written into | | `STRUT_GRAPH_EMBEDDINGS` | (on) | `off` disables the local MiniLM embedder (vectors stay NULL; search is fulltext-only) | | `STRUT_GRAPH_SEED_ONTOLOGY` | (off) | `1` seeds the bundled jarvis ontology (153 schemas + edge schemas + indexes, add-only) on first open, so a standalone Neo4j can host jarvis-typed data (Document, EvalSet, Concept, …) with no jarvis process. No-op on a jarvis-seeded DB. Also turns on the one-shot `Claim` schema upgrade (the standalone mirror of jarvis migration 124) — never run against a jarvis-hosted graph. | +| `STRUT_VERIFY_BUDGET_USD` | `1` | Verify-pass spend cap PER VERIFIED RUN: once the pass's checks have reported this much, remaining checks presumed paid (an `agent`/`llm` step anywhere in the check closure, or an unresolvable one) are skipped (`lastVerify: { skipped: "budget" }`) and the claim stays `unknown` — never `supported`. Checks that report no cost never count. | +| `STRUT_VERIFY_BUDGET_USD_PER_DAY` | `5` | The same cap PER SUBJECT PER (UTC) DAY, computed from the run store alone: the cost of today's runs under `check:` tagged with that subject. A harness that verifies many candidates raises it, or sets its paid checks to `manual`. | | `STRUT_VERIFY_DENY` | (none) | Comma-separated step-type globs added to the grader deny-list (`gaia/*`, `harvey/*`, `eval/*`, `meta/*`): an `ai`-stamped check may not reach any of them — by name, through a subflow, or via an `agentTools` grant (plans/claims.md §4.1, fixed point 2). | | `STRUT_MODEL_DIR` | `~/.cache/strut-models` | Local model files: MiniLM's ONNX cache and STT models under `stt//`. `STRUT_MODEL_CACHE` is the older alias. | | `STRUT_STT_MODEL` | `zipformer-en-kroko` | Finals recognizer for `/audio/stream` + `/audio/transcribe` (hotword-capable) | diff --git a/package.json b/package.json index 5ef1d8c..a05eb4c 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "package:desktop": "node scripts/package-desktop.mjs", "dev": "npm run build:web && tsx --env-file=.env src/server.ts", "start": "node build/server.js", - "test": "tsx --test src/expr.test.ts src/core.test.ts src/runner.test.ts src/run-control.test.ts src/control-flow.test.ts src/store.test.ts src/workspace.test.ts src/storage-conformance.test.ts src/integration.test.ts src/services.test.ts src/cassette.test.ts src/run-step.test.ts src/closure.test.ts src/createStrut.test.ts src/authoring.test.ts src/ai-integration.test.ts src/chat-store.test.ts src/chat-endpoints.test.ts src/llm.test.ts src/pricing.test.ts src/steps/registry.test.ts src/steps/core/agent.test.ts src/steps/core/pack.test.ts src/steps/core/exec.test.ts src/steps/core/llm.test.ts src/auth.test.ts src/secret-store.test.ts src/artifacts.test.ts src/slack.test.ts src/gdrive.test.ts src/html-extract.test.ts src/shell.test.ts src/validate.test.ts src/model-dir.test.ts src/audio/hotwords.test.ts src/audio/stt.test.ts src/audio/ws.test.ts web/src/run-inputs.test.ts", + "test": "tsx --test src/expr.test.ts src/core.test.ts src/runner.test.ts src/run-control.test.ts src/control-flow.test.ts src/store.test.ts src/workspace.test.ts src/storage-conformance.test.ts src/integration.test.ts src/services.test.ts src/cassette.test.ts src/run-step.test.ts src/closure.test.ts src/verify.test.ts src/createStrut.test.ts src/authoring.test.ts src/ai-integration.test.ts src/chat-store.test.ts src/chat-endpoints.test.ts src/llm.test.ts src/pricing.test.ts src/steps/registry.test.ts src/steps/core/agent.test.ts src/steps/core/pack.test.ts src/steps/core/exec.test.ts src/steps/core/llm.test.ts src/auth.test.ts src/secret-store.test.ts src/artifacts.test.ts src/slack.test.ts src/gdrive.test.ts src/html-extract.test.ts src/shell.test.ts src/validate.test.ts src/model-dir.test.ts src/audio/hotwords.test.ts src/audio/stt.test.ts src/audio/ws.test.ts web/src/run-inputs.test.ts", "test:stt": "STRUT_TEST_STT=1 tsx --test src/audio/stt.live.test.ts", "test:graph": "tsx --test --test-concurrency=1 \"src/graph/*.test.ts\" \"src/steps/lib/graph/*.test.ts\"" }, diff --git a/plans/claims.md b/plans/claims.md index f9f5a54..c60b128 100644 --- a/plans/claims.md +++ b/plans/claims.md @@ -881,7 +881,37 @@ number exists). `meta/retire-claim`, `meta/list-claims`, `meta/attach-claim`, `meta/detach-claim`, `meta/add-check`, `meta/edit-check`, `meta/retire-check`, with publisher scoping. -4. `verify.ts` + check contract + check closure + triggers (incl. the +4. **Done** — `src/verify.ts`, `strut.verifier`, `POST + /workflows/:name/runs/:runId/verify`, `verify_run` / `add_evidence` chat + tools, `meta/verify-run` / `meta/add-evidence`, publish checks on every + publish path, the `services.onRunEnd(runId, info)` trigger + the + `run_step` trigger. Decided while building: + - **A nested child's version is recorded when it runs**, on the subflow + step's `step.start` (`RunEvent.subflow: { workflow, version?, hash }`): + the child resolves at execution, not at launch, and nothing else in + the log names it. A subflow with no record yields no subject. + - **`observed_at` is when the behaviour happened** (the `step.end` / + `run.end` timestamp), not when the check ran — otherwise backfilling + an old run would make old evidence the newest. It is whole seconds + (every jarvis datetime is), so same-second evidence orders by the + source run's ms id, then the node's write stamp. + - **`exec` checks run with `allowFailure`** so a failed assertion is an + exit code, not a crash: 0 supports, 126 / 127 / a kill cannot-run, + anything else refutes. The mapping is by SHAPE, so a `subflow` check + whose child ends in an `exec` reads the same way. + - **A check's config is validated at write time** with the workflow + validator (a check IS a one-step flow whose only root is `input`). + - **`meta/add-evidence` is `observed` only for a DAG step of a workflow + the meta surface did not publish.** An agent tool call — even inside + a seeded harness — is `asserted`: `StepContext.agentTool` tells a + harness's deliberate step from a model's decision. + - `sample` is deterministic in (check, run, path), so re-verifying + samples the same way; `verify_run` fires `manual` checks and nothing + else extra; a `denied` skip reason joins the four in §5. + - **Gap:** the `llm` step reports no `cost` (it returns the bare object), + so an `llm` check is gated by the caps but its own spend is not + counted. `agent` steps are. Default per-day cap: $5. + `verify.ts` + check contract + check closure + triggers (incl. the verify-origin guard) + `add_evidence` + `meta/verify-run` + `meta/add-evidence` (§4, §6); planned slots — open in the pass for external checks, fill through `add_evidence` (§4.2). diff --git a/src/ai/prompts.ts b/src/ai/prompts.ts index e480224..f9918ed 100644 --- a/src/ai/prompts.ts +++ b/src/ai/prompts.ts @@ -39,6 +39,11 @@ export interface AiDeps { * tool so the builder can verify what its `graph/*` steps wrote. Optional: * without it the tool isn't offered. */ graph?: GraphBackend; + /** The verify pass (plans/claims.md §4), when the workspace is + * graph-backed: backs `verify_run` / `add_evidence`, runs `publish` checks + * after a publish, and verifies kept `run_step` runs. Optional: without it + * claims can still be authored, but nothing produces evidence. */ + verifier?: import("../verify.js").Verifier | null; /** Web tools for the builder — `web_search` + `web_fetch` (the same pair * the agent step ships): built per turn by createStrut for the chat's * resolved provider via `createWebTools` (src/llm.ts; native on diff --git a/src/ai/tools.ts b/src/ai/tools.ts index 89217fa..bb6c27b 100644 --- a/src/ai/tools.ts +++ b/src/ai/tools.ts @@ -85,6 +85,14 @@ export function buildTools(deps: AiDeps) { : null; const actor: ClaimActor = { publisher: AI_PUBLISHER, scoped: false }; const claimsArg = claims ? { claims: claimsArgSchema } : {}; + const verifier = claims ? (deps.verifier ?? null) : null; + /** `run_when: publish` checks fire at the end of a publish; their verdicts + * ride along on the result. */ + const publishChecks = async (kind: "step" | "workflow", name: string) => { + if (!verifier) return {}; + const r = await verifier.verifyPublish(kind === "step" ? { kind, type: name } : { kind, name }).catch(() => null); + return r && r.checks.length ? { publishChecks: r.checks.map((k) => ({ claim: k.claimId, check: k.checkId, lastVerify: k.lastVerify })) } : {}; + }; type ClaimsArg = z.infer; /** Non-blocking: warnings ride along on a successful publish. */ @@ -180,7 +188,7 @@ export function buildTools(deps: AiDeps) { if (invalid) return { error: `Nothing was published — fix the claims first. ${invalid.error}` }; const result = await publishNewStep(deps, name, code, description, AI_PUBLISHER); deps.registry = await deps.getRegistry(); - const ledger = result.ok && claims ? { claims: await claims.applyClaimsArg({ kind: "step", name }, contract, actor) } : {}; + const ledger = result.ok && claims ? { claims: await claims.applyClaimsArg({ kind: "step", name }, contract, actor), ...(await publishChecks("step", name)) } : {}; if (result.ok && result.loaded === false) { const { loadError, ...ok } = result; return { ...ok, ...ledger, warning: `Published but failed to load into the registry: ${loadError}` }; @@ -206,7 +214,7 @@ export function buildTools(deps: AiDeps) { if (invalid) return { error: `Nothing was published — fix the claims first. ${invalid.error}` }; const result = await publishStepVersion(deps, type, code, description); deps.registry = await deps.getRegistry(); - const ledger = result.ok && claims ? { claims: await claims.applyClaimsArg({ kind: "step", name: type }, contract, actor) } : {}; + const ledger = result.ok && claims ? { claims: await claims.applyClaimsArg({ kind: "step", name: type }, contract, actor), ...(await publishChecks("step", type)) } : {}; if (result.ok && result.loaded === false) { const { loadError, ...ok } = result; return { ...ok, ...ledger, warning: `Published but failed to load into the registry: ${loadError}` }; @@ -274,7 +282,7 @@ export function buildTools(deps: AiDeps) { version, renamed: finalName !== name, requested: name, - ...(claims ? { claims: await claims.applyClaimsArg({ kind: "workflow", name: finalName }, contract, actor) } : {}), + ...(claims ? { claims: await claims.applyClaimsArg({ kind: "workflow", name: finalName }, contract, actor), ...(await publishChecks("workflow", finalName)) } : {}), }, v, ); @@ -338,7 +346,7 @@ export function buildTools(deps: AiDeps) { name, version: result.version, changed: result.changed, - ...(claims ? { claims: await claims.applyClaimsArg({ kind: "workflow", name }, contract, actor) } : {}), + ...(claims ? { claims: await claims.applyClaimsArg({ kind: "workflow", name }, contract, actor), ...(await publishChecks("workflow", name)) } : {}), }, v, ); @@ -410,6 +418,46 @@ export function buildTools(deps: AiDeps) { inputSchema: z.object({ id: z.string() }), execute: async ({ id }) => claims.retireCheck(id, actor), }), + + ...(verifier + ? { + verify_run: tool({ + description: + "Verify a finished run NOW and wait for it: run the checks of every claim on the subjects the run executed, and write the evidence. Runs are verified automatically after they finish, so use this to RE-verify — after adding or editing a claim or check (only checks with no evidence for this run yet execute; it never duplicates), to backfill an older run, or to fire `manual` checks. `name` is the workflow, or `step:` for a kept run_step run. Returns per-check lastVerify: { ran } | { skipped: policy | budget | cannot-launch | unknown-version | denied, reason? } | { planned: } — then list_claims for the statuses.", + inputSchema: z.object({ + name: z.string().describe("Workflow name, or `step:` for a kept single-step run"), + runId: z.string(), + }), + execute: async ({ name, runId }) => verifier.verifyRun(name, runId, { explicit: true }), + }), + + add_evidence: tool({ + description: + "Record something YOU observed about a claim on a specific run — only what you actually saw with a tool (ffprobe output, a transcript you read, a graph_query result); `content` must say what and how. It is stored as ASSERTED (a model's word, flagged `assertedOnly` until a check observes the same thing), sourced to that run and the version it executed. If an external check is waiting on this run (an open slot), this answers it. Never use it to mark work as passing without looking.", + inputSchema: z.object({ + claim: z.string().describe("Claim id (from list_claims)"), + name: z.string().describe("The run's workflow name, or `step:` for a kept single-step run"), + runId: z.string(), + supports: z.boolean().describe("true: what you saw supports the claim; false: it refutes it"), + content: z.string().describe("What you observed, and with which tool — one bounded statement"), + subject: subjectSchema.optional().describe("Only when the run executed several of the claim's subjects"), + slot: z.string().optional().describe("Evidence id of the open slot to fill (from lastVerify.planned); found automatically for this run when omitted"), + }), + execute: async ({ claim, name, runId, supports, content, subject, slot }) => + verifier.addEvidence({ + claim, + name, + runId, + supports, + content, + ...(subject ? { subject: subject.kind === "step" ? { kind: "step" as const, type: subject.name } : { kind: "workflow" as const, name: subject.name } } : {}), + ...(slot ? { slot } : {}), + by: AI_PUBLISHER, + mode: "asserted", + }), + }), + } + : {}), } : {}), @@ -648,7 +696,12 @@ export function buildTools(deps: AiDeps) { ? { cassette: { mode: cassette, path: cassettePath(deps.dataDir!, cassetteName ?? type) } } : {}), }, - { store: deps.store, workspace: deps.workspace, claims: claimsReaderFor(deps.workspace) }, + { + store: deps.store, + workspace: deps.workspace, + claims: claimsReaderFor(deps.workspace), + onKept: (key, runId) => verifier?.schedule(key, runId), + }, ); }, }), diff --git a/src/authoring.test.ts b/src/authoring.test.ts index dfa19a9..dd3f553 100644 --- a/src/authoring.test.ts +++ b/src/authoring.test.ts @@ -82,6 +82,8 @@ describe("authoring capability (the meta surface)", () => { "meta/add-check", "meta/edit-check", "meta/retire-check", + "meta/verify-run", + "meta/add-evidence", ]) { assert.ok(registry[type], `expected "${type}" in the registry`); } diff --git a/src/authoring.ts b/src/authoring.ts index 2f3556e..e062e4a 100644 --- a/src/authoring.ts +++ b/src/authoring.ts @@ -9,6 +9,7 @@ import { stepHashesFor } from "./closure.js"; import { claimsReaderFor } from "./graph/claims.js"; import { buildClaimsAuthoring, + toSubjectRef, type CheckSpecInput, type ClaimActor, type ClaimSpecInput, @@ -410,6 +411,21 @@ export interface AuthoringCapability { addCheck(claimId: string, check: CheckSpecInput): Promise; editCheck(id: string, patch: CheckSpecInput): Promise; retireCheck(id: string): Promise; + /** Verify a finished run NOW and return per-check outcomes. Synchronous — + * a harness calls it before digesting, because the detached pass races it + * (single-flighted: whichever starts second awaits the first). */ + verifyRun(name: string, runId: string): Promise; + /** + * Record an observation about a claim on a run. `caller` decides how much + * it is worth (fixed point 3): ONLY a step of a workflow this surface did + * not publish — a seeded harness writing its graders' verdicts — records + * `observed`. An `ai`-published workflow, or ANY agent tool call (even + * inside a seeded harness), records `asserted`, whatever it says. + */ + addEvidence( + input: { claim: string; name: string; runId: string; supports: boolean; content: string; subject?: SubjectInput; slot?: string }, + caller?: { workflow?: string; runId?: string; agentTool?: boolean }, + ): Promise; } export interface AuthoringDeps extends StepPublishDeps { @@ -433,6 +449,8 @@ export interface AuthoringDeps extends StepPublishDeps { runId: string, parentRunId?: string, ) => { controller?: import("./run-control.js").RunController; untrack: () => void }; + /** The verify pass, where the claims layer exists (graph workspaces). */ + verifier?: import("./verify.js").Verifier | null; } export function buildAuthoringCapability(deps: AuthoringDeps): AuthoringCapability { @@ -447,6 +465,12 @@ export function buildAuthoringCapability(deps: AuthoringDeps): AuthoringCapabili /** Blocks a publish on an invalid contract; a contract passed where there * is no claims layer is an error too — silently dropping it would read as * "contract recorded". */ + /** `run_when: publish` checks fire at the end of a publish. */ + const publishChecks = async (kind: "step" | "workflow", name: string) => { + if (!claims || !deps.verifier) return {}; + const r = await deps.verifier.verifyPublish(kind === "step" ? { kind, type: name } : { kind, name }).catch(() => null); + return r && r.checks.length ? { publishChecks: r.checks.map((k) => ({ claim: k.claimId, check: k.checkId, lastVerify: k.lastVerify })) } : {}; + }; const claimsGate = async (arg: ClaimSpecInput[] | undefined): Promise => { if (!arg || arg.length === 0) return null; if (!claims) return CLAIMS_OFF; @@ -506,7 +530,7 @@ export function buildAuthoringCapability(deps: AuthoringDeps): AuthoringCapabili const invalid = await claimsGate(contract); if (invalid) return { error: `Nothing was published — fix the claims first. ${invalid}` }; const published = await publishNewStep(deps, name, code, description, AI_PUBLISHER); - const result = published.ok && claims ? { ...published, claims: await claims.applyClaimsArg({ kind: "step", name }, contract, actor) } : published; + const result = published.ok && claims ? { ...published, claims: await claims.applyClaimsArg({ kind: "step", name }, contract, actor), ...(await publishChecks("step", name)) } : published; // For the in-workflow author a broken publish is a FAILURE, not a // warning — §5.3.4: hand the import error back loudly. if (result.ok && result.loaded === false) { @@ -525,7 +549,7 @@ export function buildAuthoringCapability(deps: AuthoringDeps): AuthoringCapabili const published = await publishStepVersion(deps, type, code, description, { requirePublisher: AI_PUBLISHER, }); - const result = published.ok && claims ? { ...published, claims: await claims.applyClaimsArg({ kind: "step", name: type }, contract, actor) } : published; + const result = published.ok && claims ? { ...published, claims: await claims.applyClaimsArg({ kind: "step", name: type }, contract, actor), ...(await publishChecks("step", type)) } : published; if (result.ok && result.loaded === false) { return { ...result, @@ -564,7 +588,7 @@ export function buildAuthoringCapability(deps: AuthoringDeps): AuthoringCapabili } : {}), }, - { store, workspace, claims: claimsReaderFor(workspace) }, + { store, workspace, claims: claimsReaderFor(workspace), onKept: (key, runId) => deps.verifier?.schedule(key, runId) }, ); }, @@ -630,7 +654,7 @@ export function buildAuthoringCapability(deps: AuthoringDeps): AuthoringCapabili version: result.version, changed: result.changed, created: !entry, - ...(claims ? { claims: await claims.applyClaimsArg({ kind: "workflow", name }, contract, actor) } : {}), + ...(claims ? { claims: await claims.applyClaimsArg({ kind: "workflow", name }, contract, actor), ...(await publishChecks("workflow", name)) } : {}), }; } catch (err) { return { error: err instanceof Error ? err.message : String(err) }; @@ -709,5 +733,29 @@ export function buildAuthoringCapability(deps: AuthoringDeps): AuthoringCapabili addCheck: async (claimId, check) => (claims ? claims.addCheck(claimId, check, actor) : claimsOff), editCheck: async (id, patch) => (claims ? claims.editCheck(id, patch, actor) : claimsOff), retireCheck: async (id) => (claims ? claims.retireCheck(id, actor) : claimsOff), + + async verifyRun(name, runId) { + if (!claims || !deps.verifier) return claimsOff; + const gate = await notOwned(name, "verifies runs of"); + if (gate) return { error: gate }; + return deps.verifier.verifyRun(name, runId, { explicit: true }); + }, + + async addEvidence(input, caller) { + if (!claims || !deps.verifier) return claimsOff; + const root = caller?.workflow ? await findWorkflow(caller.workflow) : undefined; + const harness = !caller?.agentTool && !!root && root.publisher !== AI_PUBLISHER; + return deps.verifier.addEvidence({ + claim: input.claim, + name: input.name, + runId: input.runId, + supports: input.supports, + content: input.content, + ...(input.subject ? { subject: toSubjectRef(input.subject) } : {}), + ...(input.slot ? { slot: input.slot } : {}), + by: harness ? caller!.workflow! : `${AI_PUBLISHER}${caller?.runId ? `:${caller.runId}` : ""}`, + mode: harness ? "observed" : "asserted", + }); + }, }; } diff --git a/src/claims-authoring.ts b/src/claims-authoring.ts index 219ea68..4cef198 100644 --- a/src/claims-authoring.ts +++ b/src/claims-authoring.ts @@ -28,7 +28,9 @@ * Every method returns a plain result (`{ ok: true, … }` or `{ error }`) — * the shape the tool layer hands to a model. */ +import yaml from "js-yaml"; import { closureIncludes, flowClosure, globToRegExp, type FlowClosure } from "./closure.js"; +import { validateWorkflowYaml } from "./validate.js"; import type { StepRegistry } from "./core.js"; import type { GraphBackend } from "./graph/backend.js"; import { ClaimsReader, isExternalCheck, type CheckPolicy, type CheckRow, type ClaimStatus, type RunWhen, type SubjectRef } from "./graph/claims.js"; @@ -206,6 +208,20 @@ export function buildClaimsAuthoring(deps: ClaimsAuthoringDeps) { throw new ClaimsError("REFUSED", `${where}: a check may not reach a harness-only step (${grader}) — a contract the producer can see must never embed its grader`); } } + // The same static check a workflow gets — a check IS a one-step flow whose + // input is the subject, so `{{ input.* }}` is the only root it can read. + // Catching a mistyped config here beats a check that silently never runs. + const workflows = await deps.workspace.listWorkflows().catch(() => []); + const v = validateWorkflowYaml(yaml.dump({ name: "check", steps: [{ id: "check", type: spec.type, config }] }), { + registry, + workflows: workflows.map((w) => ({ name: w.name, versions: w.versions })), + name: "check", + }); + if (!v.ok) { + const list = v.errors.map((e) => `${e.path.replace(/^steps\[0\]\.?/, "") || "check"}: ${e.message}`).join("; "); + throw new ClaimsError("INVALID", `${where}: the check's config is not valid for step "${spec.type}" — ${list}. (get_step("${spec.type}") shows its config.)`); + } + const presumedPaid = !closure.resolvable || PAID_STEP_TYPES.some((t) => closureIncludes(closure, t)); return { ...common, diff --git a/src/claims-schemas.ts b/src/claims-schemas.ts index a689593..36bc7c1 100644 --- a/src/claims-schemas.ts +++ b/src/claims-schemas.ts @@ -18,13 +18,13 @@ export const checkSpecSchema = z .string() .optional() .describe( - "Registry step that RUNS the check: `exec` (a script — free, observed; prefer it), a custom step, `llm` / `agent` (a judgment — costs money, recorded as asserted), or `subflow` (a whole workflow, for anything bigger than a one-liner: config = { workflow, version?, input }). OMIT for an EXTERNAL check — one code cannot run — and give `description` instead.", + "Registry step that RUNS the check: `exec` (a script — free, observed; prefer it. Config is { cmd, args?, script?, cwd? } — see get_step(\"exec\"); set cwd: \"{{ input.artifactsDir }}\" to work on the files the run produced), a custom step, `llm` / `agent` (a judgment — costs money, recorded as asserted), or `subflow` (a whole workflow, for anything bigger than a one-liner: config = { workflow, version?, input }). OMIT for an EXTERNAL check — one code cannot run — and give `description` instead.", ), config: z .record(z.string(), z.any()) .optional() .describe( - "That step's config. The observed subject IS the check's input: `{{ input.output.* }}` is the step's output, `{{ input.input.* }}` its resolved config (for a workflow: its params + run input), `{{ input.error.message }}` when it failed, plus `{{ input.runId }}`, `{{ input.path }}`, `{{ input.artifactsDir }}`. A `publish` check reads `{{ input.source }}` (step) / `{{ input.yaml }}` (workflow). The check must RETURN { supports: boolean, content: string, locator?: { path?, start_time?, end_time?, url? } } — never throw on a failed assertion, return supports:false. A bare `exec` with no JSON on stdout maps from its exit code (0 = supports) with the output tail as content.", + "That step's config. The observed subject IS the check's input: `{{ input.output.* }}` is the step's output, `{{ input.input.* }}` its resolved config (for a workflow: the run's input, with `{{ input.params.* }}` beside it), `{{ input.error.message }}` when it failed, plus `{{ input.runId }}`, `{{ input.path }}`, `{{ input.artifactsDir }}`. A `publish` check reads `{{ input.source }}` (step) / `{{ input.yaml }}` (workflow). The check must RETURN { supports: boolean, content: string, locator?: { path?, start_time?, end_time?, url? } } — never throw on a failed assertion, return supports:false. A bare `exec` with no JSON on stdout maps from its exit code (0 = supports) with the output tail as content.", ), name: z.string().optional().describe("Short label, e.g. 'stt fuzzy-match'. Defaults to the step type."), description: z diff --git a/src/core.ts b/src/core.ts index 48de7cf..4f5c84f 100644 --- a/src/core.ts +++ b/src/core.ts @@ -66,6 +66,12 @@ export interface StepContext { * skip completed iterations (RUN_CONTROL_SPEC §5/§6). Absent on a fresh * run or when there is nothing journaled under this step. */ journal?: Record; + /** True when this step is executing as an AGENT'S TOOL CALL (granted via + * `agentTools`) rather than as a step of the workflow's DAG. A step that + * acts on the caller's authority reads it: what a harness workflow does + * deliberately and what a model inside it decided to do are not the same + * actor (plans/claims.md §4.1, fixed point 3). */ + agentTool?: boolean; } /** Error handling options for a step. */ @@ -184,6 +190,17 @@ export interface RunEvent { /** `"verify"` on a run the verify pass launched (a check). Such runs are * never themselves verified — the recursion guard. */ origin?: "verify"; + /** On a CHECK run's `run.start`: which check ran, over what. Lets the + * verify budget be computed from the run store alone (plans/claims.md + * §4.1): a subject's spend today is the cost of today's runs in its + * checks' buckets whose `verify.subject` is that subject. */ + verify?: { checkId: string; subject: string; sourceRunId: string }; + /** On a `subflow` step's `step.start`: the child workflow it is about to + * execute, as resolved at THAT moment — name, pinned version if any, and + * the content hash of the version that will run. A nested execution is an + * execution of the child workflow, and this is the only record of which + * version it was (the child resolves when the step runs, not at launch). */ + subflow?: { workflow: string; version?: string; hash?: string }; /** Per-run param overrides, recorded on `run.start` so a durable resume * re-executes steps with the SAME knob values the original run used. */ params?: Record; diff --git a/src/createStrut.ts b/src/createStrut.ts index 4615bb2..eafe573 100644 --- a/src/createStrut.ts +++ b/src/createStrut.ts @@ -34,7 +34,10 @@ import { MemorySecretStore, isValidSecretName, } from "./secret-store.js"; -import { runStep, cassettePath } from "./run-step.js"; +import { runStep, cassettePath, RUN_STEP_FLOW } from "./run-step.js"; +import { createVerifier, type Verifier, type VerifyResult } from "./verify.js"; +import { CLAIMS_OFF } from "./claims-schemas.js"; +import type { RunEndInfo } from "./runner.js"; import { stepHashesFor } from "./closure.js"; import { buildAuthoringCapability } from "./authoring.js"; import type { CassetteMode } from "./cassette.js"; @@ -235,6 +238,12 @@ export interface Strut { * pass is a no-op. Every consumer gates on this. */ claims: ClaimsReader | null; + /** The verify pass (plans/claims.md §4) — null unless the workspace is + * graph-backed. Runs by itself after every top-level run; a host calls + * `verifyRun` to wait for a run's evidence (single-flighted with the + * automatic pass), or `addEvidence` to report what it observed. */ + verifier: Verifier | null; + /** Boot the Hono server with `@hono/node-server`. Resolves once the * socket is listening, to the *bound* port — so `listen(0)` (or * `STRUT_PORT=0`) lets the OS pick one, which a desktop host that spawns @@ -451,6 +460,40 @@ export async function createStrut( await rebuildRegistry(); } + // The verify pass (plans/claims.md §4) — only where the claims layer exists. + // Triggered where `services.onRunEnd` fires — `runWorkflow`'s `finally`, + // once per TOP-LEVEL run — and NOT at the launch sites: a candidate that a + // harness launches through `meta/run-workflow` is its own top-level run and + // must be verified too. Always detached; a consumer's own onRunEnd + // (per-run teardown) still runs first. + let verifySettled: ((r: VerifyResult) => void) | undefined; + const verifier = workspace.graph + ? createVerifier({ + graph: workspace.graph, + store, + workspace, + services: () => services, + getRegistry: async () => { + await rebuildRegistry(); + return registry; + }, + onSettled: (r) => verifySettled?.(r), + }) + : null; + if (verifier) { + const bag = services as Record; + const prior = (bag["onRunEnd"] as ((id: string, info?: RunEndInfo) => unknown) | undefined)?.bind(bag); + bag["onRunEnd"] = async (runId: string, info?: RunEndInfo) => { + try { + await prior?.(runId, info); + } finally { + // Check runs are never verified (the recursion guard); a single-step + // run is verified by `runStep`, once it reaches the real store. + if (info?.workflow && info.origin !== "verify" && info.workflow !== RUN_STEP_FLOW) verifier.schedule(info.workflow, runId); + } + }; + } + // Auto-provide the AUTHORING capability (the workspace's author/test/inspect // operations as one service) unless the consumer injected their own — same // spirit as http/secrets/artifacts above, added here because it closes over @@ -466,6 +509,7 @@ export async function createStrut( store, services, trackRun, + verifier, publishingEnabled: !registryWasInjected, getRegistry: async () => { await rebuildRegistry(); @@ -1365,7 +1409,7 @@ export async function createStrut( ? { cassette: { mode, path: cassettePath(dataDir, body.cassetteName ?? type) } } : {}), }, - { store, workspace, claims }, + { store, workspace, claims, onKept: (key, runId) => verifier?.schedule(key, runId) }, ); return c.json(result); }); @@ -1443,6 +1487,17 @@ export async function createStrut( return runId; } + // Re-verify a finished run (plans/claims.md §4): after claims or checks + // change, to backfill, or to fire `manual` checks. Synchronous, idempotent + // per (check, run, path), and single-flighted with the detached pass. + app.post("/workflows/:name/runs/:runId/verify", async (c) => { + if (!verifier) return c.json({ error: CLAIMS_OFF }, 409); + const { name, runId } = c.req.param(); + const result = await verifier.verifyRun(name, runId, { explicit: true }); + if (result.skipped === "unknown-run") return c.json({ error: `Run ${runId} of "${name}" not found` }, 404); + return c.json(result); + }); + app.post("/workflows/:name/run", async (c) => { const name = c.req.param("name"); const body = await c.req.json(); @@ -1551,6 +1606,8 @@ export async function createStrut( }, // Read-only graph_query, when the host wired a graph backend. graph: opts.graph, + // verify_run / add_evidence + the verify triggers (graph workspaces only). + verifier, // cancel_run / pause_run / resume_run over the live controllers. controlRun: controlRunForChat, publishingEnabled: !registryWasInjected, @@ -2030,6 +2087,7 @@ export async function createStrut( run, stt, claims, + verifier, listen, close, }; diff --git a/src/graph/claims-authoring.test.ts b/src/graph/claims-authoring.test.ts index d2ba3b6..84afe1b 100644 --- a/src/graph/claims-authoring.test.ts +++ b/src/graph/claims-authoring.test.ts @@ -89,7 +89,7 @@ describe("filesystem workspace: no claims layer (pure)", () => { const STEP_SRC = (type: string, tag = "one") => `import { z, defineStep } from "strut";\nexport default defineStep({ type: "${type}", description: "${tag}", input: z.any(), output: z.any(), run: async () => ({ tag: "${tag}" }) });\n`; -const EXEC = { type: "exec", config: { command: "true" } }; +const EXEC = { type: "exec", config: { cmd: "true" } }; describe("claims authoring (live Neo4j)", { skip: cfg ? false : "STRUT_TEST_NEO4J_URI not set" }, () => { let backend: GraphBackend; @@ -161,7 +161,7 @@ describe("claims authoring (live Neo4j)", { skip: cfg ? false : "STRUT_TEST_NEO4 const contract = [ { text: "computes start/end inside the video's duration", checks: [EXEC, { description: "scrub to the cut and look — code cannot see framing" }] }, - { text: "fails loudly on a private video", checks: [{ type: "exec", config: { command: "test -n '{{ input.error.message }}'" }, name: "error surfaced" }] }, + { text: "fails loudly on a private video", checks: [{ type: "exec", config: { cmd: "test", args: ["-n", "{{ input.error.message }}"] }, name: "error surfaced" }] }, ]; const first = await claims.applyClaimsArg(STEP, contract, chat); assert.deepEqual([first.count, first.added, first.existing, first.warning], [2, 2, 0, undefined]); @@ -177,7 +177,7 @@ describe("claims authoring (live Neo4j)", { skip: cfg ? false : "STRUT_TEST_NEO4 ["fetches ", "unknown", "ai", 1], ]); const [code, external] = rows[0]!.checks; - assert.deepEqual([code!.type, code!.config, code!.name, code!.when, code!.policy, code!.external, code!.publisher], ["exec", { command: "true" }, "exec", "run", "always", false, "ai"]); + assert.deepEqual([code!.type, code!.config, code!.name, code!.when, code!.policy, code!.external, code!.publisher], ["exec", { cmd: "true" }, "exec", "run", "always", false, "ai"]); assert.deepEqual([external!.external, external!.type, external!.policy, external!.when], [true, undefined, "on_change", "run"]); // The graph shape: Claim —ABOUT→ the STABLE step, Check —TESTS→ Claim. const shape = await backend.bolt.run( @@ -190,7 +190,7 @@ describe("claims authoring (live Neo4j)", { skip: cfg ? false : "STRUT_TEST_NEO4 it("defaults: free code checks fire always; anything presumed paid — llm, agent, a subflow hiding one — fires on_change", async () => { await ws.publishWorkflowByContent("judge", "name: judge\nsteps:\n - id: j\n type: llm\n config: { prompt: ok }\n"); - await ws.publishWorkflowByContent("matcher", "name: matcher\nsteps:\n - id: m\n type: exec\n config: { command: 'true' }\n"); + await ws.publishWorkflowByContent("matcher", "name: matcher\nsteps:\n - id: m\n type: exec\n config: { cmd: 'true' }\n"); const id = idOf( await claims.addClaim( { @@ -201,7 +201,7 @@ describe("claims authoring (live Neo4j)", { skip: cfg ? false : "STRUT_TEST_NEO4 { type: "llm", config: { prompt: "is it bare? {{ input.output.answer }}" } }, { type: "subflow", config: { workflow: "judge", input: {} } }, { type: "subflow", config: { workflow: "matcher", input: {} }, name: "fuzzy" }, - { type: "exec", config: { command: "true" }, policy: "manual", when: "publish", freshnessDays: 3 }, + { type: "exec", config: { cmd: "true" }, policy: "manual", when: "publish", freshnessDays: 3 }, ], }, human, @@ -222,8 +222,12 @@ describe("claims authoring (live Neo4j)", { skip: cfg ? false : "STRUT_TEST_NEO4 await bad([{ type: "nope/missing" }], /not found/); await bad([{ description: "look", config: { a: 1 } }], /without a `type`/); await bad([{ description: "look", when: "publish" }], /external check cannot run at publish/); - await bad([{ type: "exec", policy: "sample" }], /needs a sampleRate/); - await bad([{ type: "exec", policy: "hourly" }], /policy must be one of/); + await bad([{ type: "exec", config: { cmd: "true" }, policy: "sample" }], /needs a sampleRate/); + await bad([{ type: "exec", config: { cmd: "true" }, policy: "hourly" }], /policy must be one of/); + // The check's config gets the same static check a workflow step does. + await bad([{ type: "exec", config: { command: "true" } }], /not valid for step "exec".*cmd/s); + await bad([{ type: "exec", config: { cmd: "test", args: ["{{ output.x }}"] } }], /not valid for step "exec"/); + await bad([{ type: "subflow", config: { workflow: "no-such-workflow", input: {} } }], /closure cannot be resolved.*missing/s); assert.match(errOf(await claims.addClaim({ subjects: [STEP], text: " ", checks: [EXEC] }, chat)), /text is empty/); assert.match(errOf(await claims.addClaim({ subjects: [{ kind: "step", name: "exec" }], text: "t", checks: [EXEC] }, chat)), /built-in steps cannot/); assert.match(errOf(await claims.addClaim({ subjects: [], text: "t", checks: [EXEC] }, chat)), /at least one subject/); @@ -282,10 +286,10 @@ describe("claims authoring (live Neo4j)", { skip: cfg ? false : "STRUT_TEST_NEO4 assert.equal((await listed(STEP))[0]!.status, "refuted"); assert.deepEqual(await claims.editCheck(k1, { policy: "always" }, chat), { ok: true, id: k1, unchanged: true }); - const k2 = idOf(await claims.editCheck(k1, { config: { command: "test 1 -lt 2" }, name: "bounds" }, chat)); + const k2 = idOf(await claims.editCheck(k1, { config: { cmd: "test", args: [1, "-lt", 2] }, name: "bounds" }, chat)); const [row] = await listed(STEP); assert.deepEqual([row!.status, row!.unverified], ["unknown", 1], "a changed instrument has measured nothing yet"); - assert.deepEqual(row!.checks.map((k) => [k.id, k.name, k.type, k.config, k.policy]), [[k2, "bounds", "exec", { command: "test 1 -lt 2" }, "always"]]); + assert.deepEqual(row!.checks.map((k) => [k.id, k.name, k.type, k.config, k.policy]), [[k2, "bounds", "exec", { cmd: "test", args: [1, "-lt", 2] }, "always"]]); assert.equal(typeof (await claims.reader.getCheck(k1))!.retired_at, "number"); assert.deepEqual(await backend.bolt.run(`MATCH (:Check {id: $a})-[:SUPERSEDES]->(:Check {id: $b}) RETURN count(*) AS c`, { a: k2, b: k1 }), [{ c: 1 }]); @@ -322,7 +326,7 @@ describe("claims authoring (live Neo4j)", { skip: cfg ? false : "STRUT_TEST_NEO4 assert.match(errOf(await authoring.editClaim(seeded, "answer is anything")), /only edits claims it wrote/); assert.match(errOf(await authoring.retireClaim(seeded)), /only retires claims it wrote/); assert.match(errOf(await authoring.addCheck(seeded, EXEC)), /only adds checks to claims it wrote/); - assert.match(errOf(await authoring.editCheck(seededCheck, { config: { command: "true" } })), /only edits checks it wrote/); + assert.match(errOf(await authoring.editCheck(seededCheck, { config: { cmd: "false" } })), /only edits checks it wrote/); assert.match(errOf(await authoring.retireCheck(seededCheck)), /only retires checks it wrote/); // Nor may it write claims onto a subject it did not publish. assert.match(errOf(await authoring.addClaim({ subjects: [SEEDED], text: "mine", checks: [EXEC] })), /only adds claims to subjects it authored/); @@ -362,7 +366,7 @@ describe("claims authoring (live Neo4j)", { skip: cfg ? false : "STRUT_TEST_NEO4 }); it("both doors, end to end: the capability publishes with a contract; the chat tools offer the claims surface", async () => { - const contract = [{ text: "returns a tag", checks: [{ type: "exec", config: { command: "test -n '{{ input.output.tag }}'" } }] }]; + const contract = [{ text: "returns a tag", checks: [{ type: "exec", config: { cmd: "test", args: ["-n", "{{ input.output.tag }}"] } }] }]; const created = (await authoring.createStep("cand/step", STEP_SRC("cand/step"), "d", contract)) as { ok?: true; claims?: { count: number; added: number } }; assert.deepEqual([created.ok, created.claims?.count, created.claims?.added], [true, 1, 1]); const edited = (await authoring.editStep("cand/step", STEP_SRC("cand/step", "two"), "d", contract)) as { claims?: { count: number; added: number; existing: number } }; diff --git a/src/graph/claims.test.ts b/src/graph/claims.test.ts index 16caf75..659a508 100644 --- a/src/graph/claims.test.ts +++ b/src/graph/claims.test.ts @@ -154,6 +154,19 @@ describe("claimStatus (pure)", () => { assert.equal(status([elsewhere, workflowSameName, predecessor, unattributed, ev()]).status, "supported"); }); + it("same-second verdicts order by the run that produced them (observed_at is whole seconds)", () => { + // A pass, then the regression, inside one second: the LATER run must win. + const pass = ev({ observed_at: 500, run: "1789000000100" }); + const regression = ev({ observed_at: 500, run: "1789000000900", strength: -1 }); + assert.equal(status([pass, regression]).status, "refuted"); + assert.equal(status([regression, pass]).status, "refuted"); + // No run at all (publish checks): when the node was written decides. + const clean = ev({ observed_at: 500, date_added_to_graph: 1000, version: V1 }); + const dirty = ev({ observed_at: 500, date_added_to_graph: 2000, strength: -1 }); + assert.equal(status([clean, dirty]).status, "refuted"); + assert.equal(status([dirty, clean]).status, "refuted"); + }); + it("orders by observed_at, then date_added_to_graph, then id — deterministically", () => { const a = ev({ observed_at: undefined, date_added_to_graph: 5_000, strength: -1, run: "r1" }); const b = ev({ observed_at: 6, run: "r2" }); // 6s = 6000ms, newer than a @@ -320,9 +333,11 @@ describe("claims graph: node contract + reads (live Neo4j)", { skip: cfg ? false assert.deepEqual([again.outcome, again.ref_id], ["existing", e1.ref_id]); const [row] = await reader.evidenceFor("c1", STEP); + assert.equal(typeof row!.edge_ref_id, "string", "the EVIDENCED_BY edge — what muting a slot mutes"); assert.deepEqual( - { ...row, date_added_to_graph: undefined }, + { ...row, date_added_to_graph: undefined, edge_ref_id: undefined }, { + edge_ref_id: undefined, ref_id: e1.ref_id, id, name: "computes start/end…", content: "start=12 end=31 duration=95", evidence_mode: "observed", evidence_status: "collected", observed_at: now - 5, date_added_to_graph: undefined, claim_id: "c1", strength: 1, check_id: "k1", diff --git a/src/graph/claims.ts b/src/graph/claims.ts index c2f889a..a5631a6 100644 --- a/src/graph/claims.ts +++ b/src/graph/claims.ts @@ -126,12 +126,15 @@ const SUBJECT_NODE = { * What a `run` check reads: the subject IS the check step's run input, so a * check's config templates say `{{ input.output.quote }}` — no new template * root. `input` is the step's RESOLVED CONFIG (what the runner records on - * `step.start`), or params + run input for a workflow. A step that errored + * `step.start`); for a workflow it is the run's input, with the param + * overrides beside it as `params`. A step that errored * has `error` and no `output`, so "fails loudly on a private video" is * checkable. */ export interface RunCheckSubject { input: unknown; + /** A workflow subject's param overrides (`run.start.params`). */ + params?: Record; output?: unknown; error?: { message: string; stack?: string }; runId: string; @@ -231,6 +234,8 @@ export interface EvidenceRow { claim_id: string; /** `EVIDENCED_BY.strength`: > 0 supports, < 0 refutes; absent on a slot. */ strength?: number; + /** ref_id of that `EVIDENCED_BY` edge — what muting a slot mutes. */ + edge_ref_id?: string; /** `PRODUCED_BY` target; absent when no check produced it. */ check_id?: string; /** `ABOUT` target, when it is a strut version node. */ @@ -278,14 +283,24 @@ export interface ClaimStatusInput { const NO_CHECK = ""; -function orderKey(e: EvidenceRow): number { - if (typeof e.observed_at === "number") return e.observed_at * 1000; - return typeof e.date_added_to_graph === "number" ? e.date_added_to_graph : 0; -} - -/** Newest first; id as the deterministic tie-break. */ -function newestFirst(a: EvidenceRow, b: EvidenceRow): number { - return orderKey(b) - orderKey(a) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); +/** + * Newest first. `observed_at` is epoch SECONDS (every jarvis datetime is), + * and two runs — a pass, then the regression — easily land in one second, + * so ties fall through to millisecond keys: the source run's id (run ids + * are ms timestamps), then when the node was written, then the id. + */ +export function newestFirst(a: EvidenceRow, b: EvidenceRow): number { + const seconds = (e: EvidenceRow) => (typeof e.observed_at === "number" ? e.observed_at : Math.floor((e.date_added_to_graph ?? 0) / 1000)); + const runMs = (e: EvidenceRow) => { + const n = Number(e.source?.run_id); + return Number.isFinite(n) ? n : 0; + }; + return ( + seconds(b) - seconds(a) || + runMs(b) - runMs(a) || + (b.date_added_to_graph ?? 0) - (a.date_added_to_graph ?? 0) || + (a.id < b.id ? -1 : a.id > b.id ? 1 : 0) + ); } /** @@ -430,6 +445,16 @@ export class ClaimsReader { return typeof rows[0]?.["r"] === "string" ? (rows[0]!["r"] as string) : null; } + /** `ref_id` of the version node a `VersionRef` names, or null. */ + async versionRefId(version: VersionRef): Promise { + const n = SUBJECT_NODE[version.kind]; + const rows = await this.graph.bolt.run( + `MATCH (v:\`${n.version}\` {namespace: $ns, \`${n.key}\`: $name, content_hash: $hash}) WHERE ${NOT_DELETED("v")} RETURN v.ref_id AS r LIMIT 1`, + { ns: this.ns, name: version.name, hash: version.content_hash }, + ); + return typeof rows[0]?.["r"] === "string" ? (rows[0]!["r"] as string) : null; + } + /** One claim by id (active or not), or null. */ async getClaim(id: string): Promise { const rows = await this.graph.bolt.run( @@ -516,7 +541,7 @@ export class ClaimsReader { OPTIONAL MATCH (e)-[pb:\`${CLAIM_EDGES.PRODUCED_BY}\`]->(k:\`${CHECK_TYPE}\`) WHERE ${LIVE("pb")} OPTIONAL MATCH (e)-[ab:\`${CLAIM_EDGES.ABOUT}\`]->(v) WHERE ${LIVE("ab")} AND (v:StrutStepVersion OR v:StrutWorkflowVersion) OPTIONAL MATCH (e)-[hs:\`${CLAIM_EDGES.HAS_SOURCE}\`]->(src) WHERE ${LIVE("hs")} - RETURN ${project("e", EVIDENCE_FIELDS)} AS ev, eb.strength AS strength, k.id AS check_id, + RETURN ${project("e", EVIDENCE_FIELDS)} AS ev, eb.strength AS strength, eb.ref_id AS edge_ref_id, k.id AS check_id, v:StrutStepVersion AS v_is_step, v.name AS v_name, v.step_type AS v_step_type, v.content_hash AS v_hash, src.ref_id AS src_ref, labels(src) AS src_labels, src.run_id AS src_run_id, hs.context AS hs_context, hs.start_time AS hs_start, hs.end_time AS hs_end, hs.post_url AS hs_url`, @@ -527,6 +552,7 @@ export class ClaimsReader { const e = compact>(r["ev"] as Record); const row: EvidenceRow = byId.get(e.id) ?? { ...e, claim_id: claimId }; if (row.strength === undefined && typeof r["strength"] === "number") row.strength = r["strength"] as number; + if (row.edge_ref_id === undefined && typeof r["edge_ref_id"] === "string") row.edge_ref_id = r["edge_ref_id"] as string; if (row.check_id === undefined && typeof r["check_id"] === "string") row.check_id = r["check_id"] as string; if (!row.about && typeof r["v_hash"] === "string") { const isStep = r["v_is_step"] === true; diff --git a/src/graph/verify.test.ts b/src/graph/verify.test.ts new file mode 100644 index 0000000..87117ac --- /dev/null +++ b/src/graph/verify.test.ts @@ -0,0 +1,443 @@ +/** + * The verify pass, end to end (plans/claims.md §4): real workflows run + * through `createStrut` on a graph-backed workspace, real checks executed, + * evidence read back from Neo4j. Steps are injected into the registry (these + * tests are about verification, not module loading); the workspace still + * holds their versions, which is what evidence is ABOUT. + */ +import { describe, it, before, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { z } from "zod"; +import { defineStep, type RunEvent, type StepRegistry } from "../core.js"; +import { createStrut, type Strut } from "../createStrut.js"; +import { coreRegistry } from "../steps/registry.js"; +import { MemoryRunStore } from "../store.js"; +import { buildTools } from "../ai/tools.js"; +import { buildAuthoringCapability, type AuthoringCapability } from "../authoring.js"; +import { buildClaimsAuthoring, type ClaimActor, type ClaimsAuthoring, type CheckSpecInput } from "../claims-authoring.js"; +import type { VerifyResult, Verifier } from "../verify.js"; +import { openGraphBackend, type GraphBackend } from "./backend.js"; +import { Neo4jWorkspaceStore } from "./workspace-store.js"; +import { testGraphConfig, wipeGraph } from "./test-util.js"; + +const cfg = testGraphConfig(); +const SRC = (type: string, tag: string) => `// ${tag}\nimport { z, defineStep } from "strut";\nexport default defineStep({ type: "${type}", input: z.any(), output: z.any(), run: async () => ({}) });\n`; +const WF = (name: string, steps: string) => `name: ${name}\nsteps:\n${steps}`; + +describe("verify pass (live Neo4j)", { skip: cfg ? false : "STRUT_TEST_NEO4J_URI not set" }, () => { + let backend: GraphBackend; + let dir: string; + let ws: Neo4jWorkspaceStore; + let store: MemoryRunStore; + let strut: Strut; + let claims: ClaimsAuthoring; + let verifier: Verifier; + let registry: StepRegistry; + let judged = 0; + const human: ClaimActor = { publisher: "evan", scoped: false }; + const ai: ClaimActor = { publisher: "ai", scoped: false }; + const STEP = { kind: "step" as const, name: "clip/compute-times" }; + const savedEnv: Record = {}; + + /** `test A -op B` as a free, observed check. */ + const compare = (a: string, op: string, b: string, extra: Partial = {}): CheckSpecInput => ({ type: "exec", config: { cmd: "test", args: [a, op, b] }, ...extra }); + const addClaim = async (subject: { kind: "step" | "workflow"; name: string }, text: string, checks: CheckSpecInput[], actor = human) => { + const r = await claims.addClaim({ subjects: [subject], text, checks }, actor); + assert.ok("ok" in r, (r as { error?: string }).error); + return r as { ok: true; id: string; checks: string[] }; + }; + const verify = async (name: string, runId: string): Promise => { + const res = await strut.app.request(`/workflows/${name}/runs/${runId}/verify`, { method: "POST" }); + assert.equal(res.status, 200, await res.clone().text()); + return (await res.json()) as VerifyResult; + }; + const statusOf = async (subject: { kind: "step" | "workflow"; name: string }) => { + const r = await claims.listClaims(subject); + assert.ok("ok" in r); + return (r as Extract).claims.map((c) => [c.text, c.status, c.assertedOnly, c.unverified, c.openSlot] as const); + }; + const rows = (cypher: string, params: Record = {}) => backend.bolt.run(cypher, params); + const count = async (cypher: string) => Number((await rows(cypher))[0]!["c"]); + const outcomes = (r: VerifyResult) => r.checks.map((k) => [k.path, k.lastVerify] as const); + + before(async () => { + backend = await openGraphBackend(cfg!, { embeddings: false, skipBoot: true }); + dir = await mkdtemp(join(tmpdir(), "strut-verify-")); + await wipeGraph(backend.bolt); + const { seedJarvisOntology } = await import("./ontology-seed.js"); + const { seedStrutDomain } = await import("./schema-seed.js"); + await seedJarvisOntology(backend.bolt); + await seedStrutDomain(backend.bolt); + backend.schemas.invalidate(); + for (const k of ["STRUT_VERIFY_BUDGET_USD", "STRUT_VERIFY_BUDGET_USD_PER_DAY"]) savedEnv[k] = process.env[k]; + }); + after(async () => { + for (const [k, v] of Object.entries(savedEnv)) v === undefined ? delete process.env[k] : (process.env[k] = v); + await backend.close(); + await rm(dir, { recursive: true, force: true }); + }); + beforeEach(async () => { + await rows(`MATCH (n) WHERE NOT n:Schema AND NOT n:Migration DETACH DELETE n`); + delete process.env["STRUT_VERIFY_BUDGET_USD"]; + delete process.env["STRUT_VERIFY_BUDGET_USD_PER_DAY"]; + judged = 0; + ws = new Neo4jWorkspaceStore(backend, { materializeDir: join(dir, "steps") }); + store = new MemoryRunStore(); + registry = { + ...coreRegistry(), + // start + len → end; `len` can be negative, which is the bug claims catch. + "clip/compute-times": defineStep({ + type: "clip/compute-times", + input: z.object({ start: z.number(), len: z.number().default(19) }), + output: z.any(), + run: async (c) => { + if (c.start < 0) throw new Error("start must be ≥ 0"); + return { start: c.start, end: c.start + c.len }; + }, + }), + // A stand-in for a paid judge: reports cost the way agent/llm-backed steps do. + llm: defineStep({ type: "llm", input: z.any(), output: z.any(), run: async () => ({ supports: true, content: `judged #${++judged}`, cost: 0.4 }) }), + "judge/sneaky-cost": defineStep({ type: "judge/sneaky-cost", input: z.any(), output: z.any(), run: async () => ({ supports: true, content: "called a model through services", cost: 0.3 }) }), + } as StepRegistry; + await ws.publishStep("clip/compute-times", SRC("clip/compute-times", "v1"), "v1", "ai"); + await ws.publishWorkflowByContent("clipper", WF("clipper", ` - id: times\n type: clip/compute-times\n config: { start: "{{ input.start }}", len: "{{ input.len }}" }\n`)); + strut = await createStrut({ workspace: ws, store, registry, dataDir: dir, serveUi: false, enableChat: false, stt: false }); + const getRegistry = async () => registry; + claims = buildClaimsAuthoring({ graph: backend, workspace: ws, getRegistry }); + // ONE verifier per deployment: passes are single-flighted per instance. + verifier = strut.verifier!; + }); + + it("run → evidence: Evidence + EVIDENCED_BY / PRODUCED_BY / ABOUT / HAS_SOURCE; idempotent; refuted; stale on a new version", async () => { + const onStep = await addClaim(STEP, "end is after start", [compare("{{ input.output.end }}", "-gt", "{{ input.output.start }}", { name: "bounds" })]); + await addClaim({ kind: "workflow", name: "clipper" }, "the clip starts where asked", [compare("{{ input.output.start }}", "-eq", "{{ input.input.start }}")]); + assert.deepEqual(await statusOf(STEP), [["end is after start", "unknown", false, 1, false]]); + + const good = await strut.run("clipper", { start: 5, len: 19 }); + assert.equal(good.status, "success"); + const first = await verify("clipper", good.runId); // joins (or repeats) the detached pass + assert.deepEqual(outcomes(first), [["clipper/times", { ran: true }], ["clipper", { ran: true }]]); + assert.deepEqual(await statusOf(STEP), [["end is after start", "supported", false, 0, false]]); + assert.deepEqual(await statusOf({ kind: "workflow", name: "clipper" }), [["the clip starts where asked", "supported", false, 0, false]]); + + const written = await rows( + `MATCH (c:Claim {id: $c})-[eb:EVIDENCED_BY]->(e:Evidence)-[:PRODUCED_BY]->(k:Check {id: $k}), (e)-[:ABOUT]->(v:StrutStepVersion), (e)-[hs:HAS_SOURCE]->(r:StrutRun)-[:EXECUTED]->(wv:StrutWorkflowVersion) + RETURN eb.strength AS strength, e.evidence_mode AS mode, e.evidence_status AS status, e.name AS name, e.content AS content, v.content_hash AS about, r.run_id AS run, wv.name AS wf, hs.context AS context`, + { c: onStep.id, k: onStep.checks[0] }, + ); + const stepHash = (await ws.getActiveStepHashes())["clip/compute-times"]; + assert.deepEqual( + written.map((w) => ({ ...w, context: JSON.parse(w["context"] as string) })), + [{ strength: 1, mode: "observed", status: "collected", name: "end is after start", content: "exit 0", about: stepHash, run: good.runId, wf: "clipper", context: { path: "clipper/times", checkVersion: "exec" } }], + ); + + // A second pass over the same run writes nothing — by construction. + const evidenceBefore = await count(`MATCH (e:Evidence) RETURN count(e) AS c`); + const second = await verify("clipper", good.runId); + assert.deepEqual([second.evidence, outcomes(second)], [0, [["clipper/times", { ran: true }], ["clipper", { ran: true }]]]); + assert.equal(await count(`MATCH (e:Evidence) RETURN count(e) AS c`), evidenceBefore); + assert.equal(await count(`MATCH (r:StrutRun) RETURN count(r) AS c`), 1); + + // Adding a check, then re-verifying, runs ONLY that check. + const extra = await claims.addCheck(onStep.id, compare("{{ input.output.end }}", "-lt", "1000", { name: "sane" }), human); + assert.ok("ok" in extra); + const third = await verify("clipper", good.runId); + assert.equal(third.evidence, 1); + + // A different input refutes; a refutation on the active version always wins. + const bad = await strut.run("clipper", { start: 50, len: -10 }); + await verify("clipper", bad.runId); + const [stepStatus] = await statusOf(STEP); + assert.deepEqual([stepStatus![1], stepStatus![3]], ["refuted", 0]); + const refutation = await rows(`MATCH (:Claim {id: $c})-[eb:EVIDENCED_BY]->(e:Evidence)-[:HAS_SOURCE]->(:StrutRun {run_id: $r}) WHERE eb.strength < 0 RETURN e.content AS content`, { c: onStep.id, r: bad.runId }); + assert.equal(refutation.length, 1); + + // Publish a fix: nothing runs at publish, the evidence is simply about an older version. + await ws.publishStep("clip/compute-times", SRC("clip/compute-times", "v2"), "v2"); + assert.deepEqual((await statusOf(STEP))[0]!.slice(1, 4), ["stale", false, 2]); + const fixed = await strut.run("clipper", { start: 1, len: 5 }); + await verify("clipper", fixed.runId); + assert.deepEqual((await statusOf(STEP))[0]!.slice(1, 4), ["supported", false, 0]); + }); + + it("the automatic pass fires by itself after a top-level run — no explicit verify", async () => { + await addClaim(STEP, "end is after start", [compare("{{ input.output.end }}", "-gt", "{{ input.output.start }}")]); + await strut.run("clipper", { start: 5, len: 19 }); + for (let i = 0; i < 100 && (await statusOf(STEP))[0]![1] !== "supported"; i++) await new Promise((r) => setTimeout(r, 50)); + assert.equal((await statusOf(STEP))[0]![1], "supported"); + }); + + it("a foreach yields one Evidence per iteration, and one failing iteration refutes the run; an errored step is checkable", async () => { + await ws.publishWorkflowByContent( + "batch", + WF("batch", ` - id: each\n type: foreach\n config:\n items: "{{ input.items }}"\n body:\n id: times\n type: clip/compute-times\n config: { start: "{{ $current.start }}", len: "{{ $current.len }}" }\n`), + ); + const c = await addClaim(STEP, "end is after start", [compare("{{ input.output.end }}", "-gt", "{{ input.output.start }}")]); + const run = await strut.run("batch", { items: [{ start: 1, len: 5 }, { start: 9, len: -3 }, { start: 2, len: 5 }] }); + const r = await verify("batch", run.runId); + assert.deepEqual(r.checks.map((k) => k.lastVerify), [{ ran: true }, { ran: true }, { ran: true }]); + const paths = await rows(`MATCH (:Claim {id: $c})-[eb:EVIDENCED_BY]->(e:Evidence)-[hs:HAS_SOURCE]->() RETURN hs.context AS ctx, eb.strength AS s ORDER BY ctx`, { c: c.id }); + assert.deepEqual(paths.map((p) => [JSON.parse(p["ctx"] as string).path, p["s"]]), [["batch/each#0", 1], ["batch/each#1", -1], ["batch/each#2", 1]]); + assert.equal((await statusOf(STEP))[0]![1], "refuted", "the last iteration passing does not paper over the second failing"); + + // "fails loudly on bad input": the subject is { input, error }, no output. + const loud = await addClaim(STEP, "rejects a negative start, loudly", [{ type: "exec", config: { cmd: "test", args: ["-n", "{{ input.error.message }}"] } }]); + const failed = await strut.run("clipper", { start: -1, len: 5 }); + assert.equal(failed.status, "error"); + const v = await verify("clipper", failed.runId); + const byClaim = Object.fromEntries(v.checks.filter((k) => k.path === "clipper/times").map((k) => [k.claimId, k.lastVerify])); + assert.deepEqual(byClaim[loud.id], { ran: true }); + // The bounds check has no `output` to read: its templates cannot resolve — a broken check, never a verdict. + assert.deepEqual((byClaim[c.id] as { skipped?: string }).skipped, "cannot-launch"); + const loudRow = (await statusOf(STEP)).find((s) => s[0].startsWith("rejects"))!; + assert.equal(loudRow[1], "supported"); + }); + + it("a nested subflow is an execution of the child workflow: evidence at the nested path, about the child's version", async () => { + await ws.publishWorkflowByContent("outer", WF("outer", ` - id: inner\n type: subflow\n config: { workflow: clipper, input: { start: "{{ input.start }}", len: 4 } }\n`)); + const c = await addClaim({ kind: "workflow", name: "clipper" }, "the clip starts where asked", [compare("{{ input.output.start }}", "-eq", "{{ input.input.start }}")]); + const run = await strut.run("outer", { start: 7 }); + const r = await verify("outer", run.runId); + assert.deepEqual(r.checks.map((k) => [k.subject, k.path, k.lastVerify]), [[{ kind: "workflow", name: "clipper" }, "outer/inner", { ran: true }]]); + const got = await rows( + `MATCH (:Claim {id: $c})-[:EVIDENCED_BY]->(e:Evidence)-[:ABOUT]->(v:StrutWorkflowVersion), (e)-[hs:HAS_SOURCE]->(r:StrutRun) RETURN v.name AS wf, v.content_hash AS h, r.workflow_name AS run_of, hs.context AS ctx`, + { c: c.id }, + ); + assert.deepEqual( + got.map((g) => ({ ...g, ctx: JSON.parse(g["ctx"] as string).path })), + [{ wf: "clipper", h: await ws.getWorkflowHash("clipper"), run_of: "outer", ctx: "outer/inner" }], + ); + assert.equal((await statusOf({ kind: "workflow", name: "clipper" }))[0]![1], "supported", "a workflow that only ever runs nested still gets evidence"); + }); + + it("no recorded version → no evidence, ever (never 'the active version'); a broken check is never a pass", async () => { + const c = await addClaim(STEP, "end is after start", [ + compare("{{ input.output.end }}", "-gt", "{{ input.output.start }}"), + { type: "exec", config: { cmd: "definitely-not-a-real-binary-xyz" }, name: "missing tool" }, + { type: "exec", config: { cmd: "bash", args: ["-c", "exit 127"] }, name: "command not found" }, + ]); + // A run recorded BEFORE stepHashes existed: same events, no hashes. + const ts = new Date().toISOString(); + const old: RunEvent[] = [ + { ts, runId: "1700000000000", path: "clipper", type: "run.start", input: { start: 1 } }, + { ts, runId: "1700000000000", path: "clipper/times", type: "step.start", stepType: "clip/compute-times", input: { start: 1, len: 19 } }, + { ts, runId: "1700000000000", path: "clipper/times", type: "step.end", stepType: "clip/compute-times", output: { start: 1, end: 20 } }, + { ts, runId: "1700000000000", path: "clipper", type: "run.end", output: { start: 1, end: 20 } }, + ]; + for (const e of old) await store.append("clipper", "1700000000000", e); + const blind = await verify("clipper", "1700000000000"); + assert.deepEqual(blind.checks.map((k) => k.lastVerify), [{ skipped: "unknown-version" }, { skipped: "unknown-version" }, { skipped: "unknown-version" }]); + assert.equal(await count(`MATCH (e:Evidence) RETURN count(e) AS c`), 0); + assert.equal(await count(`MATCH (r:StrutRun) RETURN count(r) AS c`), 0, "a run that produced no evidence never reaches the graph"); + + const run = await strut.run("clipper", { start: 5, len: 19 }); + const r = await verify("clipper", run.runId); + assert.deepEqual(r.checks.map((k) => ("skipped" in k.lastVerify ? k.lastVerify.skipped : "ran")), ["ran", "cannot-launch", "cannot-launch"]); + assert.equal(await count(`MATCH (:Claim {id: "${c.id}"})-[:EVIDENCED_BY]->(e) RETURN count(e) AS c`), 1); + assert.deepEqual((await statusOf(STEP))[0]!.slice(1, 4), ["supported", false, 2], "supported by the one check that ran; two still unverified"); + assert.equal((await verify("clipper", "nope").catch(() => null)), null, "an unknown run is a 404"); + }); + + it("a check can be a whole workflow; check runs are never verified, even when the check workflow has claims of its own", async () => { + await ws.publishWorkflowByContent( + "bounds-check", + WF("bounds-check", ` - id: cmp\n type: exec\n config: { cmd: bash, args: ["-c", "test {{ input.end }} -gt {{ input.start }} && echo '{\\"supports\\": true, \\"content\\": \\"end {{ input.end }} > start {{ input.start }}\\"}' || echo '{\\"supports\\": false, \\"content\\": \\"end {{ input.end }} <= start {{ input.start }}\\"}'"] }\n - id: verdict\n type: exec\n depends: [cmp]\n config: { cmd: echo, args: ["{{ cmp.stdout }}"] }\n`), + ); + // The check workflow carries its own claim — unguarded, verifying it would recurse. + await addClaim({ kind: "workflow", name: "bounds-check" }, "always answers", [compare("1", "-eq", "1")]); + const c = await addClaim(STEP, "end is after start", [ + { type: "subflow", name: "bounds", config: { workflow: "bounds-check", input: { start: "{{ input.output.start }}", end: "{{ input.output.end }}" } } }, + ]); + const listed = await claims.listClaims(STEP); + assert.equal(("ok" in listed ? listed.claims[0]!.checks[0]!.policy : ""), "always", "no agent/llm in the closure → free → always"); + + const run = await strut.run("clipper", { start: 5, len: 19 }); + await verify("clipper", run.runId); + await new Promise((r) => setTimeout(r, 300)); // let any (wrongly) scheduled pass over the check run settle + const ev = await rows(`MATCH (:Claim {id: $c})-[eb:EVIDENCED_BY]->(e:Evidence)-[hs:HAS_SOURCE]->(r:StrutRun) RETURN e.content AS content, e.evidence_mode AS mode, eb.strength AS s, hs.context AS ctx, r.run_id AS run`, { c: c.id }); + assert.deepEqual( + ev.map((e) => ({ ...e, ctx: JSON.parse(e["ctx"] as string) })), + [{ content: "end 24 > start 5", mode: "observed", s: 1, ctx: { path: "clipper/times", checkVersion: `bounds-check@${await ws.getWorkflowHash("bounds-check")}` }, run: run.runId }], + ); + // The pass terminated, and nothing is sourced to a verify-origin run. + assert.equal(await count(`MATCH (r:StrutRun) RETURN count(r) AS c`), 1); + assert.equal((await statusOf({ kind: "workflow", name: "bounds-check" }))[0]![1], "unknown", "the check's own claim gets evidence only when bounds-check is run directly"); + const direct = await strut.run("bounds-check", { start: 1, end: 2 }); + await verify("bounds-check", direct.runId); + assert.equal((await statusOf({ kind: "workflow", name: "bounds-check" }))[0]![1], "supported"); + + // Republish the instrument under the frozen Check node → on_change notices via checkVersion. + const onChange = await claims.editCheck(c.checks[0]!, { policy: "on_change" }, human); + assert.ok("ok" in onChange); + const r2 = await strut.run("clipper", { start: 6, len: 19 }); + assert.deepEqual((await verify("clipper", r2.runId)).checks.map((k) => k.lastVerify), [{ ran: true }], "a new check node has no evidence yet"); + const r3 = await strut.run("clipper", { start: 7, len: 19 }); + assert.deepEqual((await verify("clipper", r3.runId)).checks.map((k) => k.lastVerify), [{ skipped: "policy" }], "same version, same instrument, fresh"); + await ws.publishWorkflowByContent("bounds-check", WF("bounds-check", ` - id: verdict\n type: exec\n config: { cmd: echo, args: ['{"supports": true, "content": "rewritten"}'] }\n`)); + const r4 = await strut.run("clipper", { start: 8, len: 19 }); + assert.deepEqual((await verify("clipper", r4.runId)).checks.map((k) => k.lastVerify), [{ ran: true }], "the check's resolved version changed"); + }); + + it("policy + budget: paid checks fire on_change, are asserted, persisted under check:, capped; a free check that reports cost is caught", async () => { + const c = await addClaim(STEP, "the answer reads naturally", [ + { type: "llm", name: "judge-1", config: { prompt: "natural? {{ input.output.end }}", model: "sonnet" } }, + { type: "llm", name: "judge-2", config: { prompt: "natural? {{ input.output.end }}" } }, + { type: "llm", name: "judge-3", config: { prompt: "natural? {{ input.output.end }}" } }, + { type: "exec", name: "manual-only", config: { cmd: "true" }, policy: "manual" }, + ]); + process.env["STRUT_VERIFY_BUDGET_USD"] = "0.5"; + const run = await strut.run("clipper", { start: 5, len: 19 }); + const auto = await verifier.verifyRun("clipper", run.runId); // what the detached trigger calls + const byCheck = Object.fromEntries(auto.checks.map((k) => [c.checks.indexOf(k.checkId), k.lastVerify])); + assert.deepEqual(byCheck[0], { ran: true }); + assert.deepEqual(byCheck[1], { ran: true }, "0.4 spent < 0.5: the cap is checked BEFORE a check runs"); + assert.deepEqual((byCheck[2] as { skipped: string }).skipped, "budget"); + assert.deepEqual(byCheck[3], { skipped: "policy" }, "manual never fires on the automatic pass"); + assert.ok(Math.abs(auto.costUsd - 0.8) < 1e-9); + assert.equal(judged, 2); + + const paid = await rows(`MATCH (:Claim {id: $c})-[:EVIDENCED_BY]->(e:Evidence)-[hs:HAS_SOURCE]->() RETURN e.evidence_mode AS mode, hs.context AS ctx ORDER BY e.content`, { c: c.id }); + assert.deepEqual(paid.map((p) => p["mode"]), ["asserted", "asserted"], "a judgment is not an observation"); + const ctx = JSON.parse(paid[0]!["ctx"] as string) as { model?: string; checkRun?: string; checkVersion?: string }; + assert.deepEqual([ctx.model, ctx.checkVersion, typeof ctx.checkRun], ["sonnet", "llm", "string"]); + // The paid check run is on record in its own bucket, tagged with what it verified — and is never a workflow. + const bucket = `check:${c.checks[0]}`; + assert.deepEqual(await store.listRuns(bucket), [ctx.checkRun]); + const start = (await store.getRunEvents(bucket, ctx.checkRun!)).find((e) => e.type === "run.start")!; + assert.deepEqual([start.origin, start.verify], ["verify", { checkId: c.checks[0], subject: "step:clip/compute-times", sourceRunId: run.runId }]); + assert.deepEqual((await statusOf(STEP))[0]!.slice(1, 4), ["supported", true, 2], "assertedOnly: nothing observed yet"); + + // verify_run fires the manual check, re-runs nothing else — and judge-3 is still over today's... per-run cap resets per pass. + const explicit = await verify("clipper", run.runId); + const again = Object.fromEntries(explicit.checks.map((k) => [c.checks.indexOf(k.checkId), k.lastVerify])); + assert.deepEqual([again[0], again[1], again[2], again[3]], [{ ran: true }, { ran: true }, { ran: true }, { ran: true }]); + assert.equal(judged, 3); + assert.deepEqual((await statusOf(STEP))[0]!.slice(1, 4), ["supported", false, 0], "the observed exec support lifts assertedOnly"); + + // on_change: a second run of the SAME version fires none of the paid checks. + const r2 = await strut.run("clipper", { start: 6, len: 19 }); + const quiet = await verifier.verifyRun("clipper", r2.runId); + assert.deepEqual(quiet.checks.map((k) => k.lastVerify), [{ skipped: "policy" }, { skipped: "policy" }, { skipped: "policy" }, { skipped: "policy" }]); + assert.equal(judged, 3); + + // The per-subject daily cap is computed from the store: 1.2 already spent today. + process.env["STRUT_VERIFY_BUDGET_USD_PER_DAY"] = "1"; + delete process.env["STRUT_VERIFY_BUDGET_USD"]; + await ws.publishStep("clip/compute-times", SRC("clip/compute-times", "v2"), "v2"); + const r3 = await strut.run("clipper", { start: 7, len: 19 }); + const capped = await verifier.verifyRun("clipper", r3.runId); + assert.deepEqual(capped.checks.slice(0, 3).map((k) => ("skipped" in k.lastVerify ? k.lastVerify.skipped : "ran")), ["budget", "budget", "budget"]); + assert.equal((await statusOf(STEP))[0]![1], "stale", "skipped for budget is never `supported`"); + + // A presumed-FREE check that turns out to cost money: persisted, counted, visible. + const sneaky = await addClaim(STEP, "cites sources", [{ type: "judge/sneaky-cost", config: {} }]); + const lc = await claims.listClaims(STEP); + assert.equal(("ok" in lc ? lc.claims.find((x) => x.id === sneaky.id)!.checks[0]!.policy : ""), "always", "its type says free"); + const r4 = await strut.run("clipper", { start: 8, len: 19 }); + const caught = await verifier.verifyRun("clipper", r4.runId); + assert.ok(Math.abs(caught.costUsd - 0.3) < 1e-9); + assert.equal((await store.listRuns(`check:${sneaky.checks[0]}`)).length, 1); + assert.deepEqual(await store.listRuns(`check:${c.checks[3]}`), [], "a check that reports no cost is not persisted at all"); + }); + + it("external checks: a planned slot (no strength, no content), filled in place by add_evidence; a newer version replaces the question", async () => { + const c = await addClaim(STEP, "the cut sounds natural", [{ description: "listen to the clip at the cut — code cannot hear a click" }]); + const run = await strut.run("clipper", { start: 5, len: 19 }); + const r = await verify("clipper", run.runId); + const slotId = (r.checks[0]!.lastVerify as { planned: string }).planned; + assert.ok(slotId); + assert.equal(r.slots, 1); + const slot = await rows( + `MATCH (:Claim {id: $c})-[eb:EVIDENCED_BY]->(e:Evidence {id: $e})-[:PRODUCED_BY]->(k:Check), (e)-[:ABOUT]->(:StrutStepVersion), (e)-[:HAS_SOURCE]->(:StrutRun {run_id: $r}) + RETURN e.evidence_status AS status, e.name AS name, e.content AS content, e.evidence_mode AS mode, e.observed_at AS at, e.description AS ask, eb.strength AS strength, k.id AS check`, + { c: c.id, e: slotId, r: run.runId }, + ); + assert.deepEqual({ ...slot[0], ask: undefined }, { status: "planned", name: "the cut sounds natural", content: null, mode: null, at: null, ask: undefined, strength: null, check: c.checks[0] }); + assert.match(String(slot[0]!["ask"]), /listen to the clip.*run .* of clipper, clipper\/times.*"end":24/s); + assert.deepEqual((await statusOf(STEP))[0]!.slice(1), ["unknown", false, 1, true], "a question is not evidence"); + assert.equal((await verify("clipper", run.runId)).slots, 0, "re-verifying the same run opens nothing"); + + // The chat tool surface: add_evidence finds the open slot for this run and FILLS it. + const tools = buildTools({ workspace: ws, registry, store, getRegistry: async () => registry, verifier }) as unknown as Record Promise> }>; + assert.ok(tools["verify_run"] && tools["add_evidence"]); + const filled = await tools["add_evidence"]!.execute({ claim: c.id, name: "clipper", runId: run.runId, supports: true, content: "ffmpeg astats at the cut: no sample discontinuity above -60dB" }); + assert.deepEqual([filled["ok"], filled["filled"], filled["evidence"]], [true, true, slotId]); + const after = await rows(`MATCH (:Claim {id: $c})-[eb:EVIDENCED_BY]->(e:Evidence {id: $e})-[hs:HAS_SOURCE]->() RETURN e.evidence_status AS status, e.evidence_mode AS mode, eb.strength AS s, hs.context AS ctx`, { c: c.id, e: slotId }); + assert.deepEqual(after.map((a) => ({ ...a, ctx: JSON.parse(a["ctx"] as string) })), [{ status: "collected", mode: "asserted", s: 1, ctx: { path: "clipper/times", by: "ai" } }]); + assert.equal(await count(`MATCH (e:Evidence) RETURN count(e) AS c`), 1, "the SAME node"); + assert.deepEqual((await statusOf(STEP))[0]!.slice(1), ["supported", true, 0, false]); + + // Same version again: on_change, already answered → no new question. + const r2 = await strut.run("clipper", { start: 6, len: 19 }); + assert.deepEqual((await verify("clipper", r2.runId)).checks.map((k) => k.lastVerify), [{ skipped: "policy" }]); + // A new version → a fresh question. Left unanswered, a still-newer run REPLACES it (the old edge is muted). + await ws.publishStep("clip/compute-times", SRC("clip/compute-times", "v2"), "v2"); + const r3 = await strut.run("clipper", { start: 7, len: 19 }); + const s3 = ((await verify("clipper", r3.runId)).checks[0]!.lastVerify as { planned: string }).planned; + const r4 = await strut.run("clipper", { start: 8, len: 19 }); + const s4 = ((await verify("clipper", r4.runId)).checks[0]!.lastVerify as { planned: string }).planned; + assert.notEqual(s3, s4); + const open = await rows(`MATCH (:Claim {id: $c})-[eb:EVIDENCED_BY]->(e:Evidence {evidence_status: "planned"}) RETURN e.id AS id, coalesce(eb.is_muted, false) AS muted ORDER BY id`, { c: c.id }); + assert.deepEqual(open.sort((a, b) => Number(a["muted"]) - Number(b["muted"])), [{ id: s4, muted: false }, { id: s3, muted: true }]); + assert.deepEqual((await statusOf(STEP))[0]!.slice(1), ["stale", true, 1, true], "at most one open slot per external check"); + const refused = await tools["add_evidence"]!.execute({ claim: c.id, name: "clipper", runId: r4.runId, supports: true, content: "x", slot: slotId }); + assert.match(String(refused["error"]), /not an open slot/); + }); + + it("add_evidence without a slot is asserted, check-less evidence; only a seeded harness STEP records observed (fixed point 3)", async () => { + const c = await addClaim({ kind: "workflow", name: "clipper" }, "accuracy is at least the baseline", [{ description: "the harness reports it" }], human); + await ws.publishWorkflowByContent("gaia-evolve-gen", WF("gaia-evolve-gen", ` - id: a\n type: log\n config: { message: harness }\n`), "seeded", undefined, "seeder"); + await ws.publishWorkflowByContent("candidate", WF("candidate", ` - id: a\n type: log\n config: { message: candidate }\n`), "ai's", undefined, "ai"); + // A run with no slot on it: the check is external but on_change was already... use a manual check instead. + const manual = await claims.editCheck(c.checks[0]!, { policy: "manual" }, human); + assert.ok("ok" in manual); + const authoring = (strut.services as { authoring: AuthoringCapability }).authoring; + const run = await strut.run("clipper", { start: 5, len: 19 }); + await verifier.verifyRun("clipper", run.runId); + + const input = { claim: c.id, name: "clipper", runId: run.runId, supports: true, content: "53 tasks: 31 correct vs baseline 29" }; + const cases = [ + [{ workflow: "gaia-evolve-gen", runId: "h1" }, "observed", "gaia-evolve-gen"], + [{ workflow: "gaia-evolve-gen", runId: "h1", agentTool: true }, "asserted", "ai:h1"], // a MODEL inside the harness chose to call it + [{ workflow: "candidate", runId: "h2" }, "asserted", "ai:h2"], + [undefined, "asserted", "ai"], + ] as const; + for (const [caller] of cases) assert.ok(((await authoring.addEvidence(input, caller as never)) as { ok?: true }).ok, JSON.stringify(caller)); + const got = await rows(`MATCH (:Claim {id: $c})-[:EVIDENCED_BY]->(e:Evidence)-[hs:HAS_SOURCE]->() WHERE NOT (e)-[:PRODUCED_BY]->() RETURN e.evidence_mode AS mode, hs.context AS ctx ORDER BY e.id`, { c: c.id }); + assert.deepEqual(got.map((g) => [g["mode"], JSON.parse(g["ctx"] as string).by]), cases.map(([, mode, by]) => [mode, by])); + + // Evidence must be about a version that actually ran. + const other = await strut.run("candidate", {}); + assert.match(String(((await authoring.addEvidence({ ...input, name: "candidate", runId: other.runId })) as { error: string }).error), /did not execute a subject of this claim/); + assert.match(String(((await authoring.addEvidence({ ...input, content: " " })) as { error: string }).error), /content is empty/); + assert.match(String(((await authoring.verifyRun("clipper", run.runId)) as { error: string }).error), /not agent-authored/, "the meta surface verifies only what it published"); + assert.ok(!("error" in ((await authoring.verifyRun("candidate", other.runId)) as object))); + }); + + it("publish checks lint the new version's source; a kept run_step run is verified with EXECUTED → the step version it ran", async () => { + const lint = await addClaim(STEP, "never reads process.env directly", [ + { type: "exec", when: "publish", name: "env lint", config: { cmd: "bash", args: ["-c", "! grep -q 'process.env' <<< \"$SRC\""], env: { SRC: "{{ input.source }}" } } }, + ], ai); + await addClaim(STEP, "end is after start", [compare("{{ input.output.end }}", "-gt", "{{ input.output.start }}")]); + // The instance's registry is injected (publishing off), so publish through + // a capability of our own over the same workspace, store and verifier. + const authoring = buildAuthoringCapability({ workspace: ws, store, getRegistry: async () => registry, services: strut.services, verifier }); + const clean = (await authoring.editStep("clip/compute-times", SRC("clip/compute-times", "clean"))) as { publishChecks?: Array<{ check: string; lastVerify: unknown }> }; + assert.deepEqual(clean.publishChecks, [{ claim: lint.id, check: lint.checks[0], lastVerify: { ran: true } }]); + const about = await rows(`MATCH (:Claim {id: $c})-[eb:EVIDENCED_BY]->(e:Evidence)-[:ABOUT]->(v:StrutStepVersion)<-[:HAS_SOURCE]-(e) RETURN eb.strength AS s, v.content_hash AS h`, { c: lint.id }); + assert.deepEqual(about, [{ s: 1, h: (await ws.getActiveStepHashes())["clip/compute-times"] }], "no run: the source of the evidence IS the version node"); + await authoring.editStep("clip/compute-times", `${SRC("clip/compute-times", "dirty")}// const k = process.env.KEY;\n`); + assert.equal((await statusOf(STEP)).find((s) => s[0].startsWith("never"))![1], "refuted"); + + // run_step on a step with claims → kept under step: → verified, detached. + const r = (await authoring.runStep("clip/compute-times", { config: { start: 3, len: 4 } })) as { runId: string; kept?: string }; + assert.equal(r.kept, "step:clip/compute-times"); + for (let i = 0; i < 100 && (await statusOf(STEP)).find((s) => s[0].startsWith("end is"))![1] !== "supported"; i++) await new Promise((x) => setTimeout(x, 50)); + const executed = await rows(`MATCH (run:StrutRun {run_id: $r})-[:EXECUTED]->(v:StrutStepVersion) RETURN run.workflow_name AS wf, v.content_hash AS h`, { r: r.runId }); + assert.deepEqual(executed, [{ wf: "step:clip/compute-times", h: (await ws.getActiveStepHashes())["clip/compute-times"] }]); + assert.ok(!(await ws.listWorkflows()).some((w) => w.name.startsWith("step:"))); + }); +}); diff --git a/src/index.ts b/src/index.ts index 25e4892..9f3a6b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -214,6 +214,8 @@ export { runSingleStep, runStep, persistStepRun, + persistRunUnder, + RUN_STEP_FLOW, cassettePath, type RunStepOptions, type RunStepResult, @@ -324,6 +326,24 @@ export { type ClaimsResult, } from "./claims-authoring.js"; export { subjectSchema, checkSpecSchema, claimSpecSchema, claimsArgSchema } from "./claims-schemas.js"; +// The verify pass — how evidence is produced (plans/claims.md §4). +export { + createVerifier, + subjectsOfRun, + mapCheckResult, + policyFires, + sampleFires, + reportedCost, + type Verifier, + type VerifierDeps, + type VerifyResult, + type VerifiedCheck, + type LastVerify, + type SkipReason, + type ObservedSubject, + type MappedCheck, + type AddEvidenceInput, +} from "./verify.js"; export { NodeWriter, GraphValidationError, diff --git a/src/run-step.ts b/src/run-step.ts index bf8d339..509194d 100644 --- a/src/run-step.ts +++ b/src/run-step.ts @@ -26,6 +26,10 @@ import { * - `replay` — …serve those calls from the file: offline, deterministic, no * rate limits, no cost, no side effects. */ +/** Name of the ad-hoc one-step flow every single-step run executes as — its + * events' path root. Never a published workflow. */ +export const RUN_STEP_FLOW = "__run_step__"; + export interface RunStepOptions { /** The step's config (same shape as a workflow step's `config`). Templates * like `{{ input.* }}` / `{{ params.* }}` are resolved. */ @@ -43,6 +47,8 @@ export interface RunStepOptions { stepHashes?: Record; /** `"verify"` when the verify pass runs a check through here. */ origin?: "verify"; + /** Recorded on the check run's `run.start` (see `RunEvent.verify`). */ + verify?: { checkId: string; subject: string; sourceRunId: string }; } export interface RunStepResult { @@ -74,7 +80,7 @@ export async function runSingleStep( } const flow: Flow = { - name: "__run_step__", + name: RUN_STEP_FLOW, input: z.any(), steps: [{ id: "step", type, config: opts.config ?? {} }], ...(opts.params != null ? { params: opts.params } : {}), @@ -98,6 +104,7 @@ export async function runSingleStep( ...(opts.stepHashes ? { stepHashes: opts.stepHashes } : {}), ...(opts.cassette ? { cassette: opts.cassette.mode } : {}), ...(opts.origin ? { origin: opts.origin } : {}), + ...(opts.verify ? { verify: opts.verify } : {}), onEvent: (e) => { events.push(e); }, @@ -127,6 +134,9 @@ export interface RunStepDeps { workspace?: SubflowResolver & { getActiveStepHashes(): Promise> }; /** The claims layer — null/absent on a filesystem workspace. */ claims?: Pick | null; + /** Called once a run was persisted: the verify trigger (a single-step + * run only reaches the real store here, after `runWorkflow` returned). */ + onKept?: (key: string, runId: string) => void; } export interface KeptRunStepResult extends RunStepResult { @@ -179,13 +189,20 @@ export async function runStep( } if (!wanted) return result; const kept = await persistStepRun(deps.store, type, result); + // Now that it is in the real store it can be verified — detached, never awaited. + deps.onKept?.(kept, result.runId); return { ...result, kept }; } /** Copy a finished single-step run (events + summary) into `store` under * `step:`. Returns the key. */ export async function persistStepRun(store: RunStore, type: string, result: RunStepResult): Promise { - const key = stepRunKey(type); + return persistRunUnder(store, stepRunKey(type), result); +} + +/** Copy a finished in-memory single-step run into `store` under any key — + * `step:` for `run_step`, `check:` for a paid check run. */ +export async function persistRunUnder(store: RunStore, key: string, result: RunStepResult): Promise { for (const e of result.events) await store.append(key, result.runId, e); const first = result.events[0]!; const last = result.events[result.events.length - 1]!; diff --git a/src/runner.ts b/src/runner.ts index 15a2523..45f3033 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -73,6 +73,15 @@ export interface RunOptions { cassette?: "record" | "replay"; /** `"verify"` when the verify pass launches this run (a check). */ origin?: "verify"; + /** Recorded on a check run's `run.start` (see `RunEvent.verify`). */ + verify?: { checkId: string; subject: string; sourceRunId: string }; +} + +/** What `services.onRunEnd(runId, info)` is told about the settled run. */ +export interface RunEndInfo { + /** The flow's name — the run-store key the run was written under. */ + workflow: string; + origin?: "verify"; } /** Sentinel returned by steps that were skipped because their `when` didn't match. */ @@ -181,6 +190,7 @@ export async function runWorkflow( ...(opts?.stepHashes ? { stepHashes: opts.stepHashes } : {}), ...(opts?.cassette ? { cassette: opts.cassette } : {}), ...(opts?.origin ? { origin: opts.origin } : {}), + ...(opts?.verify ? { verify: opts.verify } : {}), // Tree linkage on disk: a nested run names its parent so boot-time // auto-resume can tell roots from children (§5.3). ...(opts?.controller?.parent ? { parentRunId: opts.controller.parent.runId } : {}), @@ -255,7 +265,13 @@ export async function runWorkflow( // the run's real result. (Hard kills (SIGKILL) still skip this — identical to // any in-process `finally`; that case is handled out-of-band.) try { - await (services as { onRunEnd?: (id: string) => unknown })?.onRunEnd?.(runId); + // The second argument names WHICH run settled — what a post-run + // consumer (the verify pass, plans/claims.md §4) needs and a teardown + // hook can ignore. + await (services as { onRunEnd?: (id: string, info: RunEndInfo) => unknown })?.onRunEnd?.(runId, { + workflow: wfName, + ...(opts?.origin ? { origin: opts.origin } : {}), + }); } catch (teardownErr) { console.error(`[runner] onRunEnd hook failed for run ${runId}:`, teardownErr); } @@ -514,11 +530,13 @@ async function executeStep( : undefined : resolvedConfig; + const subflow = step.type === "subflow" ? await describeSubflow(step, scope, exec) : undefined; await exec.emit({ type: "step.start", path, stepType: step.type, input: startInput, + ...(subflow ? { subflow } : {}), }); // Execute based on step type @@ -903,6 +921,23 @@ async function executeForeach( return results; } +/** Which child a `subflow` step is about to run (`RunEvent.subflow`). Never + * throws: a bad reference fails in `executeSubflow`, with its own message. + * The hash needs more than a `SubflowResolver` — a full workspace has it. */ +async function describeSubflow(step: Step, scope: Record, exec: Exec): Promise { + try { + const workflow = resolveConfig(step.config["workflow"], scope); + if (typeof workflow !== "string" || !workflow) return undefined; + const v = step.config["version"] != null ? resolveConfig(step.config["version"], scope) : undefined; + const version = typeof v === "string" && v ? v : undefined; + const hashOf = (exec.workspace as { getWorkflowHash?: (name: string, version?: string) => Promise } | undefined)?.getWorkflowHash; + const hash = hashOf ? await hashOf.call(exec.workspace, workflow, version).catch(() => null) : null; + return { workflow, ...(version ? { version } : {}), ...(hash ? { hash } : {}) }; + } catch { + return undefined; + } +} + async function executeSubflow( step: Step, scope: Record, diff --git a/src/steps/core/agent.ts b/src/steps/core/agent.ts index 9c723be..1b2ca93 100644 --- a/src/steps/core/agent.ts +++ b/src/steps/core/agent.ts @@ -553,8 +553,8 @@ export function buildRegistryTools( const base: StepContext = ctx ?? ({ runId: "", path: "", scope: {}, input: undefined, emit: (async () => {}) as any, services: undefined }); const childCtx: StepContext = options?.strutToolPath - ? { ...base, path: options.strutToolPath } - : base; + ? { ...base, agentTool: true, path: options.strutToolPath } + : { ...base, agentTool: true }; return def.run(parsed, childCtx); }, }); diff --git a/src/steps/lib/meta/add-evidence.ts b/src/steps/lib/meta/add-evidence.ts new file mode 100644 index 0000000..f464612 --- /dev/null +++ b/src/steps/lib/meta/add-evidence.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; +import { defineStep } from "../../../core.js"; +import { requireAuthoring } from "./_shared.js"; +import { subjectSchema } from "../../../claims-schemas.js"; + +export default defineStep({ + type: "meta/add-evidence", + description: + "Record an observation about a claim on a specific run — sourced to that run and the version it executed; `content` says what was observed and how. How much it is worth depends on WHO calls it: a step of a seeded harness workflow (a grader reporting its verdict — content must carry verdicts, never gold answers) records OBSERVED evidence; an agent calling this as a tool, or any agent-authored workflow, records ASSERTED evidence whatever it claims — a candidate whose only support is its own author's word shows as `assertedOnly`. If an external check is waiting on this run (an open slot), this answers it.", + input: z.object({ + claim: z.string().describe("Claim id (from meta/list-claims)"), + name: z.string().describe("The run's workflow name, or `step:` for a kept single-step run"), + runId: z.string(), + supports: z.boolean().describe("true: the observation supports the claim; false: it refutes it"), + content: z.string().describe("What was observed, and how — one bounded statement"), + subject: subjectSchema.optional().describe("Only when the run executed several of the claim's subjects"), + slot: z.string().optional().describe("Evidence id of the open slot to fill; found automatically for this run when omitted"), + }), + output: z.any(), + async run(cfg, ctx) { + return requireAuthoring(ctx.services).addEvidence( + { claim: cfg.claim, name: cfg.name, runId: cfg.runId, supports: cfg.supports, content: cfg.content, subject: cfg.subject, slot: cfg.slot }, + // Who is asking: the run's top-level workflow, and whether a model chose to. + { workflow: ctx.path.split("/")[0], runId: ctx.runId, agentTool: ctx.agentTool === true }, + ); + }, +}); diff --git a/src/steps/lib/meta/verify-run.ts b/src/steps/lib/meta/verify-run.ts new file mode 100644 index 0000000..9825f38 --- /dev/null +++ b/src/steps/lib/meta/verify-run.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; +import { defineStep } from "../../../core.js"; +import { requireAuthoring } from "./_shared.js"; + +export default defineStep({ + type: "meta/verify-run", + description: + "Verify a finished run NOW and wait for it: run the checks of every claim on the subjects the run executed and write the evidence. Runs are verified automatically after they finish, but DETACHED — a harness that reads claim statuses right after a run must call this first (it is single-flighted with the automatic pass and idempotent, so whichever starts second just awaits the first and nothing is written twice). Also re-verifies after a claim or check changed, and fires `manual` checks. `name` is an agent-authored workflow, or `step:` for a kept meta/run-step run. Returns per-check lastVerify: { ran } | { skipped: policy | budget | cannot-launch | unknown-version | denied, reason? } | { planned: }; read statuses with meta/list-claims.", + input: z.object({ + name: z.string().describe("Workflow name, or `step:` for a kept single-step run"), + runId: z.string(), + }), + output: z.any(), + async run(cfg, ctx) { + return requireAuthoring(ctx.services).verifyRun(cfg.name, cfg.runId); + }, +}); diff --git a/src/verify.test.ts b/src/verify.test.ts new file mode 100644 index 0000000..59ddda2 --- /dev/null +++ b/src/verify.test.ts @@ -0,0 +1,229 @@ +/** + * The verify pass's pure parts (plans/claims.md §4): what a run observed, + * what a check run says, when a check fires, what it cost. The pass itself + * runs against a live graph in `src/graph/verify.test.ts`. + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { RunEvent } from "./core.js"; +import type { EvidenceRow } from "./graph/claims.js"; +import { mapCheckResult, policyFires, reportedCost, sampleFires, subjectsOfRun } from "./verify.js"; + +let tick = 0; +const ev = (type: RunEvent["type"], path: string, extra: Partial = {}): RunEvent => ({ + ts: new Date(Date.UTC(2026, 8, 17, 12, 0, tick++)).toISOString(), + runId: "r1", + path, + type, + ...extra, +}); + +describe("subjectsOfRun", () => { + it("a step is its step.start.input + step.end.output, at the version recorded on run.start", () => { + const events = [ + ev("run.start", "wf", { input: { url: "u" }, params: { lang: "en" }, workflowHash: "wfhash", stepHashes: { "clip/fetch": "aaa" }, cassette: "replay" }), + ev("step.start", "wf/fetch", { stepType: "clip/fetch", input: { url: "u", langs: ["en"] } }), + ev("step.end", "wf/fetch", { stepType: "clip/fetch", output: { captions: 1 } }), + ev("step.start", "wf/note", { stepType: "log", input: { message: "hi" } }), + ev("step.end", "wf/note", { stepType: "log", output: "hi" }), + ev("run.end", "wf", { output: { ok: true } }), + ]; + const [fetch, note, wf] = subjectsOfRun("wf", events); + assert.deepEqual(fetch, { + subject: { kind: "step", type: "clip/fetch" }, + version: "aaa", + path: "wf/fetch", + at: events[2]!.ts, + check: { runId: "r1", cassette: "replay", input: { url: "u", langs: ["en"] }, path: "wf/fetch", output: { captions: 1 } }, + }); + assert.deepEqual([note!.subject, note!.version], [{ kind: "step", type: "log" }, undefined], "a built-in has no recorded version → never evidence"); + assert.deepEqual(wf, { + subject: { kind: "workflow", name: "wf" }, + version: "wfhash", + path: "wf", + at: events[5]!.ts, + check: { runId: "r1", cassette: "replay", input: { url: "u" }, params: { lang: "en" }, path: "wf", output: { ok: true } }, + }); + }); + + it("a foreach yields one subject per iteration; containers and agent tool calls yield none", () => { + const events = [ + ev("run.start", "wf", { stepHashes: { "clip/cut": "ccc" } }), + ev("step.start", "wf/each", { stepType: "foreach", input: [1, 2] }), + ev("step.start", "wf/each#0", { stepType: "clip/cut", input: { i: 0 }, iteration: 0 }), + ev("step.end", "wf/each#0", { stepType: "clip/cut", output: "a" }), + ev("step.start", "wf/each#1", { stepType: "clip/cut", input: { i: 1 }, iteration: 1 }), + ev("step.end", "wf/each#1", { stepType: "clip/cut", output: "b" }), + ev("step.end", "wf/each", { stepType: "foreach", output: ["a", "b"] }), + ev("step.start", "wf/plan/001-clip_cut", { stepType: "tool:clip_cut", input: {} }), + ev("step.end", "wf/plan/001-clip_cut", { stepType: "tool:clip_cut", output: "truncated…" }), + ev("run.end", "wf", { output: 1 }), + ]; + const steps = subjectsOfRun("wf", events).filter((o) => o.subject.kind === "step"); + assert.deepEqual(steps.map((o) => [o.path, o.check.output, o.version]), [["wf/each#0", "a", "ccc"], ["wf/each#1", "b", "ccc"]]); + }); + + it("an errored step yields { input, error } with no output; a replayed step yields nothing", () => { + const events = [ + ev("run.start", "wf", { stepHashes: { "clip/fetch": "aaa" } }), + ev("step.replayed", "wf/cached", { stepType: "clip/fetch", output: "old" }), + ev("step.start", "wf/fetch", { stepType: "clip/fetch", input: { url: "private" } }), + ev("step.error", "wf/fetch", { stepType: "clip/fetch", error: { message: "video is private" } }), + ev("run.error", "wf", { error: { message: "video is private" } }), + ]; + const [fetch, wf] = subjectsOfRun("wf", events); + assert.deepEqual(fetch!.check, { runId: "r1", input: { url: "private" }, path: "wf/fetch", error: { message: "video is private" } }); + assert.ok(!("output" in fetch!.check)); + assert.deepEqual([wf!.subject, wf!.check.error], [{ kind: "workflow", name: "wf" }, { message: "video is private" }]); + assert.equal(subjectsOfRun("wf", events).length, 2); + }); + + it("a subflow step is an execution of the CHILD workflow, at the nested path and the hash recorded when it ran", () => { + const events = [ + ev("run.start", "gaia-run", { workflowHash: "outer", stepHashes: { "gaia/answer": "sss" } }), + ev("step.start", "gaia-run/produce", { stepType: "subflow", input: { q: "?" }, subflow: { workflow: "gaia-produce", hash: "inner" } }), + ev("step.start", "gaia-run/produce/answer", { stepType: "gaia/answer", input: { q: "?" } }), + ev("step.end", "gaia-run/produce/answer", { stepType: "gaia/answer", output: { answer: "42" } }), + ev("step.end", "gaia-run/produce", { stepType: "subflow", output: { answer: "42" } }), + ev("step.start", "gaia-run/legacy", { stepType: "subflow", input: {} }), + ev("step.end", "gaia-run/legacy", { stepType: "subflow", output: 1 }), + ev("run.end", "gaia-run", { output: { answer: "42" } }), + ]; + const subjects = subjectsOfRun("gaia-run", events); + assert.deepEqual( + subjects.map((o) => [o.subject.kind, o.subject.kind === "step" ? o.subject.type : o.subject.name, o.path, o.version]), + [ + ["step", "gaia/answer", "gaia-run/produce/answer", "sss"], + ["workflow", "gaia-produce", "gaia-run/produce", "inner"], + ["workflow", "gaia-run", "gaia-run", "outer"], + ], + "a subflow with no recorded child (a run from before this change) yields nothing", + ); + assert.deepEqual(subjects[1]!.check.input, { q: "?" }); + }); + + it("a resume reloads steps: later executions carry run.resumed's hashes; a single-step bucket has no workflow subject", () => { + const events = [ + ev("run.start", "wf", { workflowHash: "w", stepHashes: { "clip/a": "v1" } }), + ev("step.start", "wf/one", { stepType: "clip/a", input: 1 }), + ev("step.end", "wf/one", { stepType: "clip/a", output: 1 }), + ev("run.error", "wf", { error: { message: "crash" } }), + ev("run.resumed", "wf", { stepHashes: { "clip/a": "v2" } }), + ev("step.replayed", "wf/one", { stepType: "clip/a", output: 1 }), + ev("step.start", "wf/two", { stepType: "clip/a", input: 2 }), + ev("step.end", "wf/two", { stepType: "clip/a", output: 2 }), + ev("run.end", "wf", { output: 2 }), + ]; + const subjects = subjectsOfRun("wf", events); + assert.deepEqual(subjects.filter((o) => o.subject.kind === "step").map((o) => [o.path, o.version]), [["wf/one", "v1"], ["wf/two", "v2"]]); + assert.deepEqual(subjects.at(-1)!.check.output, 2, "the workflow subject reads the LAST terminal event"); + + const single = [ + ev("run.start", "__run_step__", { stepHashes: { "clip/a": "v1" } }), + ev("step.start", "__run_step__/step", { stepType: "clip/a", input: {} }), + ev("step.end", "__run_step__/step", { stepType: "clip/a", output: 1 }), + ev("run.end", "__run_step__", { output: 1 }), + ]; + assert.deepEqual(subjectsOfRun("step:clip/a", single).map((o) => o.subject), [{ kind: "step", type: "clip/a" }]); + assert.deepEqual(subjectsOfRun("wf", []), []); + }); +}); + +describe("mapCheckResult — the check contract", () => { + const ok = (output: unknown) => ({ status: "success" as const, output }); + + it("{ supports, content, locator? } — directly, under `object` (agent + schema), or under `json` (exec parseJson)", () => { + assert.deepEqual(mapCheckResult("clip/judge", ok({ supports: true, content: "found at 12s", locator: { start_time: 12, end_time: 31 } })), { + kind: "evidence", supports: true, content: "found at 12s", locator: { start_time: 12, end_time: 31 }, + }); + assert.deepEqual(mapCheckResult("agent", ok({ result: "…", object: { supports: false, content: "trailing period" }, cost: 0.01 })), { kind: "evidence", supports: false, content: "trailing period" }); + assert.deepEqual(mapCheckResult("exec", ok({ code: 0, stdout: "{}", stderr: "", json: { supports: true, content: "ok" } })), { kind: "evidence", supports: true, content: "ok" }); + assert.deepEqual(mapCheckResult("subflow", ok({ supports: true, content: { score: 0.93 } })), { kind: "evidence", supports: true, content: '{"score":0.93}' }, "non-string content is kept, as JSON"); + }); + + it("a bare exec maps from its exit code; JSON on stdout wins when it is a verdict", () => { + assert.deepEqual(mapCheckResult("exec", ok({ code: 0, stdout: "duration ok\n", stderr: "" })), { kind: "evidence", supports: true, content: "duration ok" }); + assert.deepEqual(mapCheckResult("exec", ok({ code: 1, stdout: "", stderr: "end 120 > duration 95\n" })), { kind: "evidence", supports: false, content: "end 120 > duration 95" }); + assert.deepEqual(mapCheckResult("exec", ok({ code: 0, stdout: "", stderr: "" })), { kind: "evidence", supports: true, content: "exit 0" }); + assert.deepEqual(mapCheckResult("exec", ok({ code: 3, stdout: '{"supports": true, "content": "stdout verdict"}', stderr: "" })), { kind: "evidence", supports: true, content: "stdout verdict" }); + assert.deepEqual(mapCheckResult("exec", ok({ code: 0, stdout: '{"segments": []}', stderr: "" })), { kind: "evidence", supports: true, content: '{"segments": []}' }, "JSON that is not a verdict is just output"); + // Shape, not type: a `subflow` check whose child ENDS in an exec reads the same way. + assert.deepEqual(mapCheckResult("subflow", ok({ code: 0, stdout: '{"supports": false, "content": "quote not found"}\n', stderr: "", cwd: "/x", durationMs: 3, truncated: false })), { kind: "evidence", supports: false, content: "quote not found" }); + assert.deepEqual(mapCheckResult("subflow", ok({ code: 2, stdout: "", stderr: "mismatch", cwd: "/x" })), { kind: "evidence", supports: false, content: "mismatch" }); + }); + + it("a check that CANNOT run is never a verdict: nothing is written, the claim stays unknown", () => { + assert.deepEqual(mapCheckResult("exec", ok({ code: 127, stdout: "", stderr: "ffprobe: command not found" })), { kind: "cannot-run", reason: "exit 127: ffprobe: command not found" }); + assert.equal(mapCheckResult("exec", ok({ code: 126, stdout: "", stderr: "" })).kind, "cannot-run"); + assert.deepEqual(mapCheckResult("exec", ok({ code: null, stdout: "", stderr: "" })), { kind: "cannot-run", reason: "the check process was killed" }); + assert.deepEqual(mapCheckResult("clip/judge", { status: "error", error: { message: "app never booted" } }), { kind: "cannot-run", reason: "app never booted" }); + assert.equal(mapCheckResult("clip/judge", { status: "cancelled" }).kind, "cannot-run"); + assert.deepEqual(mapCheckResult("llm", ok({ text: "looks fine to me" })), { kind: "cannot-run", reason: "the check returned no { supports, content }" }); + assert.equal(mapCheckResult("clip/judge", ok({ supports: "yes", content: "x" })).kind, "cannot-run", "supports must be a boolean"); + }); + + it("content is one bounded string", () => { + const long = mapCheckResult("x", ok({ supports: true, content: "a".repeat(5000) })); + assert.ok(long.kind === "evidence" && long.content.length === 500 && long.content.endsWith("…")); + const tailed = mapCheckResult("exec", ok({ code: 1, stdout: "", stderr: `${"noise ".repeat(500)}THE REAL ERROR` })); + assert.ok(tailed.kind === "evidence" && tailed.content.length === 500 && tailed.content.endsWith("THE REAL ERROR"), "an exec's TAIL is what says why"); + }); +}); + +describe("policy", () => { + const NOW = Date.UTC(2026, 8, 17); + const collected = (over: Partial & { checkVersion?: string; daysAgo?: number } = {}): EvidenceRow => ({ + ref_id: "r", id: "e", name: "n", claim_id: "c", check_id: "k", evidence_status: "collected", strength: 1, + observed_at: Math.trunc(NOW / 1000) - (over.daysAgo ?? 0) * 86_400, + about: { kind: "step", name: "clip/a", content_hash: "v2" }, + source: { ref_id: "run", run_id: "r0", context: { checkVersion: over.checkVersion ?? "exec" } }, + ...over, + }); + const fires = (policy: string | undefined, evidence: EvidenceRow[], over: Partial[0]> = {}, check: Record = {}) => + policyFires({ check: { id: "k", policy, ...check }, evidence, version: "v2", checkVersion: "exec", runId: "r1", path: "wf/a", explicit: false, now: NOW, ...over }); + + it("always fires on every run; manual only when asked", () => { + assert.equal(fires("always", [collected()]), true); + assert.equal(fires(undefined, [collected()]), true, "no policy = always"); + assert.equal(fires("manual", []), false); + assert.equal(fires("manual", [], { explicit: true }), true); + }); + + it("on_change: no evidence, a new subject version, a republished check, or stale evidence — read from THAT check's evidence", () => { + assert.equal(fires("on_change", []), true, "never checked"); + assert.equal(fires("on_change", [collected()]), false, "same version, same instrument, fresh"); + assert.equal(fires("on_change", [collected()], { version: "v3" }), true, "the subject moved on"); + assert.equal(fires("on_change", [collected({ checkVersion: "fuzzy@h1" })], { checkVersion: "fuzzy@h2" }), true, "the check's own code changed under a frozen node"); + assert.equal(fires("on_change", [collected({ daysAgo: 8 })]), true, "older than the default 7 days — environment drift"); + assert.equal(fires("on_change", [collected({ daysAgo: 8 })], {}, { freshness_days: 30 }), false); + assert.equal(fires("on_change", [collected({ daysAgo: 2 })], {}, { freshness_days: 1 }), true); + // The NEWEST evidence decides, whatever order it arrives in. + assert.equal(fires("on_change", [collected({ daysAgo: 20, about: { kind: "step", name: "clip/a", content_hash: "v1" } }), collected()]), false); + assert.equal(fires("weekly", [collected()]), false, "an unknown policy reads as the careful one"); + }); + + it("sample is a fraction of runs, and deterministic per (check, run, path) so a re-verify samples the same way", () => { + assert.equal(sampleFires("k", "r1", "p", 1), true); + assert.equal(sampleFires("k", "r1", "p", 0), false); + const hits = Array.from({ length: 2000 }, (_, i) => sampleFires("k", `run-${i}`, "p", 0.25)).filter(Boolean).length; + assert.ok(hits > 400 && hits < 600, `~25% of runs, got ${hits}/2000`); + for (const id of ["a", "b", "c"]) assert.equal(sampleFires("k", id, "p", 0.5), sampleFires("k", id, "p", 0.5)); + assert.equal(fires("sample", [], {}, { sample_rate: 1 }), true); + assert.equal(fires("sample", [], {}, {}), false, "no rate = never"); + }); +}); + +describe("reportedCost", () => { + it("sums `cost` from step outputs — not from containers (a subflow's output IS its last step's) or tool calls", () => { + const events = [ + ev("run.start", "__run_step__"), + ev("step.end", "__run_step__/step/judge", { stepType: "agent", output: { object: { supports: true }, cost: 0.4 } }), + ev("step.end", "__run_step__/step/judge/001-web", { stepType: "tool:web_search", output: { cost: 9 } }), + ev("step.end", "__run_step__/step/note", { stepType: "log", output: "no cost here" }), + ev("step.end", "__run_step__/step", { stepType: "subflow", output: { object: { supports: true }, cost: 0.4 } }), + ev("step.end", "__run_step__/x", { stepType: "clip/judge", output: { cost: "free" } }), + ]; + assert.equal(reportedCost(events), 0.4); + assert.equal(reportedCost([]), 0); + }); +}); diff --git a/src/verify.ts b/src/verify.ts new file mode 100644 index 0000000..9afb524 --- /dev/null +++ b/src/verify.ts @@ -0,0 +1,820 @@ +/** + * The verify pass — how evidence is produced (`plans/claims.md` §4). + * + * A post-run consumer in the projector's mould: zero coupling to the hot + * path, re-runnable, and idempotent by construction. It reads a finished + * run's event log, rebuilds every SUBJECT the run observed (a step at a + * path, one per loop iteration; a nested subflow as an execution of the + * child workflow; the workflow itself), and for each active claim on that + * subject runs each active check whose policy fires — writing one + * `Evidence` per (check, run, path), or opening a planned slot for an + * external check. + * + * What it will NOT do: + * - guess a version. Evidence is ABOUT the exact version observed + * (`run.start.stepHashes` / `workflowHash`, a subflow's recorded hash). + * No record → no evidence (`skipped: "unknown-version"`) — never "the + * active version", which would attribute old behaviour to new code; + * - read a broken check as a pass. A check that cannot run writes + * NOTHING and the claim stays `unknown` ("not evaluated, never fail"); + * - verify its own runs. Checks run with `origin: "verify"`, and such runs + * are skipped by every trigger — a check workflow that has claims would + * otherwise verify its checks, whose checks verify theirs; + * - let a producer-visible check reach a grader: the deny-list is applied + * to the check CLOSURE again here, because a subflow child can be + * republished after the check was written; + * - overspend: a check presumed paid is skipped once a cap is hit, and any + * check that REPORTS cost is persisted under `check:` and counted. + */ +import { createHash } from "node:crypto"; +import { closureIncludes, flowClosure } from "./closure.js"; +import type { RunEvent, StepRegistry } from "./core.js"; +import { deniedInClosure, verifyDenyPatterns } from "./claims-authoring.js"; +import type { GraphBackend } from "./graph/backend.js"; +import { + CLAIM_EDGES, + ClaimsReader, + DEFAULT_FRESHNESS_DAYS, + EVIDENCE_TYPE, + evidenceId, + isExternalCheck, + newEpistemicId, + newestFirst, + subjectName, + type CheckResult, + type CheckRow, + type ClaimRow, + type EvidenceMode, + type EvidenceRow, + type PublishCheckSubject, + type RunCheckSubject, + type SourceContext, + type SubjectRef, + type VersionRef, +} from "./graph/claims.js"; +import { boundedName } from "./graph/claims-writer.js"; +import type { EdgeInput } from "./graph/edge-writer.js"; +import { projectRun } from "./graph/projector.js"; +import { PREVIEW_MAX_CHARS } from "./graph/strut-schemas.js"; +import { RUN_STEP_FLOW, persistRunUnder, runSingleStep, type RunStepResult } from "./run-step.js"; +import { checkRunKey, stepTypeOfRunKey, type RunStore } from "./store.js"; +import type { WorkspaceStore } from "./workspace.js"; + +// ── What a run observed ───────────────────────────────────────────────────── + +/** One thing a run observed: a subject, the version that executed, and what + * a check will read. */ +export interface ObservedSubject { + subject: SubjectRef; + /** Content hash of the executed version; absent = never recorded. */ + version?: string; + /** Event path (`wf/clip`; `wf/each#2` for a foreach body's third iteration); the workflow's own path for a workflow. */ + path: string; + /** When the behaviour happened (the `step.end` / `run.end` timestamp) — + * the Evidence's `observed_at`, so backfilling an old run never makes old + * evidence look new. */ + at: string; + check: Omit; +} + +const CONTAINER_STEPS = new Set(["subflow", "loop", "foreach"]); +const isToolEvent = (e: RunEvent) => typeof e.stepType === "string" && e.stepType.startsWith("tool:"); + +/** + * Rebuild the subjects from a run's event log alone (run events carry full + * values; previews are a projector concern). Pure. + * + * - a step at path `p`: `step.start.input` (its RESOLVED CONFIG) + `step.end.output`; + * - a loop / foreach body: the same, once per iteration (the path carries it); + * - a `subflow` step: an execution of the CHILD workflow, at the nested path; + * - the workflow itself: `run.start` input + params, `run.end` output; + * - a step / run that errored: `{ input, error }`, no output — so "fails + * loudly on a private video" is checkable; + * - `step.replayed` (resume): nothing executed, no subject; + * - an agent's tool calls (`tool:*`): not subjects — their logged output is truncated. + */ +export function subjectsOfRun(key: string, events: readonly RunEvent[]): ObservedSubject[] { + const out: ObservedSubject[] = []; + const launch = events.find((e) => e.type === "run.start"); + if (!launch) return out; + const runId = launch.runId; + const cassette = launch.cassette; + const base = { runId, ...(cassette ? { cassette } : {}) }; + let stepHashes: Record = launch.stepHashes ?? {}; + // The hashes in force when each step STARTED (a resume reloads steps). + const starts = new Map }>(); + + const stepSubject = (start: { event: RunEvent; stepHashes: Record }, end: RunEvent): ObservedSubject | null => { + const type = end.stepType; + if (!type) return null; + const result = end.type === "step.error" ? { error: end.error ?? { message: "unknown error" } } : { output: end.output }; + if (type === "subflow") { + const child = start.event.subflow; + if (!child) return null; + return { + subject: { kind: "workflow", name: child.workflow }, + ...(child.hash ? { version: child.hash } : {}), + path: end.path, + at: end.ts, + check: { ...base, input: start.event.input, path: end.path, ...result }, + }; + } + if (CONTAINER_STEPS.has(type)) return null; + const version = start.stepHashes[type]; + return { + subject: { kind: "step", type }, + ...(version ? { version } : {}), + path: end.path, + at: end.ts, + check: { ...base, input: start.event.input, path: end.path, ...result }, + }; + }; + + for (const e of events) { + if (e.type === "run.resumed" && e.stepHashes) stepHashes = e.stepHashes; + if (isToolEvent(e)) continue; + if (e.type === "step.start") starts.set(e.path, { event: e, stepHashes }); + else if (e.type === "step.end" || e.type === "step.error") { + const start = starts.get(e.path); + if (!start) continue; + // An error followed by a later success at the same path (a resumed run) + // is superseded: keep one subject per path, the last outcome. + const s = stepSubject(start, e); + if (!s) continue; + const prior = out.findIndex((o) => o.path === e.path && o.subject.kind === s.subject.kind && subjectName(o.subject) === subjectName(s.subject)); + if (prior >= 0) out.splice(prior, 1); + out.push(s); + } + } + + // The workflow itself — not for a single-step / check bucket, whose "flow" + // is the ad-hoc wrapper. + if (!key.includes(":") && launch.path !== RUN_STEP_FLOW) { + const end = [...events].reverse().find((e) => e.type === "run.end" || e.type === "run.error"); + if (end) { + out.push({ + subject: { kind: "workflow", name: key }, + ...(launch.workflowHash ? { version: launch.workflowHash } : {}), + path: launch.path, + at: end.ts, + check: { + ...base, + input: launch.input, + ...(launch.params ? { params: launch.params } : {}), + path: launch.path, + ...(end.type === "run.error" ? { error: end.error ?? { message: "unknown error" } } : { output: end.output }), + }, + }); + } + } + return out; +} + +// ── The check contract ────────────────────────────────────────────────────── + +export type MappedCheck = + | { kind: "evidence"; supports: boolean; content: string; locator?: CheckResult["locator"] } + | { kind: "cannot-run"; reason: string }; + +const tail = (s: string, n = PREVIEW_MAX_CHARS) => (s.length > n ? `…${s.slice(-(n - 1))}` : s); +const bound = (s: string, n = PREVIEW_MAX_CHARS) => (s.length > n ? `${s.slice(0, n - 1)}…` : s); + +function asCheckResult(v: unknown): CheckResult | null { + if (!v || typeof v !== "object" || Array.isArray(v)) return null; + const o = v as Record; + if (typeof o["supports"] !== "boolean") return null; + const content = typeof o["content"] === "string" ? o["content"] : o["content"] === undefined ? "" : JSON.stringify(o["content"]); + const loc = o["locator"] && typeof o["locator"] === "object" ? (o["locator"] as CheckResult["locator"]) : undefined; + return { supports: o["supports"], content, ...(loc ? { locator: loc } : {}) }; +} + +/** + * What a finished check run says (plans/claims.md §4, "The check contract"). + * Pure. + * + * - `{ supports, content, locator? }` — as the step's output, or under + * `object` (an `agent` with a schema), `json` (an `exec` with parseJson), + * or as JSON on an `exec`'s stdout; + * - a bare `exec` (or a workflow that ends in one): exit 0 → supports, + * non-zero → refutes, content = the output tail. 126 / 127 (not executable / command not found), a kill, + * or a timeout is the CHECK failing, not the subject; + * - anything else — the step threw, returned no verdict — cannot run: + * nothing is written, and the claim stays `unknown`. + */ +export function mapCheckResult(stepType: string, result: Pick): MappedCheck { + if (result.status !== "success") return { kind: "cannot-run", reason: bound(result.error?.message ?? `check run ${result.status}`, 300) }; + const out = result.output as Record | null | undefined; + const direct = asCheckResult(out) ?? asCheckResult(out?.["object"]) ?? asCheckResult(out?.["json"]); + if (direct) return { kind: "evidence", ...direct, content: bound(direct.content) }; + // Shape, not type: a `subflow` check whose child ENDS in an exec hands back + // that exec's result, and it reads the same way. + const execShaped = !!out && typeof out === "object" && typeof out["stdout"] === "string" && "code" in out; + if ((stepType === "exec" || execShaped) && out && typeof out === "object") { + const stdout = typeof out["stdout"] === "string" ? (out["stdout"] as string) : ""; + const stderr = typeof out["stderr"] === "string" ? (out["stderr"] as string) : ""; + try { + const parsed = asCheckResult(JSON.parse(stdout.trim())); + if (parsed) return { kind: "evidence", ...parsed, content: bound(parsed.content) }; + } catch { + // Not JSON: fall through to the exit code. + } + const code = out["code"]; + if (code === 0) return { kind: "evidence", supports: true, content: tail((stdout || stderr).trim()) || "exit 0" }; + if (code === 126 || code === 127) return { kind: "cannot-run", reason: bound(`exit ${code}: ${(stderr || stdout).trim()}`, 300) }; + if (typeof code === "number") return { kind: "evidence", supports: false, content: tail((stderr || stdout).trim()) || `exit ${code}` }; + return { kind: "cannot-run", reason: "the check process was killed" }; + } + return { kind: "cannot-run", reason: "the check returned no { supports, content }" }; +} + +/** Cost a check run reported: `agent` / `llm`-style steps put `cost` in their + * OUTPUT (`RunEvent` has no cost field). Containers and tool calls are + * skipped — a subflow's output is its last step's, which would double count. */ +export function reportedCost(events: readonly RunEvent[]): number { + let total = 0; + for (const e of events) { + if (e.type !== "step.end" || isToolEvent(e) || CONTAINER_STEPS.has(e.stepType ?? "")) continue; + const cost = (e.output as { cost?: unknown } | null | undefined)?.cost; + if (typeof cost === "number" && Number.isFinite(cost) && cost > 0) total += cost; + } + return total; +} + +// ── Policy ────────────────────────────────────────────────────────────────── + +/** Deterministic in (check, run, path), so re-verifying a run samples the + * same way — idempotence survives `sample`. */ +export function sampleFires(checkId: string, runId: string, path: string, rate: number): boolean { + const n = parseInt(createHash("sha256").update(`sample|${checkId}|${runId}|${path}`).digest("hex").slice(0, 8), 16); + return n / 0xffffffff < rate; +} + +export interface PolicyInput { + check: Pick; + /** THIS check's collected evidence for (claim, subject), any version. */ + evidence: readonly EvidenceRow[]; + /** The version the run under verification executed. */ + version: string; + /** What the check would run as now (`@`). */ + checkVersion: string; + runId: string; + path: string; + /** `verify_run` / `meta/verify-run`: fires `manual` checks too. */ + explicit: boolean; + now: number; +} + +/** Does a `run` check fire on this subject? (plans/claims.md §4.1). Each + * decision reads THAT check's evidence only. Pure. */ +export function policyFires(p: PolicyInput): boolean { + const policy = p.check.policy ?? "always"; + if (policy === "always") return true; + if (policy === "manual") return p.explicit; + if (policy === "sample") return sampleFires(p.check.id, p.runId, p.path, p.check.sample_rate ?? 0); + // on_change — and any value we do not know reads as the careful one. + const latest = [...p.evidence].sort(newestFirst)[0]; + if (!latest) return true; + if (latest.about?.content_hash !== p.version) return true; + if ((latest.source?.context?.checkVersion ?? p.checkVersion) !== p.checkVersion) return true; + const days = p.check.freshness_days ?? DEFAULT_FRESHNESS_DAYS; + return p.now / 1000 - (latest.observed_at ?? 0) > days * 86_400; +} + +// ── Results ───────────────────────────────────────────────────────────────── + +export type SkipReason = "policy" | "budget" | "cannot-launch" | "unknown-version" | "denied"; +export type LastVerify = { pending: true } | { ran: true } | { skipped: SkipReason; reason?: string } | { planned: string }; + +export interface VerifiedCheck { + subject: SubjectRef; + path: string; + claimId: string; + checkId: string; + lastVerify: LastVerify; +} + +export interface VerifyResult { + key: string; + runId: string; + /** The whole pass was a no-op, and why. */ + skipped?: "verify-origin" | "unknown-run" | "unfinished"; + /** Subjects the run observed that carry claims. */ + subjects: SubjectRef[]; + checks: VerifiedCheck[]; + /** Evidence nodes written / slots opened by THIS pass. */ + evidence: number; + slots: number; + /** What this pass's checks reported spending. */ + costUsd: number; +} + +export interface AddEvidenceInput { + claim: string; + /** Run-store key: a workflow name, or `step:`. */ + name: string; + runId: string; + supports: boolean; + content: string; + /** Needed only when the run executed several of the claim's subjects. */ + subject?: SubjectRef; + /** Fill THIS planned slot (the panel passes it; otherwise an open slot + * for (claim, run) is found). */ + slot?: string; + /** Who vouches: `ai`, `person`, a chat / session id. */ + by: string; + /** `observed` only for an unstamped harness; everything a model or a + * person says is `asserted` (fixed point 3). */ + mode?: EvidenceMode; +} + +export interface VerifierDeps { + graph: GraphBackend; + store: RunStore; + workspace: WorkspaceStore; + /** FRESH registry per pass: a step published this generation is visible to its checks. */ + getRegistry(): Promise; + /** The services bag checks run with (a getter: createStrut builds it late). */ + services: () => unknown; + env?: Record; + /** A DETACHED pass settled (the notifier hook). Never called for a no-op pass. */ + onSettled?: (result: VerifyResult) => void; +} + +const DEFAULT_BUDGET_USD = 1; +const DEFAULT_BUDGET_USD_PER_DAY = 5; +const AI_STAMP = "ai"; +const PAID_STEP_TYPES = ["agent", "llm"]; + +const money = (raw: string | undefined, fallback: number) => { + const n = raw === undefined || raw === "" ? NaN : Number(raw); + return Number.isFinite(n) && n >= 0 ? n : fallback; +}; +const sameSubject = (a: SubjectRef, b: SubjectRef) => a.kind === b.kind && subjectName(a) === subjectName(b); +const subjectKey = (s: SubjectRef) => `${s.kind}:${subjectName(s)}`; + +export type Verifier = ReturnType; + +export function createVerifier(deps: VerifierDeps) { + const { graph, store, workspace } = deps; + const reader = new ClaimsReader(graph); + const env = () => deps.env ?? process.env; + const inflight = new Map }>(); + + /** What the check would execute as right now: `exec`, `clip/judge@`, + * `fuzzy-match@` — recorded on the Evidence, and what `on_change` + * compares to notice a republished instrument under a frozen Check node. */ + async function resolveCheckVersion(check: CheckRow, config: Record, stepHashes: Record): Promise { + const type = check.step_type!; + if (type === "subflow" && typeof config["workflow"] === "string") { + const v = typeof config["version"] === "string" ? (config["version"] as string) : undefined; + const hash = await workspace.getWorkflowHash(config["workflow"] as string, v).catch(() => null); + return `${config["workflow"]}@${hash ?? v ?? "unknown"}`; + } + return stepHashes[type] ? `${type}@${stepHashes[type]}` : type; + } + + /** One pass's shared state. */ + function newPass(key: string, runId: string, explicit: boolean) { + return { + key, + runId, + explicit, + result: { key, runId, subjects: [], checks: [], evidence: 0, slots: 0, costUsd: 0 } as VerifyResult, + claims: new Map(), + checks: new Map(), + evidence: new Map(), + spentToday: new Map(), + runRef: undefined as string | null | undefined, + activeStepHashes: undefined as Record | undefined, + registry: undefined as StepRegistry | undefined, + }; + } + type Pass = ReturnType; + + const claimsOf = async (pass: Pass, s: SubjectRef) => { + const k = subjectKey(s); + if (!pass.claims.has(k)) pass.claims.set(k, await reader.claimsFor(s)); + return pass.claims.get(k)!; + }; + const checksOf = async (pass: Pass, claimId: string) => { + if (!pass.checks.has(claimId)) pass.checks.set(claimId, await reader.checksFor(claimId)); + return pass.checks.get(claimId)!; + }; + const evidenceOf = async (pass: Pass, claimId: string, s: SubjectRef, fresh = false) => { + const k = `${claimId}|${subjectKey(s)}`; + if (fresh || !pass.evidence.has(k)) pass.evidence.set(k, await reader.evidenceFor(claimId, s)); + return pass.evidence.get(k)!; + }; + + /** A subject's verify spend so far today, from the store alone: today's + * runs in the buckets of the checks on its claims, tagged with it. */ + async function spentToday(pass: Pass, s: SubjectRef): Promise { + const k = subjectKey(s); + if (pass.spentToday.has(k)) return pass.spentToday.get(k)!; + const midnight = new Date(); + midnight.setUTCHours(0, 0, 0, 0); + let total = 0; + for (const claim of await claimsOf(pass, s)) { + for (const check of await reader.checksFor(claim.id, { includeRetired: true })) { + const bucket = checkRunKey(check.id); + for (const id of await store.listRuns(bucket)) { + if (Number(id) < midnight.getTime()) break; // newest first + const events = await store.getRunEvents(bucket, id); + if (events.find((e) => e.type === "run.start")?.verify?.subject === k) total += reportedCost(events); + } + } + } + pass.spentToday.set(k, total); + return total; + } + + /** The `StrutRun` evidence points at — projected on first need, so a run + * that produced no evidence never reaches the graph. */ + async function runRefOf(pass: Pass): Promise { + if (pass.runRef === undefined) pass.runRef = await projectRun(graph, store, pass.key, pass.runId); + return pass.runRef; + } + + async function writeEvidence(input: { + id: string; + claim: ClaimRow; + check?: CheckRow; + version: VersionRef; + sourceRef: string; + node: Record; + strength?: number; + context: SourceContext; + locator?: CheckResult["locator"]; + }): Promise<"written" | "unknown-version"> { + const versionRef = await reader.versionRefId(input.version); + if (!versionRef) return "unknown-version"; + const node = await graph.nodes.write({ type: EVIDENCE_TYPE, data: { id: input.id, name: boundedName(input.claim.claim_text), ...input.node } }, "create"); + const loc = input.locator ?? {}; + const context: SourceContext = { ...input.context, ...(typeof loc.path === "string" ? { file: loc.path } : {}) }; + const edges: EdgeInput[] = [ + { edge: CLAIM_EDGES.ABOUT, source_ref_id: node.ref_id, target_ref_id: versionRef }, + ...(input.check ? [{ edge: CLAIM_EDGES.PRODUCED_BY, source_ref_id: node.ref_id, target_ref_id: input.check.ref_id }] : []), + { + edge: CLAIM_EDGES.HAS_SOURCE, + source_ref_id: node.ref_id, + target_ref_id: input.sourceRef, + properties: { + context: JSON.stringify(context), + ...(typeof loc.start_time === "number" ? { start_time: loc.start_time } : {}), + ...(typeof loc.end_time === "number" ? { end_time: loc.end_time } : {}), + ...(typeof loc.url === "string" ? { post_url: loc.url } : {}), + }, + }, + // Last: this is the edge that makes the evidence count. + { edge: CLAIM_EDGES.EVIDENCED_BY, source_ref_id: input.claim.ref_id, target_ref_id: node.ref_id, ...(input.strength !== undefined ? { properties: { strength: input.strength } } : {}) }, + ]; + await graph.edges.writeMany(edges); + return "written"; + } + + /** Run one step check over a subject. Decides deny / budget, runs it in + * memory, keeps the run only if it reported cost. */ + async function runCheck( + pass: Pass, + check: CheckRow, + subject: SubjectRef, + input: RunCheckSubject | PublishCheckSubject, + ): Promise<{ skipped: SkipReason; reason?: string } | { mapped: MappedCheck; mode: EvidenceMode; checkVersion: string; model?: string; checkRun?: string }> { + let config: Record; + try { + config = check.step_config ? (JSON.parse(check.step_config) as Record) : {}; + } catch { + return { skipped: "cannot-launch", reason: "step_config is not JSON" }; + } + pass.registry ??= await deps.getRegistry(); + const type = check.step_type!; + if (!pass.registry[type]) return { skipped: "cannot-launch", reason: `step type "${type}" is not in the registry` }; + + const closure = await flowClosure({ steps: [{ id: "check", type, config }] }, workspace); + if (check.publisher === AI_STAMP) { + if (!closure.resolvable) return { skipped: "denied", reason: "the check's closure cannot be resolved" }; + const grader = deniedInClosure(closure, verifyDenyPatterns(env()), Object.keys(pass.registry)); + if (grader) return { skipped: "denied", reason: `reaches a harness-only step (${grader})` }; + } + const paidInClosure = !closure.resolvable || PAID_STEP_TYPES.some((t) => closureIncludes(closure, t)); + if (paidInClosure) { + const perRun = money(env()["STRUT_VERIFY_BUDGET_USD"], DEFAULT_BUDGET_USD); + const perDay = money(env()["STRUT_VERIFY_BUDGET_USD_PER_DAY"], DEFAULT_BUDGET_USD_PER_DAY); + if (pass.result.costUsd >= perRun) return { skipped: "budget", reason: `this run's verify budget ($${perRun}) is spent` }; + if ((await spentToday(pass, subject)) >= perDay) return { skipped: "budget", reason: `today's verify budget for this subject ($${perDay}) is spent` }; + } + + pass.activeStepHashes ??= await workspace.getActiveStepHashes().catch(() => ({})); + const checkVersion = await resolveCheckVersion(check, config, pass.activeStepHashes); + const run = await runSingleStep(type, pass.registry, deps.services(), { + // A failed assertion is a result, not a crash: let `exec` return its exit code. + config: type === "exec" && config["allowFailure"] === undefined ? { ...config, allowFailure: true } : config, + input, + workspace, + origin: "verify", + verify: { checkId: check.id, subject: subjectKey(subject), sourceRunId: pass.runId }, + ...(pass.activeStepHashes[type] ? { stepHashes: { [type]: pass.activeStepHashes[type]! } } : {}), + }); + const cost = reportedCost(run.events); + let checkRun: string | undefined; + if (cost > 0) { + // On record exactly like any agent step — and counted against the caps, + // which is how a presumed-free check that turns out to cost money is caught. + await persistRunUnder(store, checkRunKey(check.id), run); + checkRun = run.runId; + pass.result.costUsd += cost; + pass.spentToday.set(subjectKey(subject), (pass.spentToday.get(subjectKey(subject)) ?? 0) + cost); + } + const model = typeof config["model"] === "string" && !config["model"].includes("{{") ? (config["model"] as string) : undefined; + return { + mapped: mapCheckResult(type, run), + // A judgment is not an observation: anything with a model in its closure is asserted. + mode: paidInClosure ? "asserted" : "observed", + checkVersion, + ...(model ? { model } : {}), + ...(checkRun ? { checkRun } : {}), + }; + } + + /** Open (or keep) the ONE planned slot an external check may have per subject. */ + async function openSlot(pass: Pass, o: ObservedSubject, claim: ClaimRow, check: CheckRow, id: string, mine: readonly EvidenceRow[]): Promise { + const runRef = await runRefOf(pass); + if (!runRef) return { skipped: "cannot-launch", reason: "the run could not be projected" }; + const version: VersionRef = { kind: o.subject.kind, name: subjectName(o.subject), content_hash: o.version! }; + const preview = o.check.error ? `error: ${o.check.error.message}` : bound(typeof o.check.output === "string" ? o.check.output : (JSON.stringify(o.check.output) ?? ""), 300); + const written = await writeEvidence({ + id, + claim, + check, + version, + sourceRef: runRef, + node: { + evidence_status: "planned", + description: bound(`${check.description ?? check.name}\n\nLook at: run ${pass.runId} of ${pass.key}, ${o.path}. It produced: ${preview}`), + }, + context: { path: o.path, ...(o.check.cassette ? { cassette: o.check.cassette } : {}) }, + }); + if (written !== "written") return { skipped: "unknown-version" }; + // A slot about an older run is a question whose answer would be born + // stale: mute it (the node holds no observation) — one open slot per check. + for (const old of mine.filter((e) => e.evidence_status === "planned" && e.id !== id)) { + if (old.edge_ref_id) await graph.edges.mute(old.edge_ref_id); + } + pass.result.slots++; + return { planned: id }; + } + + async function verifyOne(pass: Pass, o: ObservedSubject, claim: ClaimRow, check: CheckRow, artifactsDir: string | undefined): Promise { + if (!o.version) return { skipped: "unknown-version" }; + const id = evidenceId(check.id, pass.runId, o.path); + const all = await evidenceOf(pass, claim.id, o.subject); + const mine = all.filter((e) => e.check_id === check.id); + const existing = mine.find((e) => e.id === id); + if (existing) return existing.evidence_status === "planned" ? { planned: id } : { ran: true }; + + const config = (() => { + try { + return check.step_config ? (JSON.parse(check.step_config) as Record) : {}; + } catch { + return {}; + } + })(); + pass.activeStepHashes ??= await workspace.getActiveStepHashes().catch(() => ({})); + const fires = policyFires({ + check, + evidence: mine.filter((e) => e.evidence_status === "collected"), + version: o.version, + checkVersion: isExternalCheck(check) ? "external" : await resolveCheckVersion(check, config, pass.activeStepHashes), + runId: pass.runId, + path: o.path, + explicit: pass.explicit, + now: Date.now(), + }); + if (!fires) return { skipped: "policy" }; + if (isExternalCheck(check)) { + const slot = await openSlot(pass, o, claim, check, id, mine); + await evidenceOf(pass, claim.id, o.subject, true); + return slot; + } + + const ran = await runCheck(pass, check, o.subject, { ...o.check, ...(artifactsDir ? { artifactsDir } : {}) }); + if ("skipped" in ran) return ran; + if (ran.mapped.kind === "cannot-run") return { skipped: "cannot-launch", reason: ran.mapped.reason }; + const runRef = await runRefOf(pass); + if (!runRef) return { skipped: "cannot-launch", reason: "the run could not be projected" }; + const written = await writeEvidence({ + id, + claim, + check, + version: { kind: o.subject.kind, name: subjectName(o.subject), content_hash: o.version }, + sourceRef: runRef, + node: { content: ran.mapped.content, evidence_mode: ran.mode, evidence_status: "collected", observed_at: o.at }, + strength: ran.mapped.supports ? 1 : -1, + context: { + path: o.path, + ...(o.check.cassette ? { cassette: o.check.cassette } : {}), + checkVersion: ran.checkVersion, + ...(ran.model ? { model: ran.model } : {}), + ...(ran.checkRun ? { checkRun: ran.checkRun } : {}), + }, + locator: ran.mapped.locator, + }); + if (written !== "written") return { skipped: "unknown-version" }; + pass.result.evidence++; + await evidenceOf(pass, claim.id, o.subject, true); + return { ran: true }; + } + + async function runPass(key: string, runId: string, explicit: boolean): Promise { + const pass = newPass(key, runId, explicit); + const events = await store.getRunEvents(key, runId); + const launch = events.find((e) => e.type === "run.start"); + if (!launch) return { ...pass.result, skipped: "unknown-run" }; + if (launch.origin === "verify" || key.startsWith("check:")) return { ...pass.result, skipped: "verify-origin" }; + if (!events.some((e) => e.type === "run.end" || e.type === "run.error" || e.type === "run.cancelled")) return { ...pass.result, skipped: "unfinished" }; + + let artifactsDir: string | undefined; + const artifacts = (deps.services() as { artifacts?: { dir(runId: string): Promise } } | undefined)?.artifacts; + for (const o of subjectsOfRun(key, events)) { + const claims = await claimsOf(pass, o.subject); + if (claims.length === 0) continue; + if (!pass.result.subjects.some((s) => sameSubject(s, o.subject))) pass.result.subjects.push(o.subject); + if (artifacts && artifactsDir === undefined) artifactsDir = await artifacts.dir(runId).catch(() => undefined); + for (const claim of claims) { + for (const check of await checksOf(pass, claim.id)) { + if ((check.run_when ?? "run") !== "run") continue; + let lastVerify: LastVerify; + try { + lastVerify = await verifyOne(pass, o, claim, check, artifactsDir); + } catch (err) { + // One broken check must not cost the others their evidence. + lastVerify = { skipped: "cannot-launch", reason: bound(err instanceof Error ? err.message : String(err), 300) }; + } + pass.result.checks.push({ subject: o.subject, path: o.path, claimId: claim.id, checkId: check.id, lastVerify }); + } + } + } + return pass.result; + } + + /** + * Verify one finished run. Idempotent per (check, run, path): `Evidence.id` + * is deterministic and written in `create` mode, so a second pass writes + * nothing and only runs checks that have no Evidence for that (run, path) + * yet — exactly what "re-verify after adding a check" needs. Two passes on + * one run are single-flighted: the second awaits the first. + */ + async function verifyRun(key: string, runId: string, opts: { explicit?: boolean } = {}): Promise { + const explicit = opts.explicit === true; + const running = inflight.get(runId); + if (running) { + const first = await running.promise; + // `manual` checks fire only for an explicit caller — go again for them. + if (!explicit || running.explicit) return first; + } + const promise = runPass(key, runId, explicit).finally(() => { + if (inflight.get(runId)?.promise === promise) inflight.delete(runId); + }); + inflight.set(runId, { explicit, promise }); + return promise; + } + + /** The trigger: a DETACHED pass — `run_workflow` / `run_step` return + * exactly when they did before and never wait for it. */ + function schedule(key: string, runId: string): void { + setImmediate(() => { + verifyRun(key, runId) + .then((r) => { + if (!r.skipped && r.subjects.length > 0) deps.onSettled?.(r); + }) + .catch((err) => console.error(`[verify] pass over ${key}/${runId} failed:`, err)); + }); + } + + /** + * `run_when: publish` checks, over the new version's source. There is no + * run: the Evidence is ABOUT the new version and its source IS that + * version node. (Lints: "no step reads process.env directly".) + */ + async function verifyPublish(subject: SubjectRef): Promise { + const hash = await reader.activeVersion(subject); + const name = subjectName(subject); + const pass = newPass(`publish:${name}`, `publish:${hash ?? "none"}`, true); + const claims = await claimsOf(pass, subject); + if (!hash || claims.length === 0) return pass.result; + const version: VersionRef = { kind: subject.kind, name, content_hash: hash }; + const versionRef = await reader.versionRefId(version); + let input: PublishCheckSubject | null = null; + for (const claim of claims) { + for (const check of await checksOf(pass, claim.id)) { + if (check.run_when !== "publish" || isExternalCheck(check)) continue; + if (!pass.result.subjects.length) pass.result.subjects.push(subject); + const id = evidenceId(check.id, pass.runId, "publish"); + let lastVerify: LastVerify; + try { + if ((await evidenceOf(pass, claim.id, subject)).some((e) => e.id === id)) lastVerify = { ran: true }; + else if (!versionRef) lastVerify = { skipped: "unknown-version" }; + else { + input ??= subject.kind === "step" + ? { source: (await workspace.getStepSource(subject.type))?.code ?? "" } + : { yaml: await workspace.getWorkflowSource(name, (await workspace.getWorkflowMetadata(name))?.active ?? "") }; + const ran = await runCheck(pass, check, subject, input); + if ("skipped" in ran) lastVerify = ran; + else if (ran.mapped.kind === "cannot-run") lastVerify = { skipped: "cannot-launch", reason: ran.mapped.reason }; + else { + await writeEvidence({ + id, + claim, + check, + version, + sourceRef: versionRef, + node: { content: ran.mapped.content, evidence_mode: ran.mode, evidence_status: "collected", observed_at: new Date().toISOString() }, + strength: ran.mapped.supports ? 1 : -1, + context: { path: "publish", checkVersion: ran.checkVersion, ...(ran.model ? { model: ran.model } : {}), ...(ran.checkRun ? { checkRun: ran.checkRun } : {}) }, + locator: ran.mapped.locator, + }); + pass.result.evidence++; + lastVerify = { ran: true }; + } + } + } catch (err) { + lastVerify = { skipped: "cannot-launch", reason: bound(err instanceof Error ? err.message : String(err), 300) }; + } + pass.result.checks.push({ subject, path: "publish", claimId: claim.id, checkId: check.id, lastVerify }); + } + } + return pass.result; + } + + /** + * Someone's own observation (`add_evidence`): `asserted`, sourced to the + * run, with NO `PRODUCED_BY` — no check produced it. When an open slot + * exists for (claim, run) — or `slot` names one — it FILLS that slot + * instead of writing a second node: the same node becomes `collected`, + * its edge gets a strength, and `by` is recorded on the source. + */ + async function addEvidence(input: AddEvidenceInput): Promise<{ ok: true; evidence: string; filled: boolean; subject: SubjectRef } | { error: string }> { + const content = input.content?.trim(); + if (!content) return { error: "content is empty — say WHAT you observed, and with which tool" }; + const claim = await reader.getClaim(input.claim); + if (!claim) return { error: `claim "${input.claim}" not found` }; + if (claim.belief_valid_to !== undefined) return { error: `claim "${input.claim}" is retired or superseded — list_claims shows the active ones` }; + const events = await store.getRunEvents(input.name, input.runId); + if (events.length === 0) return { error: `run ${input.runId} of "${input.name}" not found` }; + if (events.find((e) => e.type === "run.start")?.origin === "verify") return { error: "that is a check run — cite the run it verified" }; + + const about = (await reader.subjectsOf(claim.id)).map((s) => s.subject); + const observed = subjectsOfRun(input.name, events).filter( + (o) => o.version && about.some((s) => sameSubject(s, o.subject)) && (!input.subject || sameSubject(input.subject, o.subject)), + ); + const distinct = [...new Map(observed.map((o) => [subjectKey(o.subject), o])).values()]; + if (distinct.length === 0) { + return { error: `run ${input.runId} did not execute a subject of this claim with a recorded version (${about.map(subjectKey).join(", ") || "no subjects"}) — evidence must be about a version that actually ran` }; + } + if (distinct.length > 1) return { error: `that run executed several of this claim's subjects (${distinct.map((o) => subjectKey(o.subject)).join(", ")}) — pass \`subject\`` }; + const o = distinct[0]!; + const mode: EvidenceMode = input.mode ?? "asserted"; + const strength = input.supports ? 1 : -1; + const now = new Date().toISOString(); + + const evidence = await reader.evidenceFor(claim.id, o.subject); + const slot = input.slot + ? evidence.find((e) => e.id === input.slot) + : evidence.find((e) => e.evidence_status === "planned" && e.source?.run_id === input.runId); + if (input.slot && (!slot || slot.evidence_status !== "planned")) return { error: `"${input.slot}" is not an open slot on this claim` }; + if (slot) { + await graph.nodes.update(slot.ref_id, { set: { content: bound(content), evidence_status: "collected", evidence_mode: mode, observed_at: now } }); + await graph.edges.update({ ref_id: slot.edge_ref_id! }, { set: { strength } }); + if (slot.source) { + await graph.edges.update( + { edge: CLAIM_EDGES.HAS_SOURCE, source_ref_id: slot.ref_id, target_ref_id: slot.source.ref_id }, + { set: { context: JSON.stringify({ ...(slot.source.context ?? {}), by: input.by }) } }, + ); + } + return { ok: true, evidence: slot.id, filled: true, subject: o.subject }; + } + + const runRef = await projectRun(graph, store, input.name, input.runId); + if (!runRef) return { error: `run ${input.runId} could not be projected into the graph` }; + const id = newEpistemicId(); + const written = await writeEvidence({ + id, + claim, + version: { kind: o.subject.kind, name: subjectName(o.subject), content_hash: o.version! }, + sourceRef: runRef, + node: { content: bound(content), evidence_mode: mode, evidence_status: "collected", observed_at: now }, + strength, + context: { path: o.path, by: input.by, ...(o.check.cassette ? { cassette: o.check.cassette } : {}) }, + }); + if (written !== "written") return { error: `the version that run executed (${o.version}) is not in the graph` }; + return { ok: true, evidence: id, filled: false, subject: o.subject }; + } + + return { reader, verifyRun, verifyPublish, addEvidence, schedule }; +} + +/** `step:` for a step subject's kept runs; the workflow name otherwise. */ +export function runKeySubject(key: string): SubjectRef { + const type = stepTypeOfRunKey(key); + return type ? { kind: "step", type } : { kind: "workflow", name: key }; +} From 7182c5da05acd7b24a4efefebba9792a7c332ce7 Mon Sep 17 00:00:00 2001 From: Evanfeenstra Date: Thu, 17 Sep 2026 12:40:22 -0700 Subject: [PATCH 05/11] claims step 5: the ledger in tool results + [verify-notification] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The forcing function (plans/claims.md §5): the builder reads what its work is claimed to do, and what the evidence says, in the RESULT of the call it just made — not in an instruction it can rationalize past. - ledger.ts: buildLedger — per subject, each claim's computed status, assertedOnly, unverified, latest evidence, and every check's lastVerify (pending | ran | skipped: policy|budget|cannot-launch|unknown-version|denied | planned). A foreach's several outcomes for one check read as the most informative one; workflow and step claims are listed separately (no roll-up) - run_workflow / run_step results carry `claims` with every RUN check `pending` (the contract of what the launch can execute: the workflow, its nested children, every step type in reach); verify_run returns the settled ledger directly - ai/verify-waker.ts: the chat that launched a run is woken with its verdict. A [verify-notification] when the pass settles — or, when it settles within 5s of the run (free checks take well under a second), the ledger rides on the run's own [run-notification] and no second wake-up is spent. Exactly one of the two ever carries it; queue-and-drain and the autoTurns cap apply unchanged. Runs nobody watches just get their evidence written. The synchronous results still never wait, as specified - verifier.costOf(subject): what keeping a subject's claims true has cost, from the run store alone; shown as verifyCostUsd on get_step / list_claims - prompt rules 5-6: not done while a claim is unknown or refuted; finish the turn after launching and act on the notification; cannot-launch means fix the CHECK; a `planned` check is a question — answer it only with something observed with a tool, else relay it to the user and stop looping - the chat flyout renders [verify-notification] as a notice --- AGENTS.md | 23 +++++ package.json | 2 +- plans/claims.md | 13 ++- src/ai/prompts.ts | 8 +- src/ai/tools.ts | 52 ++++++++-- src/ai/verify-waker.test.ts | 93 ++++++++++++++++++ src/ai/verify-waker.ts | 83 ++++++++++++++++ src/createStrut.ts | 39 +++++--- src/graph/verify.test.ts | 62 ++++++++++++ src/index.ts | 13 +++ src/ledger.test.ts | 114 ++++++++++++++++++++++ src/ledger.ts | 157 ++++++++++++++++++++++++++++++ src/verify.ts | 40 +++++++- web/src/components/ChatFlyout.tsx | 10 +- 14 files changed, 678 insertions(+), 31 deletions(-) create mode 100644 src/ai/verify-waker.test.ts create mode 100644 src/ai/verify-waker.ts create mode 100644 src/ledger.test.ts create mode 100644 src/ledger.ts diff --git a/AGENTS.md b/AGENTS.md index f921493..59f62aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,7 @@ strut/ │ ├── claims-authoring.ts # the policy layer behind BOTH claim doors (chat tools + meta/* twins): check-spec validation + write-time defaults (presumed-paid → on_change), the additive `claims` publish arg, publisher scoping (fixed point 1), the grader deny-list over the check closure (fixed point 2; STRUT_VERIFY_DENY) │ ├── claims-schemas.ts # zod shapes + model-facing docs for subjects / check specs / the `claims` arg, shared by ai/tools.ts and the meta/* claim steps │ ├── verify.ts # the verify pass (plans/claims.md §4): subjectsOfRun (a run's event log → observed subjects + the version each executed), mapCheckResult (the check contract; a check that cannot run writes NOTHING), policyFires (always / on_change / sample / manual), budget (presumed-paid skipped at a cap; reported cost persisted under `check:` and counted), planned slots for external checks, addEvidence, verifyPublish. Triggered from `services.onRunEnd` for every top-level run and after a kept run_step; check runs (`origin: "verify"`) are never verified +│ ├── ledger.ts # the ledger (plans/claims.md §5): buildLedger (claims per subject with computed status + each check's lastVerify: pending | ran | skipped | planned), subjectsOfFlow (what a launch can execute), the [verify-notification] text. The forcing function — the model reads its contract in a tool RESULT, not an instruction │ ├── closure.ts # what a flow can EXECUTE: walkSteps (loop/foreach bodies, onError), flowClosure (nested subflows via the workspace, agentTools grants; templated/missing child → unresolvable), stepHashesFor → run.start.stepHashes │ ├── run-step.ts # runSingleStep (one step, in memory, optional cassette) + runStep — the run_step surfaces: records stepHashes, then persists the run under `step:` only when the step has claims or `keep: true` (plans/claims.md §3) │ ├── chat-store.ts # ChatStore interface + FileChatStore + MemoryChatStore (chats//: meta.json + messages.jsonl + events.jsonl) + truncateToolMessages @@ -723,6 +724,28 @@ and the child env is scrubbed by construction). `streamChat` + `getChat`) persists the active `chatId` in localStorage and reattaches to a still-live turn on reopen. +- **Claims, checks, evidence — the truth layer** (`plans/claims.md`; graph + workspaces only — on `STRUT_WORKSPACE_BACKEND=fs` no claim tool is + offered, `strut.claims` / `strut.verifier` are null, and nothing below + runs). A `Claim` states how a step or workflow should BEHAVE, a `Check` + is an instrument that tests it (a registry step run over the subject, or + an external check answered through a planned slot), `Evidence` is what + one check observed on one run — all three are jarvis types, written + through the ordinary node/edge writers. Status (`supported | refuted | + stale | unknown`) is COMPUTED ON READ per (claim, subject) by + `claimStatus()` and never stored. Authoring: the `claims` arg on the + publish tools + `add_claim` / `edit_claim` / … and their `meta/*` twins + (`src/claims-authoring.ts`). Evidence: every top-level run is verified, + detached, by `src/verify.ts`, hooked where `services.onRunEnd` fires; + check runs carry `origin: "verify"` and are never verified. The builder + reads its contract in tool RESULTS (`src/ledger.ts`): run results list + the claims `pending`, and a `[verify-notification]` (or the run's + `[run-notification]`, when the pass settles within 5 s — + `src/ai/verify-waker.ts`) starts the next turn with each claim's status. + Versions are recorded, never inferred: `run.start.stepHashes` / + `workflowHash`, and a subflow step's `step.start.subflow` — no record, no + evidence. + - **Dispatch-mode `run_workflow` + run notifications** (`src/ai/notifier.ts`, `plans/dispatch-run-notifications.md`). The chat agent's `run_workflow` tool races the run against a wait window diff --git a/package.json b/package.json index a05eb4c..1db7727 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "package:desktop": "node scripts/package-desktop.mjs", "dev": "npm run build:web && tsx --env-file=.env src/server.ts", "start": "node build/server.js", - "test": "tsx --test src/expr.test.ts src/core.test.ts src/runner.test.ts src/run-control.test.ts src/control-flow.test.ts src/store.test.ts src/workspace.test.ts src/storage-conformance.test.ts src/integration.test.ts src/services.test.ts src/cassette.test.ts src/run-step.test.ts src/closure.test.ts src/verify.test.ts src/createStrut.test.ts src/authoring.test.ts src/ai-integration.test.ts src/chat-store.test.ts src/chat-endpoints.test.ts src/llm.test.ts src/pricing.test.ts src/steps/registry.test.ts src/steps/core/agent.test.ts src/steps/core/pack.test.ts src/steps/core/exec.test.ts src/steps/core/llm.test.ts src/auth.test.ts src/secret-store.test.ts src/artifacts.test.ts src/slack.test.ts src/gdrive.test.ts src/html-extract.test.ts src/shell.test.ts src/validate.test.ts src/model-dir.test.ts src/audio/hotwords.test.ts src/audio/stt.test.ts src/audio/ws.test.ts web/src/run-inputs.test.ts", + "test": "tsx --test src/expr.test.ts src/core.test.ts src/runner.test.ts src/run-control.test.ts src/control-flow.test.ts src/store.test.ts src/workspace.test.ts src/storage-conformance.test.ts src/integration.test.ts src/services.test.ts src/cassette.test.ts src/run-step.test.ts src/closure.test.ts src/verify.test.ts src/ledger.test.ts src/ai/verify-waker.test.ts src/createStrut.test.ts src/authoring.test.ts src/ai-integration.test.ts src/chat-store.test.ts src/chat-endpoints.test.ts src/llm.test.ts src/pricing.test.ts src/steps/registry.test.ts src/steps/core/agent.test.ts src/steps/core/pack.test.ts src/steps/core/exec.test.ts src/steps/core/llm.test.ts src/auth.test.ts src/secret-store.test.ts src/artifacts.test.ts src/slack.test.ts src/gdrive.test.ts src/html-extract.test.ts src/shell.test.ts src/validate.test.ts src/model-dir.test.ts src/audio/hotwords.test.ts src/audio/stt.test.ts src/audio/ws.test.ts web/src/run-inputs.test.ts", "test:stt": "STRUT_TEST_STT=1 tsx --test src/audio/stt.live.test.ts", "test:graph": "tsx --test --test-concurrency=1 \"src/graph/*.test.ts\" \"src/steps/lib/graph/*.test.ts\"" }, diff --git a/plans/claims.md b/plans/claims.md index c60b128..55100b7 100644 --- a/plans/claims.md +++ b/plans/claims.md @@ -915,7 +915,18 @@ number exists). verify-origin guard) + `add_evidence` + `meta/verify-run` + `meta/add-evidence` (§4, §6); planned slots — open in the pass for external checks, fill through `add_evidence` (§4.2). -5. Ledger in run results + the `[verify-notification]` through the +5. **Done** — `src/ledger.ts`, `src/ai/verify-waker.ts`; `run_workflow` / + `run_step` results carry `claims` with every run check `pending`; + `verify_run` returns the ledger; `[verify-notification]` wakes the chat + that launched the run (the flyout renders it as a notice); prompt rules + 5–6; `verifyCostUsd` on `get_step` / `list_claims`. Decided while + building: a `[run-notification]` WAITS up to 5 s for its run's verify + pass, so one wake-up turn carries the run and the ledger (exactly one of + the two notifications ever carries it) — machine-triggered turns are + capped, and two per run would park a build loop twice as fast. The + SYNCHRONOUS results still never wait, as specified; whether a short + grace there is worth it is a call for after the youtube-clip rerun. + Ledger in run results + the `[verify-notification]` through the notifier (§5). 6. Harness wiring (§6) — in the **stakgraph** repo (`mcp/src/lab`), after strut 3–5 are released there: seed the contract claims on `gaia-produce`, diff --git a/src/ai/prompts.ts b/src/ai/prompts.ts index f9918ed..eb2d5cc 100644 --- a/src/ai/prompts.ts +++ b/src/ai/prompts.ts @@ -44,6 +44,10 @@ export interface AiDeps { * after a publish, and verifies kept `run_step` runs. Optional: without it * claims can still be authored, but nothing produces evidence. */ verifier?: import("../verify.js").Verifier | null; + /** "This chat launched run `runId` and wants its verdict": the host wakes + * the chat with a `[verify-notification]` when that run's verify pass + * settles. Optional: without it the evidence is still written. */ + watchVerify?: (runId: string) => void; /** Web tools for the builder — `web_search` + `web_fetch` (the same pair * the agent step ships): built per turn by createStrut for the chat's * resolved provider via `createWebTools` (src/llm.ts; native on @@ -285,7 +289,9 @@ A claim is ONE plain sentence about how a step or workflow should BEHAVE; a chec 2. Behavior, not mechanism, and never the output schema restated. Claim the thing the user actually cares about ("the clip's audio contains the requested quote"), not what is easy to check ("the clip is 20 seconds long"). 3. Every claim gets at least one check. Prefer code that OBSERVES the output (an \`exec\` script, a custom step, a \`subflow\` for anything bigger than a one-liner — e.g. speech-to-text the clip, then fuzzy-match the quote): it is free, so it runs on every input. Use an \`llm\` / \`agent\` check only for judgment calls — it costs money and is recorded as asserted, not observed. If nothing can check it, give it an EXTERNAL check whose description says what to look at and why code cannot. 4. A failure you fix becomes a claim with a check — the regression move: the 429 on auto-translated captions becomes "fetches only the requested caption languages". Otherwise the next session rediscovers it. -Tools: add_claim, list_claims (claims + checks + computed status), edit_claim / edit_check (immutable nodes: an edit creates a successor and returns ITS id — the claim reads unknown until verified again), retire_claim / retire_check, attach_claim / detach_claim (share one contract across subjects instead of copying it), add_check.`; +5. Work is NOT done while any claim is unknown or refuted. run_workflow / run_step results list the contract under \`claims\` with every check \`pending\`: the checks run detached, so finish your turn after launching — a "[verify-notification]" (or the run's "[run-notification]") will start your next turn with each claim's status and its check's \`lastVerify\`. Refuted → fix the step (or the check, if the check is wrong) and run again. \`skipped: cannot-launch\` means the CHECK is broken — read its \`reason\` and fix it with edit_check; a broken check is never a pass. \`stale\` just needs a run on the current version. Evidence comes from inputs: run more than one. Tell the user plainly when a claim is only \`assertedOnly\`. +6. A check whose lastVerify is \`planned\` is a QUESTION waiting on someone. Answer it with add_evidence only if you OBSERVED the answer with a tool, and say what you saw; otherwise relay it to the user — what to look at, and where — and end your turn. A claim waiting on a person does not keep you looping: the work is "done, not yet verified", and you say which lines are waiting. +Tools: verify_run (re-verify a run after changing a claim or check; returns the ledger), add_evidence (your own tool-backed observation — stored as asserted), add_claim, list_claims (claims + checks + computed status), edit_claim / edit_check (immutable nodes: an edit creates a successor and returns ITS id — the claim reads unknown until verified again), retire_claim / retire_check, attach_claim / detach_claim (share one contract across subjects instead of copying it), add_check.`; export async function buildSystem(deps: AiDeps): Promise { const tree = await renderStepsTree(deps); diff --git a/src/ai/tools.ts b/src/ai/tools.ts index bb6c27b..5004c58 100644 --- a/src/ai/tools.ts +++ b/src/ai/tools.ts @@ -9,6 +9,7 @@ import { stepHashesFor } from "../closure.js"; import { claimsReaderFor } from "../graph/claims.js"; import { buildClaimsAuthoring, type ClaimActor } from "../claims-authoring.js"; import { checkSpecSchema, claimsArgSchema, subjectSchema } from "../claims-schemas.js"; +import { ledgerIsEmpty, subjectsOfFlow } from "../ledger.js"; import { generateRunId, stepRunKey } from "../store.js"; import { formatValidationErrors, validateWorkflowYaml } from "../validate.js"; // The shared authoring core — the same mechanism the meta/* steps' capability @@ -86,6 +87,21 @@ export function buildTools(deps: AiDeps) { const actor: ClaimActor = { publisher: AI_PUBLISHER, scoped: false }; const claimsArg = claims ? { claims: claimsArgSchema } : {}; const verifier = claims ? (deps.verifier ?? null) : null; + /** The contract of what a launch can execute, every check `pending` — so + * the model reads what its work is claimed to do in the RESULT of the run + * it just made, and knows a verdict is coming (plans/claims.md §5). */ + const pendingContract = async (flow: Parameters[0], workflowName?: string) => { + if (!verifier) return {}; + try { + const ledger = await verifier.pendingLedger(await subjectsOfFlow(flow, deps.workspace, workflowName)); + return ledgerIsEmpty(ledger) + ? {} + : { claims: ledger, verify: "pending — the checks run now, detached; a [verify-notification] will start your next turn with each claim's status. Finish this turn normally." }; + } catch { + return {}; // the run's result never depends on the graph being reachable + } + }; + /** `run_when: publish` checks fire at the end of a publish; their verdicts * ride along on the result. */ const publishChecks = async (kind: "step" | "workflow", name: string) => { @@ -141,6 +157,7 @@ export function buildTools(deps: AiDeps) { return { error: `Step type "${type}" not found` }; } const recentRuns = (await deps.store.listRuns(stepRunKey(type))).length; + const verifyCostUsd = deps.verifier ? await deps.verifier.costOf({ kind: "step", type }).catch(() => 0) : 0; return { type, description: def.description, @@ -148,6 +165,8 @@ export function buildTools(deps: AiDeps) { ...(source ? { source: (await readStepSource(type, deps)) ?? null } : {}), // Kept single-step runs: list_runs / get_run on the key `step:`. ...(recentRuns ? { recentRuns } : {}), + // What this step's paid checks have cost so far — what its claims cost to keep true. + ...(verifyCostUsd > 0 ? { verifyCostUsd } : {}), }; }, }), @@ -371,7 +390,12 @@ export function buildTools(deps: AiDeps) { description: "A subject's active claims, each with its checks (id, step type + config or external description, when/policy) and its status COMPUTED from evidence: supported | refuted | stale (evidence is about an older version) | unknown (never checked). `assertedOnly` = the verdict rests on a model's or person's word, nothing observed; `unverified` = active checks with no evidence about the active version; `openSlot` = an external check is waiting on someone.", inputSchema: z.object({ subject: subjectSchema }), - execute: async ({ subject }) => claims.listClaims(subject), + execute: async ({ subject }) => { + const listing = await claims.listClaims(subject); + if (!("ok" in listing) || !verifier) return listing; + const verifyCostUsd = await verifier.costOf(subject.kind === "step" ? { kind: "step", type: subject.name } : { kind: "workflow", name: subject.name }).catch(() => 0); + return verifyCostUsd > 0 ? { ...listing, verifyCostUsd } : listing; + }, }), edit_claim: tool({ @@ -423,12 +447,16 @@ export function buildTools(deps: AiDeps) { ? { verify_run: tool({ description: - "Verify a finished run NOW and wait for it: run the checks of every claim on the subjects the run executed, and write the evidence. Runs are verified automatically after they finish, so use this to RE-verify — after adding or editing a claim or check (only checks with no evidence for this run yet execute; it never duplicates), to backfill an older run, or to fire `manual` checks. `name` is the workflow, or `step:` for a kept run_step run. Returns per-check lastVerify: { ran } | { skipped: policy | budget | cannot-launch | unknown-version | denied, reason? } | { planned: } — then list_claims for the statuses.", + "Verify a finished run NOW and wait for it: run the checks of every claim on the subjects the run executed, and write the evidence. Runs are verified automatically after they finish, so use this to RE-verify — after adding or editing a claim or check (only checks with no evidence for this run yet execute; it never duplicates), to backfill an older run, or to fire `manual` checks. `name` is the workflow, or `step:` for a kept run_step run. Returns `claims` — the ledger: every claim on those subjects with its computed status and each check's lastVerify: { ran } | { skipped: policy | budget | cannot-launch | unknown-version | denied, reason? } | { planned: }.", inputSchema: z.object({ name: z.string().describe("Workflow name, or `step:` for a kept single-step run"), runId: z.string(), }), - execute: async ({ name, runId }) => verifier.verifyRun(name, runId, { explicit: true }), + execute: async ({ name, runId }) => { + const result = await verifier.verifyRun(name, runId, { explicit: true }); + if (result.skipped) return result; + return { ...result, claims: await verifier.ledger(result) }; + }, }), add_evidence: tool({ @@ -603,6 +631,10 @@ export function buildTools(deps: AiDeps) { // Register with the host's controller registry (when wired) so the // run is cancellable/pausable and lists as live from launch. const tracked = deps.trackRun?.(name, runId); + // This chat wants the verdict: the verify pass that follows the run + // wakes it with a [verify-notification]. + if (verifier) deps.watchVerify?.(runId); + const contract = await pendingContract(flow, name); const promise = runWorkflow(flow, coerceJsonArg(input) ?? {}, deps.registry, { runId, store: deps.store, @@ -617,7 +649,7 @@ export function buildTools(deps: AiDeps) { // No detach seam (tests / non-chat embedders) → await as before. const detach = deps.detach; - if (!detach) return promise; + if (!detach) return { ...(await promise), ...contract }; // Dispatch mode: race the run against the wait window. Fast runs // return synchronously (the quick inner-loop path); a run that @@ -631,13 +663,14 @@ export function buildTools(deps: AiDeps) { timer = setTimeout(() => res(pending), detach.waitMs); }), ]).finally(() => clearTimeout(timer)); - if (winner !== pending) return winner; + if (winner !== pending) return { ...winner, ...contract }; detach.onDetach({ workflow: name, runId, startedAt, promise }); return { status: "running", detached: true, runId, + ...contract, workflow: name, note: `Run still executing after ${Math.round(detach.waitMs / 1000)}s — it continues detached in the background. ` + @@ -682,7 +715,7 @@ export function buildTools(deps: AiDeps) { if (cassette && !deps.dataDir) { return { error: "Cassette record/replay is unavailable (no local data dir configured)." }; } - return runStep( + const result = await runStep( type, registry, deps.services, @@ -700,9 +733,14 @@ export function buildTools(deps: AiDeps) { store: deps.store, workspace: deps.workspace, claims: claimsReaderFor(deps.workspace), - onKept: (key, runId) => verifier?.schedule(key, runId), + onKept: (key, runId) => { + if (verifier) deps.watchVerify?.(runId); + verifier?.schedule(key, runId); + }, }, ); + // A kept run is being verified: show the step's contract, pending. + return result.kept ? { ...result, ...(await pendingContract({ name: type, steps: [{ id: "step", type, config: {} }] })) } : result; }, }), diff --git a/src/ai/verify-waker.test.ts b/src/ai/verify-waker.test.ts new file mode 100644 index 0000000..dcddbd4 --- /dev/null +++ b/src/ai/verify-waker.test.ts @@ -0,0 +1,93 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { Ledger } from "../ledger.js"; +import type { VerifyResult } from "../verify.js"; +import { STILL_VERIFYING, createVerifyWaker } from "./verify-waker.js"; + +const LEDGER: Ledger = { "clip/compute-times": [{ id: "c1", text: "end is after start", status: "refuted", assertedOnly: false, unverified: 0, checks: [{ id: "k1", name: "bounds", lastVerify: { ran: true } }] }] }; +const result = (over: Partial = {}): VerifyResult => ({ key: "clipper", runId: "r1", subjects: [{ kind: "step", type: "clip/compute-times" }], checks: [], evidence: 1, slots: 0, costUsd: 0, ...over }); + +function setup(opts: { passMs?: number; graceMs?: number; ledger?: Ledger; pass?: VerifyResult } = {}) { + const delivered: Array<[string, string]> = []; + let calls = 0; + const waker = createVerifyWaker({ + graceMs: opts.graceMs ?? 200, + deliver: async (chatId, text) => void delivered.push([chatId, text]), + verifier: { + verifyRun: async () => { + calls++; + await new Promise((r) => setTimeout(r, opts.passMs ?? 5)); + return opts.pass ?? result(); + }, + ledger: async () => opts.ledger ?? LEDGER, + }, + }); + return { waker, delivered, calls: () => calls }; +} + +describe("verify waker", () => { + it("a settled pass wakes the chat that launched the run — once, with the ledger", async () => { + const { waker, delivered } = setup(); + waker.watch("r1", "chat-A"); + await waker.settled(result()); + assert.equal(delivered.length, 1); + assert.equal(delivered[0]![0], "chat-A"); + assert.match(delivered[0]![1], /^\[verify-notification\] Run r1 of "clipper".*1 REFUTED/); + await waker.settled(result()); + assert.equal(delivered.length, 1, "the watch is consumed"); + }); + + it("nobody watching (an API / UI launch), a no-op pass, or an empty contract → no wake-up", async () => { + const quiet = setup(); + await quiet.waker.settled(result()); + assert.deepEqual(quiet.delivered, []); + + for (const pass of [result({ skipped: "verify-origin" }), result({ subjects: [] })]) { + const s = setup(); + s.waker.watch("r1", "chat-A"); + await s.waker.settled(pass); + assert.deepEqual(s.delivered, []); + assert.equal(await s.waker.ledgerLinesFor("clipper", "r1"), "", "and the watch was released, not leaked"); + } + const empty = setup({ ledger: {} }); + empty.waker.watch("r1", "chat-A"); + await empty.waker.settled(result()); + assert.deepEqual(empty.delivered, []); + }); + + it("a run-notification carries the ledger when the pass settles inside the grace window — and then no verify-notification follows", async () => { + const { waker, delivered } = setup({ passMs: 10, graceMs: 500 }); + waker.watch("r1", "chat-A"); + const lines = await waker.ledgerLinesFor("clipper", "r1"); + assert.match(lines, /^\nVerified against its claims — clip\/compute-times: 1 REFUTED\.\nclaims: \{/); + await waker.settled(result()); // the detached pass's own hook fires too + assert.deepEqual(delivered, [], "exactly ONE of the two carries the ledger"); + }); + + it("a slow pass: the run-notification says verification is still running, and the verify-notification follows", async () => { + const { waker, delivered } = setup({ passMs: 120, graceMs: 20 }); + waker.watch("r1", "chat-A"); + assert.equal(await waker.ledgerLinesFor("clipper", "r1"), `\n${STILL_VERIFYING}`); + await waker.settled(result()); + assert.equal(delivered.length, 1); + assert.match(delivered[0]![1], /^\[verify-notification\]/); + }); + + it("if the detached pass's hook wins the race, the run-notification adds nothing (no duplicate ledger)", async () => { + const { waker, delivered } = setup({ passMs: 40, graceMs: 500 }); + waker.watch("r1", "chat-A"); + const lines = waker.ledgerLinesFor("clipper", "r1"); + await waker.settled(result()); // settles while ledgerLinesFor is still awaiting the pass + assert.equal(await lines, ""); + assert.equal(delivered.length, 1); + }); + + it("an unwatched run adds nothing to its run-notification and starts no pass; a failing pass never breaks the notification", async () => { + const { waker, calls } = setup(); + assert.equal(await waker.ledgerLinesFor("clipper", "r1"), ""); + assert.equal(calls(), 0); + const broken = createVerifyWaker({ deliver: async () => {}, verifier: { verifyRun: async () => Promise.reject(new Error("bolt down")), ledger: async () => LEDGER } }); + broken.watch("r1", "chat-A"); + assert.equal(await broken.ledgerLinesFor("clipper", "r1"), ""); + }); +}); diff --git a/src/ai/verify-waker.ts b/src/ai/verify-waker.ts new file mode 100644 index 0000000..23628a7 --- /dev/null +++ b/src/ai/verify-waker.ts @@ -0,0 +1,83 @@ +/** + * Wakes a chat with the verdict on a run it launched (`plans/claims.md` §5). + * + * The verify pass is detached, so its result reaches the builder the same + * way a long run's does — as a user-role message that starts its next turn: + * + * - a `[verify-notification]` when the pass settles; or + * - folded into the run's own `[run-notification]` when the pass settles + * within a short grace window (free checks take well under a second), + * so ONE wake-up turn carries the run and the ledger. Machine-triggered + * turns are capped (`autoTurns`); spending two per run would park a + * build loop twice as fast. + * + * Exactly one of the two carries the ledger: whoever takes the run's entry + * out of the watch map delivers it. Queue-and-drain is the notifier's — a + * pass that settles while a turn is live lands in that turn's wake-up. + * Runs nobody watches (launched from the API or UI) just get their evidence + * written; the panel shows it. + */ +import { formatLedgerLines, formatVerifyNotification, ledgerIsEmpty } from "../ledger.js"; +import type { Verifier, VerifyResult } from "../verify.js"; + +export interface VerifyWaker { + /** Chat `chatId` launched run `runId` and wants its verdict. */ + watch(runId: string, chatId: string): void; + /** The verifier's `onSettled`: a detached pass finished. */ + settled(result: VerifyResult): Promise; + /** Lines to append to a `[run-notification]` for this run: the ledger when + * the pass settles inside the grace window, a "still running" note when + * it does not, nothing when there is no contract (or nobody is watching). */ + ledgerLinesFor(workflow: string, runId: string): Promise; +} + +export const STILL_VERIFYING = "Verification against its claims is still running — a [verify-notification] follows."; + +export function createVerifyWaker(opts: { + verifier: Pick; + deliver: (chatId: string, text: string) => Promise; + graceMs?: number; +}): VerifyWaker { + const watchers = new Map(); + const graceMs = opts.graceMs ?? 5_000; + const hasContract = (r: VerifyResult) => !r.skipped && r.subjects.length > 0; + + return { + watch(runId, chatId) { + watchers.set(runId, chatId); + }, + + async settled(r) { + const chatId = watchers.get(r.runId); + if (!chatId) return; + watchers.delete(r.runId); + if (!hasContract(r)) return; + try { + const ledger = await opts.verifier.ledger(r); + if (!ledgerIsEmpty(ledger)) await opts.deliver(chatId, formatVerifyNotification({ workflow: r.key, runId: r.runId, ledger, costUsd: r.costUsd })); + } catch (err) { + console.error(`[chat ${chatId}] verify-notification delivery failed:`, err); + } + }, + + async ledgerLinesFor(workflow, runId) { + if (!watchers.has(runId)) return ""; + try { + let timer: ReturnType | undefined; + const result = await Promise.race([ + opts.verifier.verifyRun(workflow, runId), // joins the detached pass (single-flight) + new Promise((res) => { + timer = setTimeout(() => res(null), graceMs); + }), + ]).finally(() => clearTimeout(timer)); + if (!result) return `\n${STILL_VERIFYING}`; // still watched: `settled` will deliver + if (!watchers.delete(runId)) return ""; // `settled` got there first and delivered + if (!hasContract(result)) return ""; + const ledger = await opts.verifier.ledger(result); + return ledgerIsEmpty(ledger) ? "" : `\n${formatLedgerLines(ledger)}`; + } catch { + return ""; + } + }, + }; +} diff --git a/src/createStrut.ts b/src/createStrut.ts index eafe573..677a8bc 100644 --- a/src/createStrut.ts +++ b/src/createStrut.ts @@ -37,6 +37,7 @@ import { import { runStep, cassettePath, RUN_STEP_FLOW } from "./run-step.js"; import { createVerifier, type Verifier, type VerifyResult } from "./verify.js"; import { CLAIMS_OFF } from "./claims-schemas.js"; +import { createVerifyWaker, type VerifyWaker } from "./ai/verify-waker.js"; import type { RunEndInfo } from "./runner.js"; import { stepHashesFor } from "./closure.js"; import { buildAuthoringCapability } from "./authoring.js"; @@ -467,6 +468,9 @@ export async function createStrut( // must be verified too. Always detached; a consumer's own onRunEnd // (per-run teardown) still runs first. let verifySettled: ((r: VerifyResult) => void) | undefined; + // Wakes the chat that launched a run with its verdict — built with the chat + // block (it needs the notifier); without chat, evidence is just written. + let verifyWaker: VerifyWaker | undefined; const verifier = workspace.graph ? createVerifier({ graph: workspace.graph, @@ -1546,6 +1550,15 @@ export async function createStrut( launchChatTurn(chatId, turn, modelMessages), }); + // The verify pass settled for a run this chat launched: wake it with the + // ledger (plans/claims.md §5; ai/verify-waker.ts). Queue-and-drain applies + // unchanged, and `autoTurns` counts it like any machine-triggered turn, so + // the park limit holds. + if (verifier) { + verifyWaker = createVerifyWaker({ verifier, deliver: (chatId, text) => notifier.deliver(chatId, text) }); + verifySettled = (r) => void verifyWaker!.settled(r); + } + /** * Run one chat turn detached: build the agent, stream it server-side, * persist each fine-grained part to `events.jsonl`, then append the new @@ -1608,6 +1621,7 @@ export async function createStrut( graph: opts.graph, // verify_run / add_evidence + the verify triggers (graph workspaces only). verifier, + watchVerify: (runId: string) => verifyWaker?.watch(runId, chatId), // cancel_run / pause_run / resume_run over the live controllers. controlRun: controlRunForChat, publishingEnabled: !registryWasInjected, @@ -1640,18 +1654,19 @@ export async function createStrut( }) => { promise .then( - (res) => - notifier.deliver( - chatId, - formatRunNotification({ - workflow, - runId, - status: res.status, - durationMs: Date.now() - startedAt, - output: res.output, - ...(res.error ? { error: res.error } : {}), - }), - ), + async (res) => { + const text = formatRunNotification({ + workflow, + runId, + status: res.status, + durationMs: Date.now() - startedAt, + output: res.output, + ...(res.error ? { error: res.error } : {}), + }); + // One wake-up turn carries the run AND its ledger when the + // verify pass settles quickly; else a [verify-notification] follows. + return notifier.deliver(chatId, `${text}${(await verifyWaker?.ledgerLinesFor(workflow, runId)) ?? ""}`); + }, // runWorkflow finalizes its own errors into a resolved // result; a rejection here is an unexpected throw (e.g. // store write failure) — still wake the chat with it. diff --git a/src/graph/verify.test.ts b/src/graph/verify.test.ts index 87117ac..0da6ea5 100644 --- a/src/graph/verify.test.ts +++ b/src/graph/verify.test.ts @@ -341,6 +341,9 @@ describe("verify pass (live Neo4j)", { skip: cfg ? false : "STRUT_TEST_NEO4J_URI assert.ok(Math.abs(caught.costUsd - 0.3) < 1e-9); assert.equal((await store.listRuns(`check:${sneaky.checks[0]}`)).length, 1); assert.deepEqual(await store.listRuns(`check:${c.checks[3]}`), [], "a check that reports no cost is not persisted at all"); + // Cumulative, from the store alone: 3 judgments at 0.4 + the sneaky 0.3. + assert.ok(Math.abs((await verifier.costOf({ kind: "step", type: "clip/compute-times" })) - 1.5) < 1e-9); + assert.equal(await verifier.costOf({ kind: "workflow", name: "clipper" }), 0); }); it("external checks: a planned slot (no strength, no content), filled in place by add_evidence; a newer version replaces the question", async () => { @@ -417,6 +420,65 @@ describe("verify pass (live Neo4j)", { skip: cfg ? false : "STRUT_TEST_NEO4J_URI assert.ok(!("error" in ((await authoring.verifyRun("candidate", other.runId)) as object))); }); + it("the ledger rides in tool results: run_workflow / run_step list the contract as pending, the settled pass wakes the chat, verify_run returns it", async () => { + await addClaim(STEP, "end is after start", [compare("{{ input.output.end }}", "-gt", "{{ input.output.start }}", { name: "bounds" })]); + await addClaim({ kind: "workflow", name: "clipper" }, "the cut sounds natural", [{ description: "listen to it", name: "ear" }]); + const watched: string[] = []; + const settled: VerifyResult[] = []; + // What createStrut's chat block does: remember which runs this chat + // launched, and hear about their verify pass settling. + const chatVerifier = { ...verifier, schedule: (key: string, runId: string) => void verifier.verifyRun(key, runId).then((r) => settled.push(r)) }; + const tools = buildTools({ + workspace: ws, + registry, + store, + services: strut.services, + getRegistry: async () => registry, + verifier: chatVerifier, + watchVerify: (runId: string) => watched.push(runId), + }) as unknown as Record Promise> }>; + + // run_workflow: the result carries the contract, every runnable check pending. + const ran = await tools["run_workflow"]!.execute({ name: "clipper", input: { start: 50, len: -10 } }); + assert.equal(ran["status"], "success"); + assert.deepEqual(watched, [ran["runId"]]); + assert.match(ran["verify"], /pending.*verify-notification/); + assert.deepEqual( + Object.fromEntries(Object.entries(ran["claims"] as Record).map(([k, v]) => [k, v.map((c) => [c.text, c.status, c.checks.map((x: any) => [x.name, x.lastVerify])])])), + { + clipper: [["the cut sounds natural", "unknown", [["ear", { pending: true }]]]], + "clip/compute-times": [["end is after start", "unknown", [["bounds", { pending: true }]]]], + }, + ); + + // The settled pass is what the [verify-notification] is built from. + const pass = await verifier.verifyRun("clipper", ran["runId"]); + const ledger = await verifier.ledger(pass); + assert.deepEqual(ledger["clip/compute-times"]!.map((c) => [c.status, c.latest?.mode, c.checks[0]!.lastVerify]), [["refuted", "observed", { ran: true }]]); + const slot = ledger["clipper"]![0]!.checks[0]!; + assert.deepEqual([ledger["clipper"]![0]!.status, slot.external, Object.keys(slot.lastVerify!)], ["unknown", true, ["planned"]]); + const { formatVerifyNotification } = await import("../ledger.js"); + assert.match(formatVerifyNotification({ workflow: "clipper", runId: ran["runId"], ledger }), /^\[verify-notification\] Run \d+ of "clipper".*1 REFUTED.*waiting on an external check/s); + + // verify_run returns the same ledger directly. + const explicit = await tools["verify_run"]!.execute({ name: "clipper", runId: ran["runId"] }); + assert.deepEqual(explicit["claims"], ledger); + assert.deepEqual(await tools["verify_run"]!.execute({ name: "clipper", runId: "nope" }).then((r) => [r["skipped"], r["claims"]]), ["unknown-run", undefined]); + + // run_step on a step with claims: kept, watched, scheduled — and its contract shown pending. + const stepRun = await tools["run_step"]!.execute({ type: "clip/compute-times", config: { start: 1, len: 5 } }); + assert.equal(stepRun["kept"], "step:clip/compute-times"); + assert.deepEqual(watched, [ran["runId"], stepRun["runId"]]); + assert.deepEqual(Object.keys(stepRun["claims"]), ["clip/compute-times"]); + for (let i = 0; i < 100 && settled.length === 0; i++) await new Promise((r) => setTimeout(r, 20)); + assert.deepEqual(settled.map((r) => [r.key, r.runId, r.evidence]), [["step:clip/compute-times", stepRun["runId"], 1]]); + assert.equal((await statusOf(STEP))[0]![1], "supported", "the newer run supersedes the refuting one"); + + // A scratch step (no claims) keeps nothing and shows no contract. + const scratch = await tools["run_step"]!.execute({ type: "log", config: { message: "hi" } }); + assert.deepEqual([scratch["kept"], scratch["claims"]], [undefined, undefined]); + }); + it("publish checks lint the new version's source; a kept run_step run is verified with EXECUTED → the step version it ran", async () => { const lint = await addClaim(STEP, "never reads process.env directly", [ { type: "exec", when: "publish", name: "env lint", config: { cmd: "bash", args: ["-c", "! grep -q 'process.env' <<< \"$SRC\""], env: { SRC: "{{ input.source }}" } } }, diff --git a/src/index.ts b/src/index.ts index 9f3a6b7..cf0ec73 100644 --- a/src/index.ts +++ b/src/index.ts @@ -344,6 +344,19 @@ export { type MappedCheck, type AddEvidenceInput, } from "./verify.js"; +// The ledger — the contract as a tool result, and the wake-up that carries it. +export { + buildLedger, + subjectsOfFlow, + formatVerifyNotification, + formatLedgerLines, + ledgerIsEmpty, + VERIFY_NOTIFICATION_PREFIX, + type Ledger, + type LedgerClaim, + type LedgerCheck, +} from "./ledger.js"; +export { createVerifyWaker, type VerifyWaker } from "./ai/verify-waker.js"; export { NodeWriter, GraphValidationError, diff --git a/src/ledger.test.ts b/src/ledger.test.ts new file mode 100644 index 0000000..9908c3d --- /dev/null +++ b/src/ledger.test.ts @@ -0,0 +1,114 @@ +/** The ledger (plans/claims.md §5): what a tool result says about a contract. */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { z } from "zod"; +import type { Flow } from "./core.js"; +import type { SubjectLedgerRow, SubjectRef } from "./graph/claims.js"; +import { VERIFY_NOTIFICATION_PREFIX, buildLedger, formatLedgerLines, formatVerifyNotification, ledgerIsEmpty, subjectsOfFlow } from "./ledger.js"; +import type { VerifiedCheck } from "./verify.js"; + +const STEP: SubjectRef = { kind: "step", type: "clip/compute-times" }; +const WF: SubjectRef = { kind: "workflow", name: "youtube-clip" }; + +const row = (id: string, text: string, status: SubjectLedgerRow["status"]["status"], checks: Array & { id: string }>, over: Partial = {}): SubjectLedgerRow => ({ + claim: { ref_id: `ref-${id}`, id, name: text, claim_text: text }, + checks: checks.map((k) => ({ ref_id: `ref-${k.id}`, name: k.id, created_at: 1, ...k })), + status: { status, assertedOnly: false, unverified: 0, openSlot: false, slots: [], ...over }, +}); + +const reader = (bySubject: Record) => ({ + statusFor: async (s: SubjectRef) => bySubject[s.kind === "step" ? s.type : s.name] ?? [], +}); + +describe("buildLedger", () => { + const rows = { + "clip/compute-times": [ + row("c1", "end is after start", "refuted", [{ id: "k1", step_type: "exec" }, { id: "k2", step_type: "llm" }], { + unverified: 1, + latest: { ref_id: "e", id: "e1", name: "n", claim_id: "c1", content: "end 40 <= start 50", observed_at: 99, evidence_mode: "observed", check_id: "k1", source: { ref_id: "r", context: { checkVersion: "exec" } } }, + }), + row("c2", "the cut sounds natural", "unknown", [{ id: "kx", description: "listen" }], { openSlot: true, slots: [{ evidence_id: "slot-1", check_id: "kx" }] }), + ], + "youtube-clip": [row("c3", "the clip contains the quote", "supported", [{ id: "k3", step_type: "subflow", run_when: "publish" }], { assertedOnly: true })], + }; + + it("one list per subject that has claims; status, latest, and each check's lastVerify from the pass", async () => { + const checks: VerifiedCheck[] = [ + // a foreach: three outcomes for one check read as the most informative one + { subject: STEP, path: "wf/each#0", claimId: "c1", checkId: "k1", lastVerify: { skipped: "cannot-launch", reason: "x" } }, + { subject: STEP, path: "wf/each#1", claimId: "c1", checkId: "k1", lastVerify: { ran: true } }, + { subject: STEP, path: "wf/each#0", claimId: "c1", checkId: "k2", lastVerify: { skipped: "budget" } }, + // the same check id on ANOTHER subject must not leak in + { subject: { kind: "step", type: "other" }, path: "p", claimId: "c1", checkId: "k2", lastVerify: { ran: true } }, + ]; + const ledger = await buildLedger(reader(rows), [WF, STEP, { kind: "step", type: "log" }], { result: { checks } }); + assert.deepEqual(Object.keys(ledger), ["youtube-clip", "clip/compute-times"], "a subject with no claims is left out"); + assert.deepEqual(ledger["clip/compute-times"], [ + { + id: "c1", text: "end is after start", status: "refuted", assertedOnly: false, unverified: 1, + latest: { content: "end 40 <= start 50", observed_at: 99, mode: "observed", check: "k1", checkVersion: "exec" }, + checks: [{ id: "k1", name: "k1", lastVerify: { ran: true } }, { id: "k2", name: "k2", lastVerify: { skipped: "budget" } }], + }, + { + id: "c2", text: "the cut sounds natural", status: "unknown", assertedOnly: false, unverified: 0, + checks: [{ id: "kx", name: "kx", external: true, lastVerify: { planned: "slot-1" } }], + }, + ]); + assert.deepEqual(ledger["youtube-clip"]![0]!.checks, [{ id: "k3", name: "k3" }], "no outcome this pass, no slot → no lastVerify"); + }); + + it("at launch every RUN check is pending — a publish check has no verdict coming from a run", async () => { + const ledger = await buildLedger(reader(rows), [STEP, WF], { pending: true }); + assert.deepEqual(ledger["clip/compute-times"]!.map((c) => c.checks.map((k) => k.lastVerify)), [[{ pending: true }, { pending: true }], [{ planned: "slot-1" }]]); + assert.deepEqual(ledger["youtube-clip"]![0]!.checks[0]!.lastVerify, undefined); + assert.ok(ledgerIsEmpty(await buildLedger(reader({}), [STEP]))); + }); + + it("a workflow and a step that share a name stay apart", async () => { + const same = { twin: [row("c9", "t", "unknown", [{ id: "k9" }])] }; + const ledger = await buildLedger(reader(same), [{ kind: "workflow", name: "twin" }, { kind: "step", type: "twin" }]); + assert.deepEqual(Object.keys(ledger), ["twin", "step:twin"]); + }); +}); + +describe("subjectsOfFlow / notifications", () => { + it("what a launch can execute: the workflow, its nested children, and every step type in reach", async () => { + const child: Flow = { name: "stt-check", input: z.any(), steps: [{ id: "t", type: "stt/transcribe", config: {} }] }; + const flow: Flow = { + name: "youtube-clip", + input: z.any(), + steps: [ + { id: "a", type: "clip/fetch", config: {} }, + { id: "b", type: "subflow", config: { workflow: "stt-check", input: {} } }, + { id: "c", type: "clip/fetch", config: {} }, + ], + }; + const ws = { getWorkflow: async () => child, getWorkflowVersion: async () => child }; + assert.deepEqual(await subjectsOfFlow(flow, ws, "youtube-clip"), [ + { kind: "workflow", name: "youtube-clip" }, + { kind: "workflow", name: "stt-check" }, + { kind: "step", type: "clip/fetch" }, + { kind: "step", type: "subflow" }, + { kind: "step", type: "stt/transcribe" }, + ]); + }); + + it("the [verify-notification] leads with what needs attention, then carries the ledger as JSON", async () => { + const ledger = await buildLedger( + reader({ + "clip/compute-times": [ + row("c1", "a", "refuted", [{ id: "k1" }]), + row("c2", "b", "supported", [{ id: "k2" }], { assertedOnly: true }), + row("c3", "c", "unknown", [{ id: "kx" }], { slots: [{ evidence_id: "s1", check_id: "kx" }] }), + ], + }), + [STEP], + ); + const text = formatVerifyNotification({ workflow: "youtube-clip", runId: "1789", ledger, costUsd: 0.0123 }); + assert.ok(text.startsWith(`${VERIFY_NOTIFICATION_PREFIX} Run 1789 of "youtube-clip" was verified against its claims — clip/compute-times: 1 REFUTED, 1 supported, 1 unknown (1 asserted-only) (1 waiting on an external check). Checks cost $0.0123.`), text); + assert.deepEqual(JSON.parse(text.split("\n")[1]!.replace(/^claims: /, "")), ledger); + assert.match(text, /not done/); + assert.ok(!formatVerifyNotification({ workflow: "w", runId: "1", ledger }).includes("cost")); + assert.match(formatLedgerLines(ledger), /^Verified against its claims — .*\nclaims: \{/); + }); +}); diff --git a/src/ledger.ts b/src/ledger.ts new file mode 100644 index 0000000..56db8db --- /dev/null +++ b/src/ledger.ts @@ -0,0 +1,157 @@ +/** + * The ledger — the contract as a tool result (`plans/claims.md` §5). + * + * This is the forcing function: the model reads what its work is claimed to + * do, and what the evidence says, in the RESULT of the call it just made — + * not in an instruction it can rationalize past. Three places carry it: + * + * - `run_workflow` / `run_step` results list the claims with every check + * `lastVerify: { pending: true }` — the contract at once, a verdict coming; + * - the `[verify-notification]` message (and a `[run-notification]` whose + * verify pass already settled) carries the computed statuses; + * - `verify_run` returns it directly. + * + * Workflow claims and each step's claims are listed separately: step + * evidence does NOT roll up into the workflow (roll-up needs sub-claims). + */ +import { flowClosure } from "./closure.js"; +import type { Flow } from "./core.js"; +import { isExternalCheck, subjectName, type ClaimsReader, type SubjectRef } from "./graph/claims.js"; +import type { SubflowResolver } from "./runner.js"; +import type { LastVerify, VerifyResult } from "./verify.js"; + +export interface LedgerCheck { + id: string; + name: string; + /** An external check: answered by a person or an outside system. */ + external?: true; + lastVerify?: LastVerify; +} + +export interface LedgerClaim { + id: string; + text: string; + status: "supported" | "refuted" | "unknown" | "stale"; + /** The verdict rests on a model's or a person's word — nothing observed. */ + assertedOnly: boolean; + /** Active checks with no evidence about the active version. */ + unverified: number; + latest?: { content?: string; observed_at?: number; mode?: string; check?: string; checkVersion?: string }; + checks: LedgerCheck[]; +} + +/** One list per subject: a workflow by name, a step by type (`step:` + * only if a workflow shares the name). */ +export type Ledger = Record; + +export const VERIFY_NOTIFICATION_PREFIX = "[verify-notification]"; + +const sameSubject = (a: SubjectRef, b: SubjectRef) => a.kind === b.kind && subjectName(a) === subjectName(b); + +/** Several outcomes for one check in one run (a foreach yields one per + * iteration) read as the most informative one. */ +function mergeLastVerify(all: LastVerify[]): LastVerify | undefined { + return all.find((v) => "ran" in v) ?? all.find((v) => "planned" in v) ?? all.find((v) => "pending" in v) ?? all[0]; +} + +/** + * The ledger for `subjects`. `lastVerify` comes from a verify result when + * there is one; `pending` marks every runnable check instead (a run was just + * launched and its pass has not settled). Subjects with no claims are left + * out, so an empty ledger means "nothing here carries a contract". + */ +export async function buildLedger( + reader: Pick, + subjects: readonly SubjectRef[], + opts: { result?: Pick; pending?: boolean } = {}, +): Promise { + const ledger: Ledger = {}; + const names = new Set(); + for (const subject of subjects) { + const rows = await reader.statusFor(subject); + if (rows.length === 0) continue; + let key = subjectName(subject); + if (names.has(key)) key = `${subject.kind}:${key}`; + names.add(key); + ledger[key] = rows.map((r) => ({ + id: r.claim.id, + text: r.claim.claim_text, + status: r.status.status, + assertedOnly: r.status.assertedOnly, + unverified: r.status.unverified, + ...(r.status.latest + ? { + latest: { + ...(r.status.latest.content !== undefined ? { content: r.status.latest.content } : {}), + ...(r.status.latest.observed_at !== undefined ? { observed_at: r.status.latest.observed_at } : {}), + ...(r.status.latest.evidence_mode ? { mode: r.status.latest.evidence_mode } : {}), + ...(r.status.latest.check_id ? { check: r.status.latest.check_id } : {}), + ...(r.status.latest.source?.context?.checkVersion ? { checkVersion: r.status.latest.source.context.checkVersion } : {}), + }, + } + : {}), + checks: r.checks.map((k) => { + const outcomes = (opts.result?.checks ?? []).filter((c) => c.checkId === k.id && c.claimId === r.claim.id && sameSubject(c.subject, subject)).map((c) => c.lastVerify); + const slot = r.status.slots.find((s) => s.check_id === k.id); + const lastVerify: LastVerify | undefined = + mergeLastVerify(outcomes) ?? + (slot ? { planned: slot.evidence_id } : undefined) ?? + // Only a check that fires on a RUN has a verdict coming from this one. + (opts.pending && (k.run_when ?? "run") === "run" ? { pending: true } : undefined); + return { id: k.id, name: k.name, ...(isExternalCheck(k) ? { external: true as const } : {}), ...(lastVerify ? { lastVerify } : {}) }; + }), + })); + } + return ledger; +} + +/** + * The subjects a flow can execute, BEFORE it has run: the workflow, its + * nested children, and every step type in reach — what a launch result + * lists as pending. (After a run, the verify result names what it observed.) + */ +export async function subjectsOfFlow(flow: Pick, workspace: SubflowResolver | undefined, workflowName?: string): Promise { + const closure = await flowClosure(flow, workspace); + return [ + ...(workflowName ? [{ kind: "workflow" as const, name: workflowName }] : []), + ...closure.workflows.map((w) => ({ kind: "workflow" as const, name: w.workflow })), + ...[...closure.types].map((type) => ({ kind: "step" as const, type })), + ].filter((s, i, all) => all.findIndex((o) => sameSubject(o, s)) === i); +} + +export function ledgerIsEmpty(ledger: Ledger): boolean { + return Object.keys(ledger).length === 0; +} + +/** One line per subject — what a person reads before the JSON. */ +function summarize(ledger: Ledger): string { + return Object.entries(ledger) + .map(([subject, claims]) => { + const n = (s: string) => claims.filter((c) => c.status === s).length; + const parts = [ + n("refuted") ? `${n("refuted")} REFUTED` : "", + n("supported") ? `${n("supported")} supported` : "", + n("unknown") ? `${n("unknown")} unknown` : "", + n("stale") ? `${n("stale")} stale` : "", + ].filter(Boolean); + const waiting = claims.filter((c) => c.checks.some((k) => k.lastVerify && "planned" in k.lastVerify)).length; + const asserted = claims.filter((c) => c.assertedOnly).length; + return `${subject}: ${parts.join(", ")}${asserted ? ` (${asserted} asserted-only)` : ""}${waiting ? ` (${waiting} waiting on an external check)` : ""}`; + }) + .join("; "); +} + +/** The `[verify-notification]` message text. */ +export function formatVerifyNotification(info: { workflow: string; runId: string; ledger: Ledger; costUsd?: number }): string { + const cost = info.costUsd && info.costUsd > 0 ? ` Checks cost $${info.costUsd.toFixed(4)}.` : ""; + return [ + `${VERIFY_NOTIFICATION_PREFIX} Run ${info.runId} of "${info.workflow}" was verified against its claims — ${summarize(info.ledger)}.${cost}`, + `claims: ${JSON.stringify(info.ledger)}`, + `A refuted or unknown claim means the work is not done: fix it (or the check) and run again. A claim whose check is \`planned\` is waiting on someone — answer it with add_evidence only if you OBSERVED the answer with a tool, otherwise tell the user what to look at and where.`, + ].join("\n"); +} + +/** The ledger lines appended to a `[run-notification]` whose pass already settled. */ +export function formatLedgerLines(ledger: Ledger): string { + return [`Verified against its claims — ${summarize(ledger)}.`, `claims: ${JSON.stringify(ledger)}`].join("\n"); +} diff --git a/src/verify.ts b/src/verify.ts index 9afb524..262701e 100644 --- a/src/verify.ts +++ b/src/verify.ts @@ -54,6 +54,7 @@ import { } from "./graph/claims.js"; import { boundedName } from "./graph/claims-writer.js"; import type { EdgeInput } from "./graph/edge-writer.js"; +import { buildLedger, type Ledger } from "./ledger.js"; import { projectRun } from "./graph/projector.js"; import { PREVIEW_MAX_CHARS } from "./graph/strut-schemas.js"; import { RUN_STEP_FLOW, persistRunUnder, runSingleStep, type RunStepResult } from "./run-step.js"; @@ -336,7 +337,8 @@ export interface VerifierDeps { /** The services bag checks run with (a getter: createStrut builds it late). */ services: () => unknown; env?: Record; - /** A DETACHED pass settled (the notifier hook). Never called for a no-op pass. */ + /** A DETACHED pass settled — always called, no-op passes included, so a + * listener waiting on that run can stop waiting. */ onSettled?: (result: VerifyResult) => void; } @@ -684,10 +686,11 @@ export function createVerifier(deps: VerifierDeps) { function schedule(key: string, runId: string): void { setImmediate(() => { verifyRun(key, runId) - .then((r) => { - if (!r.skipped && r.subjects.length > 0) deps.onSettled?.(r); + .catch((err): VerifyResult => { + console.error(`[verify] pass over ${key}/${runId} failed:`, err); + return { key, runId, skipped: "unknown-run", subjects: [], checks: [], evidence: 0, slots: 0, costUsd: 0 }; }) - .catch((err) => console.error(`[verify] pass over ${key}/${runId} failed:`, err)); + .then((r) => deps.onSettled?.(r)); }); } @@ -810,7 +813,34 @@ export function createVerifier(deps: VerifierDeps) { return { ok: true, evidence: id, filled: false, subject: o.subject }; } - return { reader, verifyRun, verifyPublish, addEvidence, schedule }; + /** + * What keeping this subject's claims true has cost, all time — from the + * run store alone: every persisted (paid) check run in the buckets of the + * checks on its claims, retired ones included, tagged with this subject. + * Cost is a constraint, not telemetry (EVOLVE_SPEC §7): the builder, a + * person and the evolve loop all read the same number. + */ + async function costOf(subject: SubjectRef): Promise { + const k = subjectKey(subject); + let total = 0; + for (const claim of await reader.claimsFor(subject, { includeRetired: true })) { + for (const check of await reader.checksFor(claim.id, { includeRetired: true })) { + const bucket = checkRunKey(check.id); + for (const id of await store.listRuns(bucket)) { + const events = await store.getRunEvents(bucket, id); + if (events.find((e) => e.type === "run.start")?.verify?.subject === k) total += reportedCost(events); + } + } + } + return total; + } + + /** The ledger a settled pass produced: computed statuses + per-check lastVerify. */ + const ledger = (result: VerifyResult): Promise => buildLedger(reader, result.subjects, { result }); + /** The contract of what a launch can execute, every runnable check `pending`. */ + const pendingLedger = (subjects: readonly SubjectRef[]): Promise => buildLedger(reader, subjects, { pending: true }); + + return { reader, verifyRun, verifyPublish, addEvidence, schedule, ledger, pendingLedger, costOf }; } /** `step:` for a step subject's kept runs; the workflow name otherwise. */ diff --git a/web/src/components/ChatFlyout.tsx b/web/src/components/ChatFlyout.tsx index 178f01b..bdcb36c 100644 --- a/web/src/components/ChatFlyout.tsx +++ b/web/src/components/ChatFlyout.tsx @@ -36,9 +36,11 @@ function setChatUrlParam(id: string | null) { history.replaceState(null, "", url); } -// Server-initiated wake-up messages (a detached run finished) are stored as -// user-role messages with this prefix; render them as a notice, not a bubble. -const NOTIFICATION_PREFIX = "[run-notification]"; +// Server-initiated wake-up messages (a detached run finished; a run was +// verified against its claims) are stored as user-role messages with one of +// these prefixes; render them as a notice, not a bubble. +const NOTIFICATION_PREFIXES = ["[run-notification]", "[verify-notification]"]; +const isNotification = (text: string) => NOTIFICATION_PREFIXES.some((p) => text.startsWith(p)); // The picker's "type any model name" option. const CUSTOM_MODEL = "__custom__"; @@ -190,7 +192,7 @@ function transcriptToEntries(messages: { role: string; content: unknown }[]): Ch const text = extractText(m.content); if (text) { entries.push( - text.startsWith(NOTIFICATION_PREFIX) + isNotification(text) ? { kind: "notice", content: text } : { kind: "user", content: text }, ); From 1e102f086bb04479d159bbf8834af8c72763bc72 Mon Sep 17 00:00:00 2001 From: Evanfeenstra Date: Thu, 17 Sep 2026 12:52:50 -0700 Subject: [PATCH 06/11] =?UTF-8?q?claims=20step=207:=20the=20Claims=20panel?= =?UTF-8?q?=20=E2=80=94=20a=20person's=20door=20onto=20the=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - claims-routes.ts: GET /claims?kind=&name= returns a subject's contract — claims with computed status, the newest evidence behind each verdict (and the run it came from), their checks, and open slots as to-dos. POST / PATCH / DELETE for claims and checks, attach / detach, and POST /claims/:id/evidence — which is also how an open slot is ANSWERED. The actor is a person: not publisher-scoped, stamped `person` (so an `ai` author can never edit it, and the producer's grader deny-list does not apply); what they say about a run is `asserted`, `by: person`. Mutations sit behind requireApiKey. On a filesystem workspace GET answers { enabled: false } and mutations 409 - the listing (shared with list_claims) gains `latest` and `slots`; the reader reports the source run's store key; statusFor returns the evidence it computed from - web: ClaimsPanel — status badge, asserted-only / unverified flags, latest evidence with a link that opens the run, the checks under each claim, add / reword / retire, and a check editor for step checks (type, YAML config, fires, policy) and external checks. Open slots render FIRST as to-dos: the question, the run and its artifacts, a note, Supports / Refutes. Every write re-reads, because an edit replaces the node it touched - StepEditFlyout: a collapsible Claims section on the step TYPE (custom steps only) that opens by itself on a refutation or a to-do. A workflow-level ClaimsFlyout behind a topbar button whose dot says what needs attention (refuted > waiting on someone > unverified). Hidden where there is no claims layer --- AGENTS.md | 1 + plans/claims.md | 12 +- src/claims-authoring.ts | 34 ++- src/claims-routes.ts | 122 ++++++++ src/createStrut.test.ts | 11 + src/createStrut.ts | 11 + src/graph/claims.test.ts | 2 +- src/graph/claims.ts | 9 +- src/graph/verify.test.ts | 53 ++++ src/index.ts | 1 + src/ledger.test.ts | 1 + web/src/api.ts | 83 ++++++ web/src/app.tsx | 48 +++- web/src/components/ClaimsFlyout.tsx | 38 +++ web/src/components/ClaimsPanel.tsx | 392 ++++++++++++++++++++++++++ web/src/components/StepEditFlyout.tsx | 43 +++ web/src/styles/components.css | 126 +++++++++ 17 files changed, 980 insertions(+), 7 deletions(-) create mode 100644 src/claims-routes.ts create mode 100644 web/src/components/ClaimsFlyout.tsx create mode 100644 web/src/components/ClaimsPanel.tsx diff --git a/AGENTS.md b/AGENTS.md index 59f62aa..ab996f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,7 @@ strut/ │ ├── claims-schemas.ts # zod shapes + model-facing docs for subjects / check specs / the `claims` arg, shared by ai/tools.ts and the meta/* claim steps │ ├── verify.ts # the verify pass (plans/claims.md §4): subjectsOfRun (a run's event log → observed subjects + the version each executed), mapCheckResult (the check contract; a check that cannot run writes NOTHING), policyFires (always / on_change / sample / manual), budget (presumed-paid skipped at a cap; reported cost persisted under `check:` and counted), planned slots for external checks, addEvidence, verifyPublish. Triggered from `services.onRunEnd` for every top-level run and after a kept run_step; check runs (`origin: "verify"`) are never verified │ ├── ledger.ts # the ledger (plans/claims.md §5): buildLedger (claims per subject with computed status + each check's lastVerify: pending | ran | skipped | planned), subjectsOfFlow (what a launch can execute), the [verify-notification] text. The forcing function — the model reads its contract in a tool RESULT, not an instruction +│ ├── claims-routes.ts # the Claims panel's HTTP door: GET /claims?kind=&name= (contract + computed status + latest evidence + open slots; `{ enabled: false }` on a filesystem workspace), POST/PATCH/DELETE /claims[/:id], /claims/:id/{attach,detach,checks,evidence}, PATCH/DELETE /checks/:id. Mutations behind requireApiKey; the actor is a PERSON (unscoped, stamped `person`; evidence `asserted`, `by: person`) │ ├── closure.ts # what a flow can EXECUTE: walkSteps (loop/foreach bodies, onError), flowClosure (nested subflows via the workspace, agentTools grants; templated/missing child → unresolvable), stepHashesFor → run.start.stepHashes │ ├── run-step.ts # runSingleStep (one step, in memory, optional cassette) + runStep — the run_step surfaces: records stepHashes, then persists the run under `step:` only when the step has claims or `keep: true` (plans/claims.md §3) │ ├── chat-store.ts # ChatStore interface + FileChatStore + MemoryChatStore (chats//: meta.json + messages.jsonl + events.jsonl) + truncateToolMessages diff --git a/plans/claims.md b/plans/claims.md index 55100b7..1d31f5c 100644 --- a/plans/claims.md +++ b/plans/claims.md @@ -933,7 +933,17 @@ number exists). `meta/attach-claim` in `gaia-evolve-gen`, `meta/verify-run` before `canddigest`, the fitness `meta/add-evidence`, per-claim pass rates in `gaia/digest-results`. Not needed for step 8. -7. UI panel (`web/src/components/StepEditFlyout.tsx` + the workflow view), +7. **Done** — `src/claims-routes.ts` (the panel's HTTP door; the actor is a + PERSON: unscoped, stamped `person`, evidence `asserted` / `by: person`), + `web/src/components/ClaimsPanel.tsx` (status badge, latest evidence with + its run, checks, add / reword / retire, a check editor for step and + external checks), a collapsible Claims section in `StepEditFlyout` (on + the step TYPE; opens by itself on a refutation or a to-do) and a + workflow-level `ClaimsFlyout` behind a topbar button whose dot says what + needs attention. Open slots render FIRST, as to-dos: the question, the + run and its artifacts, a note, Supports / Refutes. Hidden entirely on a + filesystem workspace (`GET /claims` → `enabled: false`). + UI panel (`web/src/components/StepEditFlyout.tsx` + the workflow view), incl. open slots as to-dos. 8. Re-run the `youtube-clip` prompt on a fresh workspace; compare transcripts. diff --git a/src/claims-authoring.ts b/src/claims-authoring.ts index 4cef198..32f3b51 100644 --- a/src/claims-authoring.ts +++ b/src/claims-authoring.ts @@ -33,7 +33,7 @@ import { closureIncludes, flowClosure, globToRegExp, type FlowClosure } from "./ import { validateWorkflowYaml } from "./validate.js"; import type { StepRegistry } from "./core.js"; import type { GraphBackend } from "./graph/backend.js"; -import { ClaimsReader, isExternalCheck, type CheckPolicy, type CheckRow, type ClaimStatus, type RunWhen, type SubjectRef } from "./graph/claims.js"; +import { ClaimsReader, isExternalCheck, type CheckPolicy, type CheckRow, type ClaimStatus, type EvidenceRow, type RunWhen, type SubjectRef } from "./graph/claims.js"; import { ClaimsError, ClaimsWriter, boundedName, type CheckData } from "./graph/claims-writer.js"; import type { WorkspaceStore } from "./workspace.js"; @@ -134,6 +134,10 @@ export interface ClaimListing { assertedOnly: boolean; unverified: number; openSlot: boolean; + /** The newest evidence carrying the verdict. */ + latest?: { content?: string; observedAt?: number; mode?: string; check?: string; checkVersion?: string; by?: string; run?: { name?: string; runId?: string; path?: string } }; + /** Open questions an external check is waiting on — the panel's to-dos. */ + slots: Array<{ evidence: string; check?: string; question?: string; run?: { name?: string; runId?: string; path?: string } }>; checks: Array<{ id: string; name: string; @@ -282,6 +286,12 @@ export function buildClaimsAuthoring(deps: ClaimsAuthoringDeps) { return check; } + const runOf = (e: EvidenceRow) => ({ + ...(e.source?.run_key ? { name: e.source.run_key } : {}), + ...(e.source?.run_id ? { runId: e.source.run_id } : {}), + ...(e.source?.context?.path ? { path: e.source.context.path } : {}), + }); + const listingOf = (k: CheckRow): ClaimListing["checks"][number] => { let config: unknown; if (k.step_config) { @@ -475,6 +485,28 @@ export function buildClaimsAuthoring(deps: ClaimsAuthoringDeps) { assertedOnly: r.status.assertedOnly, unverified: r.status.unverified, openSlot: r.status.openSlot, + ...(r.status.latest + ? { + latest: { + ...(r.status.latest.content !== undefined ? { content: r.status.latest.content } : {}), + ...(r.status.latest.observed_at !== undefined ? { observedAt: r.status.latest.observed_at } : {}), + ...(r.status.latest.evidence_mode ? { mode: r.status.latest.evidence_mode } : {}), + ...(r.status.latest.check_id ? { check: r.status.latest.check_id } : {}), + ...(r.status.latest.source?.context?.checkVersion ? { checkVersion: r.status.latest.source.context.checkVersion } : {}), + ...(r.status.latest.source?.context?.by ? { by: r.status.latest.source.context.by } : {}), + ...(r.status.latest.source?.run_id ? { run: runOf(r.status.latest) } : {}), + }, + } + : {}), + slots: r.status.slots.map((slot) => { + const e = r.evidence.find((x) => x.id === slot.evidence_id); + return { + evidence: slot.evidence_id, + ...(slot.check_id ? { check: slot.check_id } : {}), + ...(e?.description ? { question: e.description } : {}), + ...(e?.source?.run_id ? { run: runOf(e) } : {}), + }; + }), checks: r.checks.map(listingOf), })), }; diff --git a/src/claims-routes.ts b/src/claims-routes.ts new file mode 100644 index 0000000..320385e --- /dev/null +++ b/src/claims-routes.ts @@ -0,0 +1,122 @@ +/** + * The Claims panel's HTTP door (`plans/claims.md` §2 "UI", §4.2) — onto the + * same write the chat tools and the `meta/*` twins use. + * + * Whoever is at the keyboard is a PERSON: not publisher-scoped, and stamped + * `person` — so an `ai` author can never edit what they wrote, and their + * checks are not subject to the producer's grader deny-list. What a person + * says about a run is still `asserted` evidence (`by: person`): a person + * vouched, no instrument measured. + * + * Reads are open, like the rest of the read surface; every mutation is + * behind `requireApiKey` (permissive in dev). On a filesystem workspace + * `GET /claims` answers `{ enabled: false }` — the panel hides itself — and + * mutations answer 409. + */ +import type { Context, Hono } from "hono"; +import { z } from "zod"; +import { requireApiKey } from "./auth.js"; +import { buildClaimsAuthoring, toSubjectRef, type ClaimActor } from "./claims-authoring.js"; +import { CLAIMS_OFF, checkSpecSchema, claimSpecSchema, subjectSchema } from "./claims-schemas.js"; +import type { StepRegistry } from "./core.js"; +import type { Verifier } from "./verify.js"; +import type { WorkspaceStore } from "./workspace.js"; + +export interface ClaimsRoutesDeps { + workspace: WorkspaceStore; + verifier: Verifier | null; + getRegistry(): Promise; +} + +const PERSON: ClaimActor = { publisher: "person", scoped: false }; + +export function claimsRoutes(app: Hono, deps: ClaimsRoutesDeps): void { + const claims = deps.workspace.graph ? buildClaimsAuthoring({ graph: deps.workspace.graph, workspace: deps.workspace, getRegistry: deps.getRegistry }) : null; + + /** Parse the JSON body with a zod shape; a 400 names what is wrong. */ + async function bodyOf(c: Context, shape: S): Promise<{ data: z.infer } | { error: string }> { + const parsed = shape.safeParse(await c.req.json().catch(() => null)); + return parsed.success ? { data: parsed.data } : { error: `invalid body: ${parsed.error.issues.map((i) => `${i.path.join(".") || "body"}: ${i.message}`).join("; ")}` }; + } + /** Authoring results are `{ ok, … } | { error }` — an error is the caller's to fix. */ + const reply = (c: Context, result: unknown) => c.json(result as object, result && typeof result === "object" && "error" in result ? 400 : 200); + const off = (c: Context) => c.json({ error: CLAIMS_OFF }, 409); + const id = (c: Context) => c.req.param("id") ?? ""; + + // A subject's contract: claims with computed status, latest evidence, their + // checks, and open slots (questions an external check is waiting on). + app.get("/claims", async (c) => { + if (!claims) return c.json({ enabled: false, claims: [] }); + const subject = subjectSchema.safeParse({ kind: c.req.query("kind"), name: c.req.query("name") }); + if (!subject.success) return c.json({ error: "kind (step | workflow) and name are required" }, 400); + const listing = await claims.listClaims(subject.data); + // A built-in step has no node in the workspace: it simply has no contract. + if (!("ok" in listing)) return c.json({ enabled: true, subject: subject.data, claims: [], note: listing.error }); + const verifyCostUsd = deps.verifier ? await deps.verifier.costOf(toSubjectRef(subject.data)).catch(() => 0) : 0; + return c.json({ enabled: true, subject: listing.subject, claims: listing.claims, ...(verifyCostUsd > 0 ? { verifyCostUsd } : {}) }); + }); + + app.post("/claims", requireApiKey, async (c) => { + if (!claims) return off(c); + const body = await bodyOf(c, claimSpecSchema.extend({ subjects: z.array(subjectSchema).min(1) })); + if ("error" in body) return c.json(body, 400); + return reply(c, await claims.addClaim(body.data, PERSON)); + }); + + app.patch("/claims/:id", requireApiKey, async (c) => { + if (!claims) return off(c); + const body = await bodyOf(c, z.object({ text: z.string() })); + if ("error" in body) return c.json(body, 400); + return reply(c, await claims.editClaim(id(c), body.data.text, PERSON)); + }); + + app.delete("/claims/:id", requireApiKey, async (c) => (claims ? reply(c, await claims.retireClaim(id(c), PERSON)) : off(c))); + + for (const verb of ["attach", "detach"] as const) { + app.post(`/claims/:id/${verb}`, requireApiKey, async (c) => { + if (!claims) return off(c); + const body = await bodyOf(c, z.object({ subject: subjectSchema })); + if ("error" in body) return c.json(body, 400); + return reply(c, verb === "attach" ? await claims.attachClaim(id(c), body.data.subject, PERSON) : await claims.detachClaim(id(c), body.data.subject, PERSON)); + }); + } + + app.post("/claims/:id/checks", requireApiKey, async (c) => { + if (!claims) return off(c); + const body = await bodyOf(c, z.object({ check: checkSpecSchema })); + if ("error" in body) return c.json(body, 400); + return reply(c, await claims.addCheck(id(c), body.data.check, PERSON)); + }); + + app.patch("/checks/:id", requireApiKey, async (c) => { + if (!claims) return off(c); + const body = await bodyOf(c, z.object({ patch: checkSpecSchema })); + if ("error" in body) return c.json(body, 400); + return reply(c, await claims.editCheck(id(c), body.data.patch, PERSON)); + }); + + app.delete("/checks/:id", requireApiKey, async (c) => (claims ? reply(c, await claims.retireCheck(id(c), PERSON)) : off(c))); + + // A person's own observation on a run — and how an open slot is ANSWERED + // from the panel (pass `slot`). Always `asserted`, `by: person`. + app.post("/claims/:id/evidence", requireApiKey, async (c) => { + if (!deps.verifier) return off(c); + const body = await bodyOf( + c, + z.object({ name: z.string(), runId: z.string(), supports: z.boolean(), content: z.string(), slot: z.string().optional(), subject: subjectSchema.optional() }), + ); + if ("error" in body) return c.json(body, 400); + const { subject, slot, ...rest } = body.data; + return reply( + c, + await deps.verifier.addEvidence({ + claim: id(c), + ...rest, + ...(slot ? { slot } : {}), + ...(subject ? { subject: toSubjectRef(subject) } : {}), + by: "person", + mode: "asserted", + }), + ); + }); +} diff --git a/src/createStrut.test.ts b/src/createStrut.test.ts index ab191ac..5b69648 100644 --- a/src/createStrut.test.ts +++ b/src/createStrut.test.ts @@ -376,6 +376,17 @@ describe("createStrut", () => { assert.deepEqual(listed.map((w) => w.name), ["shouter"], "a step key is never a workflow"); }); + it("a filesystem workspace has no claims layer: GET /claims says so, mutations are 409", async () => { + const strut = await createStrut({ workspace: new WorkspaceManager(tempDir), store: new MemoryRunStore(), serveUi: false, enableChat: false, stt: false }); + assert.deepEqual([strut.claims, strut.verifier], [null, null]); + const read = await strut.app.request("/claims?kind=step&name=log"); + assert.deepEqual([read.status, await read.json()], [200, { enabled: false, claims: [] }]); + const post = (path: string) => strut.app.request(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }); + assert.equal((await post("/claims")).status, 409); + assert.equal((await post("/claims/x/evidence")).status, 409); + assert.equal((await post("/workflows/wf/runs/1/verify")).status, 409); + }); + it("exposes /steps with registered types", async () => { const myStep = defineStep({ type: "custom-thing", diff --git a/src/createStrut.ts b/src/createStrut.ts index 677a8bc..79be3d0 100644 --- a/src/createStrut.ts +++ b/src/createStrut.ts @@ -37,6 +37,7 @@ import { import { runStep, cassettePath, RUN_STEP_FLOW } from "./run-step.js"; import { createVerifier, type Verifier, type VerifyResult } from "./verify.js"; import { CLAIMS_OFF } from "./claims-schemas.js"; +import { claimsRoutes } from "./claims-routes.js"; import { createVerifyWaker, type VerifyWaker } from "./ai/verify-waker.js"; import type { RunEndInfo } from "./runner.js"; import { stepHashesFor } from "./closure.js"; @@ -1491,6 +1492,16 @@ export async function createStrut( return runId; } + // The Claims panel's HTTP door (plans/claims.md §2 "UI", §4.2). + claimsRoutes(app, { + workspace, + verifier, + getRegistry: async () => { + await rebuildRegistry(); + return registry; + }, + }); + // Re-verify a finished run (plans/claims.md §4): after claims or checks // change, to backfill, or to fire `manual` checks. Synchronous, idempotent // per (check, run, path), and single-flighted with the detached pass. diff --git a/src/graph/claims.test.ts b/src/graph/claims.test.ts index 659a508..ae6a511 100644 --- a/src/graph/claims.test.ts +++ b/src/graph/claims.test.ts @@ -342,7 +342,7 @@ describe("claims graph: node contract + reads (live Neo4j)", { skip: cfg ? false evidence_mode: "observed", evidence_status: "collected", observed_at: now - 5, date_added_to_graph: undefined, claim_id: "c1", strength: 1, check_id: "k1", about: { kind: "step", name: "clip/compute-times", content_hash: V2 }, - source: { ref_id: ref["run"], node_type: "StrutRun", run_id: "run-1", context: { path: "step", cassette: "replay", checkVersion: "exec" } }, + source: { ref_id: ref["run"], node_type: "StrutRun", run_id: "run-1", run_key: "step:clip/compute-times", context: { path: "step", cassette: "replay", checkVersion: "exec" } }, }, ); assert.deepEqual(await reader.evidenceFor("c1", { kind: "workflow", name: "youtube-clip" }), [], "same claim, other subject: its own status"); diff --git a/src/graph/claims.ts b/src/graph/claims.ts index a5631a6..4b74190 100644 --- a/src/graph/claims.ts +++ b/src/graph/claims.ts @@ -245,6 +245,8 @@ export interface EvidenceRow { ref_id: string; node_type?: string; run_id?: string; + /** The run-store key of that run: a workflow name, or `step:`. */ + run_key?: string; context?: SourceContext; start_time?: number; end_time?: number; @@ -408,6 +410,8 @@ export interface SubjectLedgerRow { claim: ClaimRow; checks: CheckRow[]; status: ClaimStatus; + /** Everything the status was computed from (slots included), newest first. */ + evidence: EvidenceRow[]; } /** @@ -543,7 +547,7 @@ export class ClaimsReader { OPTIONAL MATCH (e)-[hs:\`${CLAIM_EDGES.HAS_SOURCE}\`]->(src) WHERE ${LIVE("hs")} RETURN ${project("e", EVIDENCE_FIELDS)} AS ev, eb.strength AS strength, eb.ref_id AS edge_ref_id, k.id AS check_id, v:StrutStepVersion AS v_is_step, v.name AS v_name, v.step_type AS v_step_type, v.content_hash AS v_hash, - src.ref_id AS src_ref, labels(src) AS src_labels, src.run_id AS src_run_id, + src.ref_id AS src_ref, labels(src) AS src_labels, src.run_id AS src_run_id, src.workflow_name AS src_run_key, hs.context AS hs_context, hs.start_time AS hs_start, hs.end_time AS hs_end, hs.post_url AS hs_url`, { ns: this.ns, id: claimId }, ); @@ -564,6 +568,7 @@ export class ClaimsReader { ref_id: r["src_ref"], node_type: labels.find((l) => !/^(Node|Data_Bank|Domain_.*)$/.test(l)), run_id: r["src_run_id"], + run_key: r["src_run_key"], context: parseContext(r["hs_context"]), start_time: r["hs_start"], end_time: r["hs_end"], @@ -583,7 +588,7 @@ export class ClaimsReader { return Promise.all( claims.map(async (claim) => { const [checks, evidence] = await Promise.all([this.checksFor(claim.id), this.evidenceFor(claim.id, subject)]); - return { claim, checks, status: claimStatus({ claim, checks, subject, evidence, activeVersion }) }; + return { claim, checks, status: claimStatus({ claim, checks, subject, evidence, activeVersion }), evidence }; }), ); } diff --git a/src/graph/verify.test.ts b/src/graph/verify.test.ts index 0da6ea5..c85f353 100644 --- a/src/graph/verify.test.ts +++ b/src/graph/verify.test.ts @@ -479,6 +479,59 @@ describe("verify pass (live Neo4j)", { skip: cfg ? false : "STRUT_TEST_NEO4J_URI assert.deepEqual([scratch["kept"], scratch["claims"]], [undefined, undefined]); }); + it("the Claims panel's HTTP door: a person authors, reads the contract with its to-dos, and answers an open slot", async () => { + const http = async (method: string, path: string, body?: unknown) => { + const res = await strut.app.request(path, { method, ...(body !== undefined ? { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) } : {}) }); + return { status: res.status, body: (await res.json()) as Record }; + }; + const SUBJECT = "/claims?kind=step&name=clip%2Fcompute-times"; + assert.deepEqual((await http("GET", SUBJECT)).body, { enabled: true, subject: STEP, claims: [] }); + assert.equal((await http("GET", "/claims?kind=nope&name=x")).status, 400); + assert.deepEqual((await http("GET", "/claims?kind=step&name=exec")).body["claims"], [], "a built-in step simply has no contract"); + + const made = await http("POST", "/claims", { + subjects: [STEP], + text: "the cut sounds natural", + checks: [{ description: "listen at the cut — code cannot hear a click", name: "ear" }, compare("{{ input.output.end }}", "-gt", "{{ input.output.start }}", { name: "bounds" })], + }); + assert.equal(made.status, 200, JSON.stringify(made.body)); + assert.equal((await http("POST", "/claims", { subjects: [STEP], text: "no checks", checks: [] })).status, 400); + assert.match((await http("POST", "/claims", { subjects: [STEP], text: "bad check", checks: [{ type: "exec", config: { command: "x" } }] })).body["error"], /not valid for step "exec"/); + + const run = await strut.run("clipper", { start: 5, len: 19 }); + await verify("clipper", run.runId); + const listed = (await http("GET", SUBJECT)).body; + const claim = listed["claims"][0]; + assert.deepEqual([claim.text, claim.speaker, claim.status, claim.openSlot, claim.unverified], ["the cut sounds natural", "person", "supported", true, 1]); + assert.deepEqual([claim.latest.content, claim.latest.mode, claim.latest.run], ["exit 0", "observed", { name: "clipper", runId: run.runId, path: "clipper/times" }]); + assert.deepEqual(claim.checks.map((k: any) => [k.name, k.external, k.publisher]), [["ear", true, "person"], ["bounds", false, "person"]]); + // The to-do: the question, and which run to look at. + assert.equal(claim.slots.length, 1); + assert.match(claim.slots[0].question, /listen at the cut.*run .* of clipper/s); + assert.deepEqual(claim.slots[0].run, { name: "clipper", runId: run.runId, path: "clipper/times" }); + + const answered = await http("POST", `/claims/${claim.id}/evidence`, { name: "clipper", runId: run.runId, supports: false, content: "audible click at 24.0s", slot: claim.slots[0].evidence }); + assert.deepEqual([answered.status, answered.body["filled"]], [200, true]); + const after = (await http("GET", SUBJECT)).body["claims"][0]; + assert.deepEqual([after.status, after.openSlot, after.slots, after.latest.by, after.latest.mode], ["refuted", false, [], "person", "asserted"], "a person's refutation on the active version wins"); + + // Edit → successor; check edit / add / retire; retire the claim. + const reworded = await http("PATCH", `/claims/${claim.id}`, { text: "the cut is inaudible" }); + assert.ok(reworded.body["id"] && reworded.body["id"] !== claim.id); + const succ = (await http("GET", SUBJECT)).body["claims"][0]; + assert.deepEqual([succ.id, succ.status, succ.checks.length], [reworded.body["id"], "unknown", 2]); + const bounds = succ.checks.find((k: any) => k.name === "bounds"); + const patched = await http("PATCH", `/checks/${bounds.id}`, { patch: { policy: "manual" } }); + assert.ok(patched.body["id"] !== bounds.id); + assert.equal((await http("DELETE", `/checks/${patched.body["id"]}`)).status, 200); + assert.match((await http("DELETE", `/checks/${succ.checks.find((k: any) => k.name === "ear").id}`)).body["error"], /last active check/); + assert.equal((await http("POST", `/claims/${succ.id}/attach`, { subject: { kind: "workflow", name: "clipper" } })).body["attached"], true); + assert.equal((await http("POST", `/claims/${succ.id}/detach`, { subject: { kind: "workflow", name: "clipper" } })).body["detached"], true); + assert.equal((await http("DELETE", `/claims/${succ.id}`)).status, 200); + assert.deepEqual((await http("GET", SUBJECT)).body["claims"], []); + assert.equal((await http("DELETE", `/claims/${succ.id}`)).status, 400, "already retired"); + }); + it("publish checks lint the new version's source; a kept run_step run is verified with EXECUTED → the step version it ran", async () => { const lint = await addClaim(STEP, "never reads process.env directly", [ { type: "exec", when: "publish", name: "env lint", config: { cmd: "bash", args: ["-c", "! grep -q 'process.env' <<< \"$SRC\""], env: { SRC: "{{ input.source }}" } } }, diff --git a/src/index.ts b/src/index.ts index cf0ec73..3720516 100644 --- a/src/index.ts +++ b/src/index.ts @@ -326,6 +326,7 @@ export { type ClaimsResult, } from "./claims-authoring.js"; export { subjectSchema, checkSpecSchema, claimSpecSchema, claimsArgSchema } from "./claims-schemas.js"; +export { claimsRoutes, type ClaimsRoutesDeps } from "./claims-routes.js"; // The verify pass — how evidence is produced (plans/claims.md §4). export { createVerifier, diff --git a/src/ledger.test.ts b/src/ledger.test.ts index 9908c3d..ca8a5e4 100644 --- a/src/ledger.test.ts +++ b/src/ledger.test.ts @@ -14,6 +14,7 @@ const row = (id: string, text: string, status: SubjectLedgerRow["status"]["statu claim: { ref_id: `ref-${id}`, id, name: text, claim_text: text }, checks: checks.map((k) => ({ ref_id: `ref-${k.id}`, name: k.id, created_at: 1, ...k })), status: { status, assertedOnly: false, unverified: 0, openSlot: false, slots: [], ...over }, + evidence: [], }); const reader = (bySubject: Record) => ({ diff --git a/web/src/api.ts b/web/src/api.ts index 3483f16..3899e5d 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -770,3 +770,86 @@ export const deleteSecret = (name: string) => fetchJSON<{ ok: true; name: string }>(`/secrets/${encodeURIComponent(name)}`, { method: "DELETE", }); + +// ── Claims (plans/claims.md) ─────────────────────────────────────────────── +// A subject's contract: how it should behave, the checks that test it, and +// what the evidence says. Graph-backed workspaces only — `enabled: false` +// otherwise, and the panel hides itself. + +export interface ClaimSubject { + kind: "step" | "workflow"; + /** Workflow name, or custom step type. */ + name: string; +} + +export type ClaimStatusValue = "supported" | "refuted" | "stale" | "unknown"; + +export interface ClaimRunRef { + /** Run-store key: a workflow name, or `step:`. */ + name?: string; + runId?: string; + path?: string; +} + +/** A check as the panel edits it: a STEP check names a registry step + * (`type` + `config`); an EXTERNAL check has only a `description`. */ +export interface ClaimCheckSpec { + type?: string; + config?: Record; + name?: string; + description?: string; + when?: "run" | "publish"; + policy?: "always" | "on_change" | "sample" | "manual"; + freshnessDays?: number; + sampleRate?: number; +} + +export interface ClaimCheck extends ClaimCheckSpec { + id: string; + name: string; + external: boolean; + publisher?: string; +} + +export interface ClaimEntry { + id: string; + text: string; + /** Who wrote it: `ai`, `person`, a seeder. */ + speaker?: string; + status: ClaimStatusValue; + /** The verdict rests on a model's or a person's word — nothing observed. */ + assertedOnly: boolean; + /** Active checks with no evidence about the active version. */ + unverified: number; + openSlot: boolean; + latest?: { content?: string; observedAt?: number; mode?: string; check?: string; checkVersion?: string; by?: string; run?: ClaimRunRef }; + /** Questions an external check is waiting on — the panel's to-dos. */ + slots: Array<{ evidence: string; check?: string; question?: string; run?: ClaimRunRef }>; + checks: ClaimCheck[]; +} + +export interface ClaimsResponse { + enabled: boolean; + subject?: ClaimSubject; + claims: ClaimEntry[]; + /** Why there is no contract here (e.g. a built-in step). */ + note?: string; + /** What this subject's paid checks have cost so far. */ + verifyCostUsd?: number; +} + +const json = (method: string, body?: unknown): RequestInit => ({ method, ...(body !== undefined ? { body: JSON.stringify(body) } : {}) }); + +export const getClaims = (subject: ClaimSubject) => + fetchJSON(`/claims?kind=${subject.kind}&name=${encodeURIComponent(subject.name)}`); +export const addClaim = (subject: ClaimSubject, text: string, checks: ClaimCheckSpec[]) => + fetchJSON<{ id: string; checks: string[] }>("/claims", json("POST", { subjects: [subject], text, checks })); +/** Rewording creates a SUCCESSOR (claims are immutable); returns its id. */ +export const editClaim = (id: string, text: string) => fetchJSON<{ id: string; superseded?: string }>(`/claims/${id}`, json("PATCH", { text })); +export const retireClaim = (id: string) => fetchJSON<{ id: string }>(`/claims/${id}`, json("DELETE")); +export const addCheck = (claimId: string, check: ClaimCheckSpec) => fetchJSON<{ id: string }>(`/claims/${claimId}/checks`, json("POST", { check })); +export const editCheck = (id: string, patch: ClaimCheckSpec) => fetchJSON<{ id: string }>(`/checks/${id}`, json("PATCH", { patch })); +export const retireCheck = (id: string) => fetchJSON<{ id: string }>(`/checks/${id}`, json("DELETE")); +/** A person's observation on a run; with `slot`, the answer to an open question. */ +export const addClaimEvidence = (claimId: string, body: { name: string; runId: string; supports: boolean; content: string; slot?: string }) => + fetchJSON<{ evidence: string; filled: boolean }>(`/claims/${claimId}/evidence`, json("POST", body)); diff --git a/web/src/app.tsx b/web/src/app.tsx index 75ff9ae..9f80733 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -24,6 +24,8 @@ import { EventsPanel } from "./components/EventsPanel"; import { EventsResizer } from "./components/EventsResizer"; import { StepRunFlyout } from "./components/StepRunFlyout"; import { ParamsFlyout } from "./components/ParamsFlyout"; +import { ClaimsFlyout } from "./components/ClaimsFlyout"; +import { claimsSummary } from "./components/ClaimsPanel"; import { PromoteFlyout } from "./components/PromoteFlyout"; import { RunInputPopover } from "./components/RunInputPopover"; import { deriveInputBindings, stepTypesIn, type InputBinding } from "./run-inputs"; @@ -115,6 +117,10 @@ export function App() { const [localParams, setLocalParams] = useState | null>(null); // Whether the Params flyout (editable) is open. const [showParams, setShowParams] = useState(false); + // The workflow's Claims flyout, and its contract as last read — null until + // probed; `enabled: false` (a filesystem workspace) hides the button. + const [showClaims, setShowClaims] = useState(false); + const [wfClaims, setWfClaims] = useState(null); // Declared promotions resolved against the selected run's output (the // "promote a winner" review surface) + whether its flyout is open. const [promotions, setPromotions] = useState([]); @@ -308,6 +314,9 @@ export function App() { setRunDrill([]); setLoadError(false); setShowParams(false); + setShowClaims(false); + setWfClaims(null); + if (selectedWf) api.getClaims({ kind: "workflow", name: selectedWf }).then(setWfClaims).catch(() => setWfClaims(null)); if (!selectedWf) { setPublishedSteps(null); setLocalSteps(null); @@ -600,6 +609,7 @@ export function App() { const stepIndex = node.customData?.stepIndex as number | undefined; if (stepId == null) return; setShowParams(false); + setShowClaims(false); setShowPromote(false); setInfoStep(null); setFlyoutStepId(stepId); @@ -709,6 +719,15 @@ export function App() { const closeFlyout = () => { setFlyoutStepId(null); setFlyoutStepIndex(null); }; + // A claim's evidence (or to-do) points at the run it came from: go look at it. + const openRunFromClaim = (workflow: string, runId: string) => { + setShowClaims(false); + closeFlyout(); + setViewVersion(null); + setSelectedWf(workflow); + setSelectedRun(runId); + }; + // Sidebar Steps catalog: grouped by tier, in the same order as the Add // Step picker. Clicking an item toggles its read-only info flyout. const stepGroups = useMemo(() => [ @@ -727,6 +746,7 @@ export function App() { const openStepInfo = useCallback((entry: StepTypeEntry) => { setShowParams(false); + setShowClaims(false); setShowPromote(false); setFlyoutStepId(null); setFlyoutStepIndex(null); @@ -887,15 +907,27 @@ export function App() { {isRunView && promotions.length > 0 && ( )} {selectedWf && localParams && Object.keys(localParams).length > 0 && ( )} + {selectedWf && wfClaims?.enabled && (() => { + // What needs attention, at a glance: refuted > a to-do > unverified. + const sum = claimsSummary(wfClaims.claims); + const tone = sum.refuted ? "bad" : sum.todos ? "todo" : sum.open || sum.total === 0 ? "open" : "ok"; + return ( + + ); + })()} + +
+
+ + How this workflow should behave. Status is computed from evidence on the active version — runs are verified + automatically. A step's own claims are on that step. + +
+ +
+ + ); +} diff --git a/web/src/components/ClaimsPanel.tsx b/web/src/components/ClaimsPanel.tsx new file mode 100644 index 0000000..6a11722 --- /dev/null +++ b/web/src/components/ClaimsPanel.tsx @@ -0,0 +1,392 @@ +import { useEffect, useState } from "preact/hooks"; +import yaml from "js-yaml"; +import * as api from "../api"; + +// ── Claims panel ─────────────────────────────────────────────────────────── +// +// A subject's CONTRACT (plans/claims.md): each claim is one sentence about how +// the step / workflow should behave; its status is COMPUTED from evidence on +// the active version — never asserted — and shown with the newest evidence +// behind it and the checks that produce it. A person can add, reword and +// retire claims and checks here, and ANSWER an external check's open question +// (a "slot"): those render first, as to-dos, because a claim waiting on a +// person reads `unknown` until someone looks. +// +// Rewording a claim or editing a check creates a successor (both are +// immutable once they have evidence), so the panel always re-reads after a +// write rather than patching local state. + +const STATUS_LABEL: Record = { + supported: "supported", + refuted: "refuted", + stale: "stale", + unknown: "unknown", +}; +const STATUS_HINT: Record = { + supported: "The newest evidence on the active version supports it.", + refuted: "Evidence on the active version refutes it — a refutation always wins.", + stale: "The evidence is about an older version; a run on the current one will refresh it.", + unknown: "Never checked on any version.", +}; + +const BLANK_CHECK: CheckDraft = { kind: "step", type: "exec", config: "cmd: test\nargs: [\"{{ input.output.ok }}\", \"=\", \"true\"]\n", name: "", description: "", when: "run", policy: "" }; + +interface CheckDraft { + kind: "step" | "external"; + type: string; + /** YAML text of the step's config. */ + config: string; + name: string; + description: string; + when: "run" | "publish"; + /** "" = let the server pick (always for free code checks, on_change for paid / external). */ + policy: "" | "always" | "on_change" | "sample" | "manual"; + sampleRate?: string; +} + +function draftOf(k: api.ClaimCheck): CheckDraft { + return { + kind: k.external ? "external" : "step", + type: k.type ?? "", + config: k.config ? yaml.dump(k.config, { lineWidth: -1, noRefs: true }) : "", + name: k.name, + description: k.description ?? "", + when: k.when ?? "run", + policy: k.policy ?? "", + ...(k.sampleRate != null ? { sampleRate: String(k.sampleRate) } : {}), + }; +} + +/** A draft as the API's check spec; throws with a readable message. */ +function specOf(d: CheckDraft): api.ClaimCheckSpec { + const common = { ...(d.name.trim() ? { name: d.name.trim() } : {}), ...(d.policy ? { policy: d.policy } : {}) }; + if (d.kind === "external") { + if (!d.description.trim()) throw new Error("Say what to look at, and why code cannot check it."); + return { ...common, description: d.description.trim() }; + } + if (!d.type.trim()) throw new Error("A step check needs a step type (e.g. exec)."); + let config: unknown = {}; + if (d.config.trim()) { + try { + config = yaml.load(d.config); + } catch (e) { + throw new Error(`Config is not valid YAML: ${(e as Error).message}`); + } + if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error("Config must be a YAML mapping."); + } + const rate = d.policy === "sample" ? Number(d.sampleRate) : undefined; + if (d.policy === "sample" && !(rate! > 0 && rate! <= 1)) throw new Error("Sample rate must be in (0, 1]."); + return { + ...common, + type: d.type.trim(), + config: config as Record, + when: d.when, + ...(d.description.trim() ? { description: d.description.trim() } : {}), + ...(rate !== undefined ? { sampleRate: rate } : {}), + }; +} + +function ago(seconds?: number): string { + if (!seconds) return ""; + const mins = Math.round((Date.now() / 1000 - seconds) / 60); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + if (mins < 60 * 24) return `${Math.round(mins / 60)}h ago`; + return `${Math.round(mins / 60 / 24)}d ago`; +} + +function CheckEditor(props: { draft: CheckDraft; onChange: (d: CheckDraft) => void; stepTypes: string[] }) { + const d = props.draft; + const set = (patch: Partial) => props.onChange({ ...d, ...patch }); + return ( +
+
+ + +
+ {d.kind === "step" ? ( + <> +
+ + set({ type: (e.target as HTMLInputElement).value })} /> + {props.stepTypes.map((t) => +
+
+ +