Skip to content

Claims, checks, evidence — the truth layer (plans/claims.md) - #9

Merged
Evanfeenstra merged 11 commits into
mainfrom
claims-step-1
Sep 18, 2026
Merged

Evanfeenstra merged 11 commits into
mainfrom
claims-step-1

Conversation

@Evanfeenstra

Copy link
Copy Markdown
Contributor

Implements the whole of plans/claims.md — the truth layer: a Claim states how a step or workflow should behave, a Check is an instrument that tests it, Evidence is what one check observed on one run, and a claim's status (supported | refuted | stale | unknown) is computed from evidence on the active version, never asserted. (Branch name says "step-1"; the PR is all eight steps plus the switch.)

Walkthrough, by commit

  1. Schema, claims.ts, status rule, graph gate (2446990) — Claim / Check / Evidence are jarvis types resolved from the :Schema meta-graph (fixture re-dumped from post-125 jarvis); a one-shot claim-schema-upgrade mirrors jarvis 124 for already-seeded DBs (only with STRUT_GRAPH_SEED_ONTOLOGY). claimStatus() computed on read per (claim, subject); ClaimsReader. Gate is the workspace (WorkspaceStore.graph), not StrutOptions.graph.
  2. Runs record what they executed (f1d346d) — run.start gains stepHashes / cassette / origin; closure.ts walks a flow's reach (loops, onError, subflows, agentTools). step:<type> / check:<id> run-store buckets. runStep persists a single-step run when the step has claims or keep: true. Only runs recorded from here on can ever be verified.
  3. Claim + check authoring (420d438) — claims-writer.ts holds the graph invariants (≥1 check per claim, edits SUPERSEDE, retire = timestamp, detach = muted edge). claims-authoring.ts is the one policy layer behind both doors: the claims arg on the publish tools, and add_claim / list_claims / edit_* / retire_* / attach_* / add_check + nine meta/* twins. Scoped authors touch only what they stamped; ai checks can't reach a grader.
  4. The verify pass (6a20648) — post-run consumer: rebuilds every subject a run observed and runs each check whose policy fires (always / on_change / sample / manual), one Evidence per (check, run, path), or a planned slot for an external check. Versions never guessed. Budgets (STRUT_VERIFY_BUDGET_USD[_PER_DAY]). Check runs carry origin: verify and are never themselves verified. add_evidence for tool-backed observations. strut.verifier, POST …/runs/:id/verify, verify_run.
  5. The ledger in tool results + [verify-notification] (7182c5d) — run_workflow / run_step results carry the contract with every check pending; the chat that launched a run is woken with the verdict (rides on the [run-notification] when the pass settles within 5s, else its own wake-up). verifier.costOf. Prompt rules 5–6.
  6. The Claims panel (1e102f0) — claims-routes.ts (GET/POST/PATCH/DELETE /claims…, actor = person); web ClaimsPanel, a collapsible section in StepEditFlyout, a workflow-level ClaimsFlyout behind a topbar button whose dot says what needs attention. Open slots render first, as to-dos.
  7. Notifications in chat / tool descriptions (47eb690, f9ec09f).
  8. One switch (ee8d975) — the layer had been gated in eight places, each re-deriving workspace.graph. Now createStrut decides once and threads claims: ClaimsAuthoring | null to every consumer. On a graph workspace it is on by default; STRUT_CLAIMS=0 or createStrut({ claims: false }) turns the whole thing off (no tools, no claims arg, no prompt section, no verify pass, panel hidden). Filesystem workspaces never have it.

Not in scope

The Hive eval chain / LLM-judged graph nodes (jarvis side) — see plans/claims.md.

Testing

  • npm test — 813/813
  • npm run test:graph against a throwaway Neo4j (STRUT_TEST_NEO4J_URI=bolt://localhost:7688) — claims-authoring, verify (incl. the switch), query, schema tests all green

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
…ves a record

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:<type>` and `check:<id>` keys map to steps/<type>/runs/
  and checks/<id>/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:<type>` 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
…roof

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
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:<id> 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
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
- 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
The layer was gated in eight places, each re-deriving `workspace.graph`
for itself (createStrut ×2, authoring ×2, claims-routes, tools ×2, prompts).
Now createStrut decides once — on where the workspace is graph-backed,
unless `claims: false` or STRUT_CLAIMS=0 — builds ONE ClaimsAuthoring, and
threads `claims: ClaimsAuthoring | null` to the authoring capability, the
chat tools, the system prompt and the /claims routes. Nothing downstream
reads `workspace.graph` any more, so the switch cannot be missed by a
future consumer. Default on a graph workspace is unchanged (on).

Graph test covers: option off, env off, option winning over env, and the
chat surface offering nothing when handed no claims layer.
@Evanfeenstra
Evanfeenstra merged commit 41fb967 into main Sep 18, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant