T2-2 — Snapshot v2: derivation-edge graph + per-rule traceability (#679)

On this page

Epic &56 / Track 2, T2-2 (#679). T1-10 (#678) + T2-3 (#684) + T2-4 (#685) made every program determination freeze an immutable, signature-bound input snapshot (ADR-028) — flat proven facts
provenance + resolved policy params + a whole-corpus ruleset content-hash. ADR-028 §2/§34–42 explicitly deferred the self-explaining derivation graph (which derived fact came from which inputs via which rule) to "v2 / Track 2". T2-2 builds it: a typed derivation-edge graph captured at determination time, inline on the snapshot (so it rides the existing JCS hash
signature + append-only guarantees), recording every derived fact’s value, its inputs, and the versioned rule/function that produced it, with #669 inferred deprivation/utility frozen as provisional derived-fact nodes. This is the architecturally-correct graph for all five program services, captured at the granularity the engine + named functions losslessly expose: field-level for decision-table inputs (zen-engine hands us the resolved fields via reference_map), node-level for expression-node inputs — whose exact field references live authoritatively in the corpus_hash-pinned ruleset; re-encoding them into the snapshot would duplicate, and risk drifting from, the source of truth (same principle as Decision B/K in Decisions: the graph does not re-encode what the pinned corpus already states authoritatively, so a re-verifier reads the pinned expression — no information is lost). The only genuinely omitted edges are two classes literally blocked by unshipped unrelated work (see Scope boundary).

NOTE
All file.rs:NNN anchors below are accurate on this branch as of 2026-06-22 and are pre-implementation — pair each with its semantic anchor (the function/call/struct name), which is the durable address; line numbers are a convenience that drifts as determine.rs / snapshot.rs evolve (references into snapshot.rs in particular shift once MR3 adds the field). MR1/MR3/MR4–7 use the named function/struct/call as the address.

Scope boundary

T2-2 delivers the determination derivation graph at its architecturally-correct granularity (see the lead): the rules engine surfaces the edges it already traces internally, the snapshot gains a v3 typed graph field, and all five program services capture both their JDM-internal and their Rust-side derivations.

In scope:

  • Rules-engine edge emissioncanopy-rules folds the zen-engine per-node execution trace (already collected, today discarded as an opaque trace blob) into a typed list of rule firings on EvaluateResponse; canopy-rules-client stops dropping it.

  • v3 snapshot schema — a derivation_graph: Option<DerivationGraph> field on DeterminationSnapshot, reusing the T2-3/T2-4 cross_program_inputs additive-typed pattern verbatim (conditional schema_version = 3, single canonical encoding of "empty", enforced refuse-unknown-version on re-verify).

  • Full per-program capture — every JDM-internal derived value (eligibility/benefit tests, deductions, per-COA booleans) and every Rust-side derived value (SNAP SE deduction, TANF earned-income split, Medicaid SOLQ→ABD flags, CAPS/WIC params lookups, the orchestrator utility/deprivation inferences) for snap/tanf/medicaid/caps/wic, captured as graph edges referencing the snapshot’s own fact leaves / policy params / cross-program inputs.

  • #669 provisional nodes — inferred deprivation + utility frozen as DerivedFactNode { is_provisional: true, … } (the substrate T2-8 uses to exclude provisional-derived chains from automated recovery).

  • ADR amendments — ADR-028 (a new Amendment 2; incl. the explicit "rule_version == corpus_hash" decision), ADR-011 (a note rule→regulation citation is not introduced here), ADR-014 (a note edges carry fact-id references, not FTI payloads, so the FTI hash-chain surface is unchanged).

Out of scope — two genuine blocks + three correctness/separation calls, each with a reason (per the "defer only if literally blocked" directive: the first two ARE literally blocked; the rest are not deferrals of in-scope work but correctness boundaries — the data is captured by reference, not omitted):

  • Medicaid TMA upstream-determination by-reference edge (TANF determination_id → TMA eligibility). Literally blocked: the TANF determination id never reaches Medicaid — it requires the unshipped tanf.case_closed event-contract change to publish det_id (ADR-028 Amendment 1; the deferred T2-3 follow-up — T2-3 landing did NOT unblock it). The TMA within-snapshot edges (the TMA-phase JDM node’s local inputs/outputs) ARE in scope.

  • FDSH-input edges. Literally blocked: canopy-medicaid store/fdsh.rs is a stub never called by determine() (ADR-028 Amendment 1) — there is no FDSH input captured to draw an edge from until a separate FDSH-gating feature ships. The edge model (FactPath::CrossProgram) can already represent it.

  • Expression-node input field-path precision — a correctness boundary, not a deferral. zen-engine’s trace gives field-level inputs for decision-table nodes (reference_map) but only the node-level input object for expression nodes; expression-node outputs ARE field-level (output per key) and are captured. The exact field references an expr- node reads live *authoritatively in the corpus_hash-pinned ruleset (the expression source); re-deriving them into the snapshot (by parsing zen-expression ASTs — and zen-expression exposes no public referenced-variable API, so this would mean a fork or a hand-rolled parser) would duplicate and risk drifting from that source of truth. So node-granular expr- inputs is the architecturally-honest unit — no information is lost (the pinned expression names the fields exactly); same principle as Decision B (corpus_hash) and K. A follow-up could *denormalize the refs into the snapshot if a measured audit need appears, but it is denormalization of already-captured data, not new capture.

  • Rule→regulation citation (a JDM node ↔ CFR/PAMMS cite). Not blocked — a separate ADR-011 capability (rule-citations.toml keyed by RuleRef) no edge here needs. Explicitly NOT built; recorded in ADR-011 so the next reader does not assume it landed.

  • A denormalized per-rule query column. corpus_hash is already the denormalized queryable column and IS the rule version (see Decisions); sub-corpus queries go through the snapshot JSONB blob until a measured need justifies a column → reporting follow-up.

Status

Step Description Status

(plan)

This execution plan + its nav.adoc entry (inserted in the flat epic-&56 list after T1-10, before T2-4, non-archive path), iterated through three contextless review rounds + an external-review pass.

Done (2026-06-22) — the plan commit.

MR1 — rule identity + engine edge emission

canopy-contracts-rules: RuleRef, RuleNodeKind, RuleFiring (full defs in Data model). canopy-rules: fold the serialized zen-engine trace into Vec<RuleFiring> in the pinned eval closure; add derivation_edges: Option<Vec<RuleFiring>> to EvaluationOutcome + EvaluateResponse (gated by the existing trace flag); a committed real 0.55.0 trace fixture + shape test + a fold proptest.

Done (2026-06-23) — merged e3f672bd (MR !665). B3a lock 736→754 for the 18 genuinely-structural trace-parsing serde_json::Value (ADR-003; maintainer-approved, net ratchet from pre-#898 757). Preceded by the #898 B3a offset paydown (757→736, merged 16d57db).

MR2 — client threading

canopy-rules-client: mirror derivation_edges; stop dropping it; evaluate_with_provenance accessor; default-None back-compat; wire round-trip test.

Done (2026-06-23) — this MR. The new accessor’s input/output serde_json::Value reuse the sibling evaluate_with_corpus_hash STRUCTURAL-VALUE markers, so B3a stays 754. Both error-body reads now carry SILENT-OK markers (the new one + the sibling’s pre-existing), ratcheting B5 308→307.

MR3 — v3 snapshot schema

canopy-contracts-eligibility: DerivationGraph, DerivationEdge, FactPath, EdgeSource, DerivedFactNode (full defs in Data model); the derivation_graph field + schema_version = 3 gating + derivation_graph_is_empty; a verify_schema_version enforcer wired at every snapshot read/verify site; canonical-bytes + JCS hash-stability proptests + v3-empty golden hash.

Done (2026-06-23) — this MR. New dep canopy-contracts-rules (RuleRef reuse). Two as-built elaborations beyond the literal data-model: (a) FactPath/EdgeSource serialize internally-tagged (tag = "kind") for a flat, queryable audit discriminator; (b) DerivationGraph::sort() homes Decision I’s deterministic ordering in the contract (so MR4–7 each call one method, not re-implement the 3-level sort) — covered by an order-independence + idempotence proptest. SnapshotError::UnsupportedSchemaVersion + SCHEMA_VERSION_MAX = 3; verify_schema_version wired at the only current deserialize-for-serving site (snap get_determination_snapshot handler — tanf/medicaid/caps/wic have no snapshot-read endpoint yet). B3a/B5/B3b flat (754/307/191): the graph types live under crates/canopy-contracts-* (outside the B3a counter) and no service gained a Value. Producers set derivation_graph: None (capture lands MR4–7).

MR4 — SNAP capture (reference impl)

canopy-snap: a rules_input_provenance field→Vec<FactPath> map; rewrite engine RuleFiring`s → snapshot `DerivationEdge`s (`EdgeSource::Jdm); emit Rust-side edges (SE deduction, alien per-member, orchestrator utility-tier inference → provisional node #669); assemble DerivationGraph in build_input_snapshot; dangling-reference + re-hash + v2-byte-identity tests.

Done (2026-06-23) — this MR. New services/canopy-snap/src/derivation.rs: firings_to_edges (the engine firing→edge rewrite, a closure resolver + optional output-prefix), build_rules_input_provenance, se_deduction_edge, utility_tier_edge (provisional, 669), finalize (dedup-by-path + sort). determine switches the main eval + the alien_eligibility::evaluate wrapper to evaluate_with_provenance, builds the provenance map, assembles the graph, and sets it on the snapshot (schema_version=3) after build_input_snapshot returns (kept ≤7 args). As-built deviations from the literal plan: (a) the graph is set in determine, not inside build_input_snapshot (arg-count); (b) alien edges are index-scoped (alien_eligibility_inputs[idx].*) not member[<person_id>]AlienEligibilityInput carries no person_id (filed #902); (c) tests are an in-src [cfg(test)] module (canopy-snap is a binary crate — no lib for tests/ to import) covering the rewrite, provenance, SE/utility builders, and finalize; the snapshot-level re-hash / v2-byte-identity properties are locked by the MR3 contract proptests, and the existing devstack integration tests now exercise v3 end-to-end (snapshot_hash redacted in insta, so unaffected). B3a/B5/B3b flat (754/307/191).

MR5 — TANF capture

canopy-tanf: engine edges (eligibility + benefit rulesets) + Rust-side compute_tanf_earned_income; #669 inferred deprivationDerivedFactNode { is_provisional: true }; dangling-ref + provisional re-hash test.

Done (2026-06-23) — this MR. New services/canopy-tanf/src/derivation.rs: build_tanf_eligibility_provenance (the Decision C map), tanf_earned_income_edge (the PAMMS 1540/1615 split as a compute_tanf_earned_income RustFn edge → earned_income.{gross,net,total_disregard} nodes), deprivation_edge (669 infer_tanf_deprivation RustFn → a provisional deprivation_basis node); re-exports the shared firings_to_edges/map_resolver/finalize. rules_client gains evaluate_eligibility_with_provenance + calculate_benefit_with_provenance (+ a generic evaluate_namespaced_with_provenance); determine threads both, assembles the graph, and sets schema_version = 3 before hashing. As-built deviations: (a) gated by a prerequisite fix: MR #903 (firings_to_edges now drops the bare input/context zen passThrough envelope roots — they were falling back to dangling Derived refs; already shipping in snap MR4) merged before this MR; (b) the dt-elig PR-failures column is the expression len(input.personal_responsibility_failures) (the fold reports a decision-table column’s field verbatim) — the provenance maps that exact form to the captured eligibility_input.personal_responsibility_failure_count; (c) citizenship_verified/residency_verified added to program_input.eligibility_input for snapshot completeness (the provenance references citizenship_verified); (d) the tanf-benefit-calculation expr-benefit edge has empty inputs after the #903 root-drop (its four outputs are still captured as nodes); (e) tests are an in-src [cfg(test)] module (canopy-tanf is a binary crate) covering the provenance map, both RustFn builders, the assembled-graph no-dangling/no-dup invariant, and the denied-no-benefit-nodes property, PLUS one integration test (tanf_provisional_deprivation_graph_survives_rehash) exercising the provisional path end-to-end (v3 + provisional node + no-dangling + re-hash). B3a/B5/B3b flat (754/307/191).

MR6 — Medicaid capture (per-subject)

canopy-medicaid: per-member snapshot (ADR-035, landed) each carrying its member-local graph; engine edges (MAGI/non-MAGI/CHIP/cascade/denial/hierarchy) + the full Rust-side ABD chain (derive_abd_flags_from_solq, inputs FactPath::CrossProgram into cross_program_inputs.solq); TMA within-snapshot edges (NOT the blocked by-ref link).

Done (2026-06-23) — this MR. New services/canopy-medicaid/src/derivation.rs: subject_firing_edges (the per-subject ruleset firing→edge rewrite — no provenance map needed, see below) + abd_flags_edge (the Rust ABD chain → five flag nodes; EdgeSource::RustFn { fn_name: "derive_abd_flags_from_solq" }; CrossProgram inputs into solq[person_id=<uuid>].{lost_ssi_due_to_cola_flag,benefit_category,lost_ssi_as_disabled_child_flag} when the subject has a SOLQ leaf, empty when absent — Decision G). rules_client gains with_provenance variants for evaluate{magi,non_magi,chip,hierarchy,tma_phase} (+ a generic evaluate_namespaced_with_provenance); evaluate_member threads the firings into a per-subject Vec, assembles the subject-local graph, and sets schema_version = 3 before hashing. Captured engine edges: MAGI / non-MAGI / CHIP / hierarchy / TMA + the ABD RustFn chain. As-built deviations: (a) each Medicaid ruleset is a separate engine eval whose expression nodes see only the input/context envelope roots (dropped by #903), so those edges carry derived output values as nodes with empty inputs — no provenance map is needed (the field-precise expression-input deferral, Decision K); (b) the ABD chain is always captured (defaults when SOLQ-absent), so every Medicaid snapshot is now v3 (the existing SOLQ-present/absent snapshot tests updated from v2/v1 → v3 — a legitimate version change, the graph is now present); (c) medicaid-denial-reasons (per-COA join_all) + medicaid-cmd-cascade-priority (determination-level) are NOT captured as graph edges — their derived values already ride program_input.cascade_evaluations / program_input.priority_order (no audit data lost; cf. Decision K), filed as #904 /relate #679. Tests: in-src unit tests for both builders + the two devstack integration tests (medicaid_snapshot_freezes_solq_projection… and …omits_cross_program_inputs_when_no_solq) enriched to assert the v3 ABD graph (SOLQ-present CrossProgram refs resolve; SOLQ-absent no cross-program edge; no dangling Derived/CrossProgram; no-dup-path; per-subject re-hash). Verified end-to-end: v3 snapshots with 4 Jdm + 1 RustFn edges, the SOLQ-derived flag values, and zero dangling refs. B3a/B5/B3b flat (754/307/191).

MR7 — CAPS + WIC capture (per-subject)

canopy-caps (per-child: income gate + activity/age/copayment Rust) + canopy-wic (per-participant: categorical/income ruleset + adjunctive/food-package/cert-date Rust). Smaller; co-shipped.

Done (2026-06-23) — this MR. New services/canopy-caps/src/derivation.rs (subject_firing_edges + caps_rust_edges: activity_eligible/age_eligible/copayment_weekly_cents RustFn nodes) and services/canopy-wic/src/derivation.rs (subject_firing_edges + wic_rust_edges: adjunctive_eligible/food_package/certification_end_date RustFn nodes; nutritional_risk_documented is a DB lookup, captured in program_input, not a derived node). Both services call the shared RulesClient::evaluate_with_provenance directly (no typed wrapper — CAPS/WIC use the raw client), assemble the per-subject graph, and set schema_version = 3 before hashing. As-built deviations: (a) both eligibility rulesets are expression nodes (CAPS one income node; WIC chained categorical→income), so the engine edges have empty/intermediate inputs after #903 and capture the derived OUTPUT values as nodes (Decision K) — no provenance map; (b) CAPS/WIC carry no itemised fact leaves, so the RustFn-edge inputs are FactPath::Input into program_input (Decision F); the WIC adjunctive gate also reads the derived income_eligible (FactPath::Derived); (c) the Rust-side gates are always captured, so every CAPS/WIC snapshot is now v3 (the existing re-hash tests have no schema_version assertion, so they were unaffected; new *_captures_v3_derivation_graph integration tests added); (d) the duplicated subject_firing_edges + rust_fn_edge helpers (medicaid/caps/wic) are filed for shared-crate extraction as #905 /relate #679 (DRY follow-up). Tests: in-src unit tests for both *_rust_edges builders + the envelope-root drop, and per-program devstack integration tests asserting the v3 graph (expected nodes, no dangling Derived, no-dup-path, per-subject re-hash). Verified end-to-end: CAPS v3 (1 Jdm + 3 RustFn edges), WIC v3 (2 Jdm + 1 RustFn edge), zero dangling refs. B3a/B5/B3b flat (754/307/191).

MR8 (FINAL) — ADRs + docs + status flip

ADR-028 Amendment 2; ADR-011 + ADR-014 notes; data-models/api pages; api-docs --update; CHANGELOG; master plan T2-2 → Done; this plan → Done + As-built; .claude/CLAUDE.md status; file the blocked/deferred follow-ups (before merge). Closes #679.

Done (2026-06-23) — this MR. ADR-028 Amendment 2 (the realized derivation graph + the rule_version == corpus_hash decision + provisional nodes + the granularity boundary + deferred edges); ADR-011 note (rule→regulation citation NOT introduced) + ADR-014 note (edges reference FTI by id, not value); the five data-models/canopy-*.adoc snapshot rows + api/canopy-rules.adoc (derivation_edges on /evaluate) updated + cargo xtask api-docs --update; CHANGELOG == Unreleased; master plan T2-2 → Done + epic summary; this plan → Done + As-built + nav Active→Archive. Follow-ups filed/relate #679: TMA-upstream by-ref + FDSH (blocked), #904 (denial/cascade edges), #905 (DRY hoist), full-ToSchema sweep, rule→citation, field-precise expr inputs, denormalized per-rule query column. .claude/CLAUDE.md carries no per-T2 issue status table (status lives in the master plan), so no flip there. Closes #679.

Epic: &56
Issue: #679 — a single issue delivered as 8 dependency-sliced MRs (MR1→MR3 foundation / MR4 reference / MR5–7 per-program / MR8 docs+close); the slicing rationale is the gitlab-issue-mr-standards "one MR per issue unless justified" justification: each MR is independently reviewable + mergeable + leaves the tree green, and bundling would make one unreviewable diff across 8 crates. Relates to #679 on MR1–7; Closes #679 on MR8.
Branches: feat/fact-authoring-t2-2-{engine-edges,client,v3-schema,snap,tanf,medicaid,caps-wic,docs}, each cut fresh from main (not stacked); regular merge commits, never squash (git-and-mr-workflow).
Merge order (mandatory — branches are not stacked, so each must be cut from a main that already has its deps): MR1 → MR2 + MR3 → MR4 → {MR5, MR6, MR7} → MR8. MR3 adds a canopy-contracts-eligibilitycanopy-contracts-rules dependency (that dep does not exist today — crates/canopy-contracts-eligibility/Cargo.toml ends its deps at line 20; MR3 adds canopy-contracts-rules = { workspace = true }) and imports RuleRef/RuleNodeKind, so MR3’s branch must be cut after MR1 lands. MR4 consumes MR2’s client accessor + MR3’s snapshot types, so it follows both. MR5–7 copy MR4. MR8 follows all.

Context

ADR-028 §2: "v1 is the flat input snapshot + per-fact provenance + resolved policy params
ruleset corpus content-hash; the self-explaining derivation graph — derived facts with their derivation edges and per-rule versions — is v2 (Track 2)."
§34–42: v1 "does NOT include the per-fact derivation graph (which derived fact came from which inputs via which rule)."

Today a determination computes many derived values and throws the structure away:

  • The orchestrator’s infer_utility_tier / infer_tanf_deprivation (services/canopy-eligibility/src/orchestrator.rs:817-831) are pure Rust fns whose inputs are discarded; their outputs ride ApplicationContext into the program services and are consumed as ruleset inputs. T2-2 re-attributes them as Rust-side derived nodes (see Decision H) — the inputs are the household’s expense leaves (utility) / member facts (deprivation), which the snapshot already carries, so the edge is reconstructable at snapshot assembly.

  • SNAP se_deduction::compute (services/canopy-snap/src/determine.rs:202), the per-member alien check (:309-322), TANF compute_tanf_earned_income (services/canopy-tanf/src/determine.rs:97-210), Medicaid derive_abd_flags_from_solq (services/canopy-medicaid/src/determine.rs:86-109) — Rust derivations, inputs not recorded.

  • The eligibility rulesets (evaluate_with_corpus_hash, e.g. snap determine.rs:344-355) return all JDM-computed intermediates in the output JSON; only ~5 wire fields are parsed and the rest discarded. The engine also collects a full per-node trace and the HTTP contract types it as an opaque Option<serde_json::Value> (crates/canopy-contracts-rules/src/rule_sets.rs:34-49), which canopy-rules-client then drops (crates/canopy-rules-client/src/lib.rs).

So a re-verifier can confirm that inputs produced a snapshot_hash, but cannot explain how a specific derived fact arose. T2-2 supplies that as a typed, signature-bound graph — and because the zen-engine trace already carries per-node input/output bindings (verified below), this is principally about typing + capturing a signal we already produce.

Empirical feasibility (verified against the locked library). zen-engine 0.55.0 (Cargo.lock): DecisionGraphResponse.trace: Option<HashMap<Arc<str>, DecisionGraphTrace>> (graph.rs:247-252); DecisionGraphTrace { input: Variable, output: Variable, id: Arc<str>, trace_data: Option<Variable>, order: u32, … } — per-node input + output + stable node id
deterministic order, all pub (tracer.rs:79-87). Decision-table nodes serialize a reference_map (input_field → resolved Variable) into trace_data (nodes/decision_table/mod.rs:161-207). No zen-engine fork, PR, or shadow pass is needed. The canopy engine already requests the trace and serializes it to a serde_json::Value inside the pinned closure (services/canopy-rules/src/engine.rs:336-353) — so the fold must consume the serialized JSON (the library’s DecisionTableRowTrace fields are private and never cross that boundary). The trace carries no node kind, and its output Variable is passThrough-merged, so the fold joins each trace entry to the loaded DecisionContent (the parsed JDM graph the engine already holds) for the node’s kind + declared outputs — see Decision M. MR1 pins the exact serialized 0.55.0 shape with a committed real-trace fixture + a shape test, so a future library bump cannot silently break the fold.

Decisions

Decision Resolution

A — rule identity = RuleRef, NOT sub-file semantic ids

A JDM node’s _id (e.g. r-gi-pass) is a document-local label, freely reused/rewritten across edits — no durable sub-file identity. The stable address of a rule site is RuleRef (full def in Data model): ruleset_name (the JDM name, e.g. georgia-snap-eligibility) + node_id (the JDM graph node id, e.g. dt-gross-income) + node_kind + rule_id_in_node (the winning decision-table row _id; None for expression/function nodes). The JDM format does not change — adding a stable-id/version field would be unenforceable + redundant. Only derivation nodes become firings: node_kind ∈ {DecisionTable, Expression, Function, Decision}. The zen tracer skips only switch nodes (verified, tracer.rs:27), so input and output (graph terminal) nodes ARE in the trace — the fold explicitly skips input/output/ switch (they route/inject/select, they don’t derive). node_kind is not in the trace (DecisionGraphTrace has no kind, tracer.rs:79); the fold reads it from the joined DecisionContent node (Decision M).

B — rule_version IS the corpus_hash (no per-rule semver)

The only on-disk version is corpus-level (services/canopy-rules/src/engine.rs:83-95,146). A rule and the policy-param table it reads must stay mutually consistent and ship together; a per-rule semantic version that could drift from its param version would be a false guarantee. So "per-rule versioning" (the issue title) = per-rule traceability (RuleRef) at corpus version granularity (corpus_hash). Tradeoff (stated, not hidden): any single-rule change rotates the whole corpus_hash, so the hash alone cannot attribute which rule changed — per-rule attribution comes from corpus diffs + git history of the JDM files, not from the hash. This is the honest version unit; ADR-028 Amendment 2 states it verbatim to foreclose re-litigation. The corpus_hash lives on EdgeSource::Jdm (it versions rule firings only); Rust-side edges are versioned by service_version instead (Decision H).

C — engine emits field-path firings; the program service maps to FactPath

Two coordinate systems. The engine knows only evaluation field paths — and these are namespaced: rules_input is { "input": { … }, "context": { "thresholds": { … } } } (verified, snap determine.rs:274), so a trace input key is e.g. input.gross_earned_income or context.thresholds.gross_income_limit. The engine cannot know snapshot fact ids. So canopy-rules emits RuleFiring with these exact namespaced field-path strings. The program service — which assembled rules_input from facts — owns a rules_input_provenance: BTreeMap<String, Vec<FactPath>> keyed by those exact emitted paths (each input.* field → its source FactPath::Leaf/Input, aggregates like input.gross_earned_income → all contributing income leaves; each context.thresholds.<k>FactPath::Param { key: "<k>" }, stripping the context.thresholds. wrapper because policy_params stores the thresholds object unwrapped, snap determine.rs:731). It rewrites firings into snapshot DerivationEdge`s with typed `FactPath endpoints. This map is the only place the field→fact correspondence is knowable; explicit, per-program, dangling-ref-tested.

D — inline derivation_graph field, not a side table

Add derivation_graph: Option<DerivationGraph> to DeterminationSnapshot, mirroring cross_program_inputs (crates/canopy-contracts-eligibility/src/snapshot.rs, the field
its *_is_empty predicate, current ~103-126). Inline ⇒ the graph rides the existing canonical_bytes()snapshot_hash → signature chain and the append-only trigger automatically; no second hash surface, no join at re-verify.

E — schema_version = 3 iff a non-empty graph; single empty encoding, enforced

The version is driven by a non-empty graph, not mere Some: schema_version = 3 iff derivation_graph.as_ref().is_some_and(|g| !g.is_empty()) (else the existing 2/1 logic). The derivation_graph_is_empty skip_serializing_if predicate returns true for both None and Some(empty), so both omit the field AND both compute the same version — so they canonicalize byte-identically (resolving the version/encoding contradiction: an empty graph is never v3). v3 holds regardless of whether cross_program_inputs is also present (the graph’s non-emptiness alone determines it). The "refuse unknown version" contract is today only a docstring (snapshot.rs schema_version doc, current ~61-70) — there is no validator. MR3 adds DeterminationSnapshot::verify_schema_version(&self) → Result<(), SnapshotError> (reject > 3) and calls it at every site that deserializes a snapshot for serving/verification (the program-service snapshot-read endpoints — e.g. snap store/mod.rs:163 Json<DeterminationSnapshot> — and any reporting reader).

F — FactPath addresses the frozen snapshot by path; fact_id is optional metadata

FactPath { Leaf { path, fact_id? } | Input { path } | Param { key } | CrossProgram { path } | Derived { path } } (full def + grammars in Data model). The earlier Leaf { fact_id } was wrong: IncomeFactLeaf.fact_id is Option (snapshot.rs:169), MemberLeaf has no fact_id (snapshot.rs:242), TANF/Medicaid populate many as None, and CAPS/WIC store no itemised income leaves at all (caps determine.rs:323, wic determine.rs:368). So a Leaf addresses its position in the immutable snapshot (income[2], household.members[0]) — always resolvable — and carries fact_id only as the corpus link when present. Programs that capture inputs in program_input rather than as itemised leaves (CAPS/WIC) use FactPath::Input { path }. Edges reference snapshot locations, never copy values; a derived fact is identified by its DerivedFactNode.path and referenced via Derived { path } (no separate content-hash id — the whole snapshot is already hashed + signed). Referencing FTI/IEVS-derived data (Medicaid SOLQ) by reference means edges add no new FTI surface (ADR-014 note).

G — derived facts are nodes carrying their value; provisional is per-node (#669)

DerivedFactNode { path, value, is_provisional, provisional_reason }. The node carries the derived value itself (serde_json::Value, STRUCTURAL — the JDM output value from the producing firing, or the Rust fn result) so the graph is genuinely self-explaining (value + provenance), not topology-only — this is what satisfies the lead’s "every derived value". (These values are otherwise discarded today: only ~5 wire fields of the rules output are kept, the intermediates thrown away.) #669: both the inferred utility tier (SNAP, from expenses) and the inferred deprivation basis (TANF, from household composition) are frozen as is_provisional: true nodes — committing the hedge: an inferred value is provisional; a worker-verified one (when such a path exists) would be false. Today TANF deprivation_provisional (canopy-tanf determine.rs:512) is the only explicit flag; utility is always inferred → provisional. Per-node (not snapshot-level) so T2-8 excludes only the affected chain. Medicaid SOLQ-absent ABD flags default to false — a default, not a provisional inferenceis_provisional: false (do not extend #669 to them).

H — Rust-side derivations are EdgeSource::RustFn { fn_name, service_version }

Rust-side derivations are not JDM firings — they have no rule_ref and are not versioned by the rules corpus. So EdgeSource::RustFn { fn_name, service_version } carries the Rust identity: fn_name (the function, e.g. infer_utility_tier, se_deduction::compute, derive_abd_flags_from_solq) + service_version = the determination’s existing program_service_version (bound at snap store/mod.rs:75) — the binary that ran the Rust logic. (This is why rule_ref/corpus_hash are per-variant on EdgeSource::Jdm, not mandatory top-level edge fields — the earlier shape couldn’t represent a RustFn edge.) The orchestrator inferences (infer_utility_tier / infer_tanf_deprivation, which run pre-dispatch and ride ApplicationContext) are re-attributed by the consuming program service as RustFn edges whose outputs is the provisional DerivedFactNode and inputs are the contributing snapshot leaves (utility ← expense leaves; deprivation ← member facts). They are recorded derived facts, not silent inputs.

I — graph deterministically sorted via explicit sort keys (not Ord derives)

DerivationGraph.edges sorted by (source key, sorted input paths, sorted output paths); nodes by path; inputs/outputs within an edge sorted — byte-stable JCS (same discipline as SOLQ-by-person_id). Because DerivedFactNode/DerivationEdge carry a serde_json::Value (the derived value), they cannot derive Ord — sorting is via explicit sort_by_key on the string projections (each FactPath/EdgeSource IS Ord, being pure strings; the Value-bearing structs are not). The source key for an edge is the EdgeSource (Jdm’s (ruleset_name, node_id, rule_id_in_node) or RustFn’s fn_name). The version (corpus_hash for Jdm, service_version for RustFn) rides EdgeSource so an edge extracted as an appeals/QC exhibit is self-contained.

J — hybrid edge computation (the only correct split)

JDM-internal edges = the engine (only it has the trace). Rust-side edges (EdgeSource::RustFn) = the program service (only it owns the Rust fn + its inputs). Both merge into one DerivationGraph at snapshot assembly. SNAP (MR4) is the reference implementation the other four follow.

K — edge breadth: terminal + named nodes the trace emits, not sub-node arithmetic

Capture the values a human auditor reads: every node the zen trace emits (each test, the deductions/benefit nodes, per-COA booleans) + every named Rust fn. The ruleset itself (pinned by corpus_hash) remains the source of any sub-node arithmetic; the graph does not re-encode it. (Context’s "the rest discarded" describes current code behavior, not the graph’s intended breadth.)

L — snapshot cardinality follows ADR-035 (landed, Accepted 2026-06-16)

SNAP + TANF are household-level → one snapshot, one household graph (per-member facts carry their person_id on the leaf; a per-member derived node’s path is member-scoped, see Data model). Medicaid (per member), CAPS (per child), WIC (per participant) emit per-subject snapshots (for member in &ctx.members, e.g. canopy-medicaid determine.rs:220); each per-subject snapshot carries its own subject-local graph (paths are subject-local, no member[…​] prefix needed within a per-subject snapshot).

M — the fold joins the trace to the loaded DecisionContent (kind + declared outputs)

The serialized trace carries id/input/output/trace_data/order but no node kind and its output Variable is passThrough-merged (zen merges a node’s input into its output, transform_attributes.rs:108; canopy rulesets use passThrough heavily — snap-eligibility.json:41), so raw output keys would over-record (every passed-through input as a "derived output"). The fold therefore joins each trace entry’s id back to the ruleset’s parsed DecisionContent — the engine holds it via its NamedFilesystemLoader/CachedLoader (engine.rs:214,249-258, loadable by ruleset name on the main task after the pinned eval returns the serialized trace). From the joined node it reads (a) the kindRuleNodeKind (skipping input/output/switch), and (b) the node’s declared output fields (decision-table output columns / expression output keys) → the RuleFiring.outputs (each paired with its value from the trace output). Inputs come from the trace (decision-table reference_map field-level; expression node-granular). So the fold = runtime trace ⋈ static graph content, keyed by node id. MR1 owns this join.

Data model

New types, matching the existing snapshot’s serde discipline (which deliberately omits deny_unknown_fields for forward-compat, snapshot.rs:56-57; Option fields use skip_serializing_if = "Option::is_none"). serde_json::Value appears ONLY for genuinely heterogeneous derived values (the JDM output of a node is schema-per-ruleset, ADR-003) and carries the // STRUCTURAL-VALUE annotation, exactly like the existing program_input/policy_params fields — there is no business logic over it.

canopy-contracts-rules (MR1) — the engine’s typed firing (evaluation-coordinate space):

pub enum RuleNodeKind { DecisionTable, Expression, Function, Decision }
// input / output / switch nodes are NOT derivations (see Decision A) — the fold never emits them.

pub struct RuleRef {
    pub ruleset_name: String,      // JDM `name`, e.g. "georgia-snap-eligibility"
    pub node_id: String,           // JDM graph node id, e.g. "dt-gross-income"
    pub node_kind: RuleNodeKind,   // from the JOINED DecisionContent node, NOT the trace (the trace has no kind)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rule_id_in_node: Option<String>, // winning decision-table row `_id`; None for expr/function
}

pub struct DerivedValue {
    pub field: String,             // namespaced eval coordinate the node DECLARES it produces (from DecisionContent)
    #[schema(value_type = Object)]
    pub value: serde_json::Value,  // STRUCTURAL-VALUE: the produced value, read from the trace `output` Variable
}

pub struct RuleFiring {
    pub rule_ref: RuleRef,
    pub corpus_hash: String,       // == EvaluateResponse.corpus_hash
    pub inputs: Vec<String>,       // namespaced eval field paths READ — decision-table: `reference_map` keys
                                   // (field-level); expression/function: the node input-object keys (node-granular)
    pub outputs: Vec<DerivedValue>,// keys the node DECLARES it produces (joined DecisionContent), each with its value
                                   // — NOT the passThrough-merged `output` Variable keys (Decision A / MR1)
}

RuleNodeKind/RuleRef/RuleFiring derive Serialize, Deserialize, Clone, Debug, PartialEq
utoipa::ToSchema; RuleRef/RuleNodeKind additionally derive Eq, Hash, PartialOrd, Ord (pure string/enum — usable as a sort key). RuleFiring/DerivedValue carry a serde_json::Value so they are not Ord/Eq/Hash; ordering is via explicit sort keys (Decision I).

canopy-contracts-eligibility (MR3) — the snapshot graph (snapshot-coordinate space); RuleRef re-used via a new dep on canopy-contracts-rules:

pub enum FactPath {
    // Address a location in THIS frozen snapshot by path — stable because the snapshot is
    // immutable. fact_id is OPTIONAL metadata (absent for members + many program leaves).
    Leaf        { path: String, #[serde(default, skip_serializing_if = "Option::is_none")] fact_id: Option<String> },
                                     //   path into `facts`, e.g. "income[2]", "household.members[0]"
    Input       { path: String },    //   path into `program_input` (programs with no itemised leaves, e.g. CAPS/WIC)
    Param       { key: String },     //   a `policy_params` key (post-unwrap, e.g. "gross_income_limit")
    CrossProgram{ path: String },    //   a `cross_program_inputs` path, e.g. "solq[person_id=<uuid>].lost_ssi_due_to_cola_flag"
    Derived     { path: String },    //   a `DerivedFactNode.path` in this graph
}

pub enum EdgeSource {
    Jdm    { rule_ref: RuleRef, corpus_hash: String },      // a JDM firing, versioned by the rules corpus
    RustFn { fn_name: String, service_version: String },    // a program-service Rust derivation, versioned by
                                                            // the determination's `program_service_version`
}

pub struct DerivationEdge {
    pub inputs:  Vec<FactPath>,      // deterministically sorted (Decision I)
    pub outputs: Vec<FactPath>,      // the `Derived` node(s) this edge produces; sorted
    pub source:  EdgeSource,         // carries the per-kind version (corpus_hash | service_version) — Decision A/H
}

pub struct DerivedFactNode {
    pub path: String,                // the derived fact's coordinate (identity in this snapshot; see grammar)
    #[schema(value_type = Object)]
    pub value: serde_json::Value,    // STRUCTURAL-VALUE: the derived value itself — the graph is self-explaining,
                                     // not topology-only (Decision G); from the producing firing's DerivedValue
    pub is_provisional: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub provisional_reason: Option<String>,
}

pub struct DerivationGraph {
    pub edges: Vec<DerivationEdge>,  // sorted by (source key, input paths, output paths) — Decision I
    pub nodes: Vec<DerivedFactNode>, // sorted by path
}

FactPath/EdgeSource are Ord (pure strings) and usable as sort keys; DerivationEdge, DerivedFactNode, DerivationGraph carry a Value (or are sorted by projection) and are not Ord — they sort via explicit sort_by_key (Decision I). All derive ToSchema.

path grammars. A FactPath::Leaf.path addresses snapshot.facts positionally (income[2], assets[0], expenses[1], household.members[0]) — stable within the frozen snapshot; fact_id is carried alongside as the corpus link when the leaf has one. A FactPath::Input.path addresses program_input for programs that capture inputs there rather than as itemised leaves (CAPS/WIC). A DerivedFactNode.path is a dotted derived coordinate (net_income, gross_income_test, se_deduction.net, benefit_amount); in a household snapshot (SNAP/TANF) a per-member derived value is member-scoped member[<person_id-uuid>].<field>; in a per-subject snapshot (Medicaid/CAPS/WIC) paths are subject-local (no member[…​] prefix). The path is the node’s identity; uniqueness within a snapshot is asserted in the dangling-reference test.

Implementation

Eight MRs under #679, sliced by dependency (foundation → reference → per-program → docs). Only MR8 touches .claude/CLAUDE.md status tables. Each commit builds green; per commit the pre-commit token gate + a fresh J1–J8 subagent over the staged diff (pre-commit-token-protocol), reported as text. The (plan) commit (this .adoc + the nav entry) lands first.

MR1 — Rule identity + engine edge emission

Files: crates/canopy-contracts-rules/src/rule_sets.rs, services/canopy-rules/src/engine.rs, services/canopy-rules/src/api/mod.rs, services/canopy-rules/tests/ (+ a committed trace fixture).

  • Add RuleNodeKind/RuleRef/DerivedValue/RuleFiring (Data model) to canopy-contracts-rules.

  • The fold (Decision M): join the serialized trace to the parsed DecisionContent. After the pinned eval returns the serialized trace (a serde_json::Value keyed by node id), run the fold on the main task (where self.loader is accessible): load the ruleset’s DecisionContent by name (cached, engine.rs:214,249-258) and index its nodes by id. For each trace entry, sorted by order:

    • Look up the joined DecisionContent node by id. Skip input / output / switch nodes (only switch is skipped by the tracer, so input/output ARE present — tracer.rs:27); they are not derivations.

    • node_kind ← the joined node’s kind (NOT the trace — the trace has none).

    • outputs ← the node’s declared output fields (decision-table output columns / expression output keys from DecisionContent), each paired with its value read from the trace output Variable — NOT the raw passThrough-merged output keys (which would over-record passed-through inputs as derived outputs; transform_attributes.rs:108).

    • inputs ← decision-table: trace_data.reference_map keys (field-level) + the winning row _id (→ rule_id_in_node); expression/function: the node’s input-object keys (node-granular).

    • Defensive: a trace id with no matching DecisionContent node, or absent trace_data/reference_map, → skip the field-level refinement (node-granular inputs from the trace input keys); never panic. Build Vec<RuleFiring> stamped with the response corpus_hash.

  • EvaluationOutcome (engine.rs:222-226) + EvaluateResponse (rule_sets.rs:35-49) gain #[serde(default, skip_serializing_if = "Option::is_none")] pub derivation_edges: Option<Vec<RuleFiring>>, populated only when trace was requested (computing edges needs the trace; determine() already requests it). The opaque trace field is unchanged.

  • Tests: (1) a recorded-trace shape test — capture a real zen-engine-0.55.0 trace from a SNAP-eligibility evaluation, commit it at services/canopy-rules/tests/fixtures/snap-eligibility-0.55.0-trace.json (the raw serialized serde_json::Value as produced inside the pinned closure, engine.rs:336-353), and assert the fold deserializes it
    the fields it depends on (input/output/id/order, decision-table trace_data reference_map + row _id) are present (this is the version pin). (2) a proptest over synthetic serialized-trace structures asserting the fold is deterministic (same input → same output) and total (no panic on any shape, incl. missing trace_data). (3) a RuleFiring/RuleRef JCS-stability proptest (the contract type itself, mirroring the MR3 snapshot-type proptests): all RuleNodeKind variants × rule_id_in_node None/Some serialize → JCS canonical bytes → deserialize round-trip stable.

MR2 — Client threading

Files: crates/canopy-rules-client/src/lib.rs.

  • Add #[serde(default)] pub derivation_edges: Option<Vec<RuleFiring>> to the client’s EvaluateResponse mirror (default None — back-compat with an older engine, mirroring the corpus_hash empty-string default); stop discarding it.

  • Add the full accessor, mirroring evaluate_with_corpus_hash (lib.rs:144) exactly:

    pub async fn evaluate_with_provenance(
        &self,
        rule_set_name: &str,
        context_type: &str,
        context_id: Uuid,
        input: serde_json::Value,      // STRUCTURAL-VALUE (ADR-003)
        token: Option<&str>,
    ) -> Result<(serde_json::Value, String, Option<Vec<RuleFiring>>), ApiError>

    It requests the trace via the ?trace=true query parameter — verified: the engine toggle is EvaluateParams { trace: bool } (crates/canopy-contracts-rules/src/rule_sets.rs:18, parameter_in = Query), read by the handler as Query<EvaluateParams>params.trace (services/canopy-rules/src/api/mod.rs:145,155); it is not a body field on EvaluateRequest (the client’s body, lib.rs:29, has no trace field and gains none). So evaluate_with_provenance appends ?trace=true to the POST URL (evaluate_with_corpus_hash posts without it); returns (output, corpus_hash, derivation_edges). evaluate_with_corpus_hash stays unchanged for edge-uninterested callers.

  • Test: a client round-trip asserting firings survive the wire via evaluate_with_provenance; and that evaluate_with_corpus_hash is unchanged — still the (output, corpus_hash) 2-tuple, still posts without ?trace=true (it does not gain an edges return).

MR3 — v3 snapshot schema

Files: crates/canopy-contracts-eligibility/src/snapshot.rs (+ Cargo.toml dep on canopy-contracts-rules), crates/canopy-contracts-eligibility/tests/snapshot_roundtrip.rs.

  • Add the MR3 types (Data model). Carriage: #[serde(default, skip_serializing_if = "derivation_graph_is_empty")] pub derivation_graph: Option<DerivationGraph> appended after cross_program_inputs; derivation_graph_is_empty returns true for both None and Some(empty) (mirroring cross_program_inputs_is_empty). schema_version computed as 3 iff a non-empty graphderivation_graph.as_ref().is_some_and(\|g\| !g.is_empty()) (else the existing 2/1 logic), so None and Some(empty) agree on both version and encoding (Decision E). Update the schema_version docstring to document v3.

  • Add DeterminationSnapshot::verify_schema_version(&self) → Result<(), SnapshotError> (reject > 3) and call it at every snapshot deserialize-for-verification site (Decision E).

  • Tests: extend snapshot_roundtrip.rsarb_derivation_graph() covering all five FactPath variants + provisional/non-provisional nodes + empty/populated; set schema_version = 3 iff the graph is non-empty; extend the canonical-bytes/JCS round-trip proptests to v3; a bidirectional empty-equivalence golden test asserting derivation_graph = None is byte-identical to the pre-MR3 encoding in both directions: (i) graph=None + no cross_program_inputs → identical canonical bytes + hash to a v1 snapshot; (ii) graph=None + cross_program_inputs present → identical to a v2 snapshot (so adding the field never rotates an existing snapshot’s hash — mirroring the existing cross_program-empty golden pin); assert a reader’s verify_schema_version rejects a hypothetical v4.

MR4 — SNAP capture (reference implementation)

Files: services/canopy-snap/src/determine.rs, services/canopy-snap/src/alien_eligibility.rs (+ a derivation mapping module — if a new .rs file, it carries the // SPDX-License-Identifier: AGPL-3.0-or-later header), services/canopy-snap/tests/.

  • Switch the main eval to evaluate_with_provenance. Also thread provenance through the local wrapper alien_eligibility::evaluate (alien_eligibility.rs:95) — today it discards the trace/firings; it must return them so determine.rs can capture the alien edges (Decision C/MEDIUM). Build a rules_input_provenance: BTreeMap<String, Vec<FactPath>> keyed by the exact namespaced emitted paths (Decision C) as each contributing fact/param is folded into rules_input (the assembly around determine.rs:~274-292; register in each block — do not assume one contiguous loop). Each contribution registers itself, e.g.:

    // summing earned income into rules_input["input"]["gross_earned_income"] — key is the
    // NAMESPACED emitted path (Decision C); fact_id is optional metadata, leaf addressed by position:
    for (i, inc) in earned.iter().enumerate() {
        provenance.entry("input.gross_earned_income".into()).or_default()
            .push(FactPath::Leaf { path: format!("income[{i}]"), fact_id: inc.fact_id.clone() });
    }
    // threshold fields: emitted as context.thresholds.<k>, but policy_params stores <k> unwrapped:
    provenance.entry("context.thresholds.gross_income_limit".into()).or_default()
        .push(FactPath::Param { key: "gross_income_limit".into() });
      Then rewrite each engine `RuleFiring` into a snapshot `DerivationEdge` (`EdgeSource::Jdm {
      rule_ref, corpus_hash }`): expand each input field through the provenance map (a field with no
      provenance entry — e.g. a derived intermediate like `net_income` produced by a prior firing —
      resolves to `FactPath::Derived { path }`); register each firing `output` (a `DerivedValue`)
      as a `DerivedFactNode { path, value, is_provisional: false, .. }` referenced by `Derived { path }`.
    * Emit Rust-side `EdgeSource::RustFn` edges:
      ** `se_deduction::compute` (`:202`) — income/expense leaves + params → `se_deduction.*`.
      ** the **alien-eligibility gate** (`:309-322`): this is a per-member *loop* over
         `context.alien_eligibility_inputs` that calls the `{jurisdiction}-snap-alien-eligibility`
         ruleset and **short-circuits on the first ineligible member** to a single household-level
         `alien_denial`. Capture an edge for **each member actually evaluated** (those up to and
         including the short-circuit), member-scoped path `member[<person_id>].alien_eligibility`,
         source `Jdm` (the alien check is itself a ruleset eval, so its firings come through the
         engine) — NOT one synthetic "per-member" node for members never evaluated. Because each
         iteration is an engine eval, its firings already flow through the standard
         engine→`DerivationEdge` rewrite (Decision C); the only loop change is to tag each
         iteration's firings with the member's `person_id` for the
         `member[<person_id>].alien_eligibility` path and stop at the short-circuit — no parallel
         Rust-side `RustFn` capture. The operative denial is the household gate; record which member
         triggered it.
      ** the orchestrator **utility-tier** inference — re-attributed by **canopy-snap** (the
         consuming service) post-dispatch as `EdgeSource::RustFn { fn_name: "infer_utility_tier" }`
         → a **provisional** `DerivedFactNode` (#669), inputs = the snapshot's expense leaves (NOT a
         new `ApplicationContext` field — the orchestrator's inputs are reconstructable from the
         expense leaves the snapshot already holds; Decision H).
    * Assemble + sort the `DerivationGraph` (Decision I) in `build_input_snapshot`
      (`determine.rs:666-736`); set it on the snapshot (→ `schema_version = 3`).
    * Tests: graph-present → snapshot is v3 + re-hashes to the signed `snapshot_hash`; **dangling-
      reference** test (every edge `FactPath::Leaf` resolves in `snapshot.facts`, every `Param` in
      `policy_params`, every `Derived` in `nodes`, every `CrossProgram` in `cross_program_inputs`,
      **and no two `DerivedFactNode`s share a `path`** — the uniqueness invariant from Decision F);
      **v2-byte-identity** test (a snapshot value with `derivation_graph = None` canonical-hashes
      identically to the pre-MR4 v2 snapshot); a known SE-deduction edge has the expected
      inputs/outputs/`rule_ref`; the utility node is `is_provisional: true` **and** a regular
      eligibility-test node (e.g. `gross_income_test`) is `is_provisional: false` (the negative case).

MR5 — TANF capture

Files: services/canopy-tanf/src/determine.rs, services/canopy-tanf/src/rules_client.rs, tests.

  • Thread provenance through the typed wrapper tanf rules_client.rs:269 (it currently discards the trace/firings) so determine.rs can rewrite them.

  • Engine edges for the rulesets that actually fired: eligibility always; benefit_calculation only on the approved branch (determine.rs:462 runs it conditionally) — trace-based capture is naturally conditional (no firing → no edge), so a denied determination simply has no benefit edges. Rust-side edge for compute_tanf_earned_income (:97-210).

  • #669: the inferred deprivation (deprivation_provisional, :512) → DerivedFactNode { path: "deprivation_basis", value: <basis>, is_provisional: true, provisional_reason: Some("inferred deprivation basis (ADR-028 §57)") }, via an EdgeSource::RustFn { fn_name: "infer_tanf_deprivation", service_version } edge (inputs = member facts).

  • Test: the provisional flag survives re-hash; a denied determination has no benefit edges (the conditional-firing case); dangling-reference test (incl. no-duplicate-path).

MR6 — Medicaid capture (per-subject)

Files: services/canopy-medicaid/src/determine.rs, services/canopy-medicaid/src/rules_client.rs, tests.

  • Thread provenance through the typed wrapper medicaid rules_client.rs:491 (it currently discards the trace/firings) so determine.rs can rewrite them.

  • Per-subject (ADR-035, landed): each member’s snapshot (the for member in &ctx.members loop, :220) carries its own subject-local DerivationGraph.

  • Engine edges for the rulesets that actually fired — MAGI / non-MAGI / CHIP / cascade-priority / denial-reasons; EE15-hierarchy only when ≥1 eligible COA (determine.rs:1068 skips it otherwise) — trace-based capture is naturally conditional (no firing → no edge).

  • The Rust-side ABD chain via derive_abd_flags_from_solq (:86-109), which produces up to five ABD-flag derived nodes (subject-local paths): lost_ssi_due_to_cola, is_disabled_adult_child, is_disabled_widow, is_widow_60_64, lost_ssi_as_disabled_child. When a SOLQ record exists for the subject: emit the flag edge inputs = FactPath::CrossProgram { path: "solq[person_id=<uuid>].<flag>" }, source: RustFn { fn_name: "derive_abd_flags_from_solq", service_version }, outputs = the flag node. When SOLQ is absent (derive_abd_flags_from_solq returns defaults and the snapshot omits cross_program_inputs, :1255): the flags are defaults, not derivations — emit the flag as a DerivedFactNode { value: false, is_provisional: false } with no CrossProgram input edge (a RustFn edge with empty inputs, or no edge) so there is no dangling cross-program reference (MEDIUM). Downstream Pickle/COA JDM edges consume the flag nodes (FactPath::Derived) → assigned_coa. SOLQ is referenced (Decision F) — no FTI value copied into any edge.

  • NOT the TMA upstream-determination by-reference edge (blocked, see Scope boundary); the TMA decision node in the Medicaid ruleset (the TMA-phase eval in the :550-1111 block) has all its in-snapshot inputs/outputs captured as ordinary engine edges — only the by-reference link to the TANF determination id is blocked.

  • Tests: with SOLQ present, the ABD chain walks each SOLQ leaf → its flag node → COA (assert the flag nodes are referenced downstream); SOLQ-absent → flag nodes exist with no CrossProgram edge and no dangling reference; dangling-reference test (incl. no-duplicate-path); per-subject re-hash (one snapshot per member, each v3 with its own graph).

MR7 — CAPS + WIC capture (per-subject)

Files: services/canopy-caps/src/determine.rs, services/canopy-wic/src/determine.rs, tests.

  (Anchors here are line-fragile — use the named call/gate as the address per the preamble note.)
* CAPS (per child): the income-eligibility-gate ruleset eval (`~:167-175`) → engine edge;
  Rust-side derived nodes: `activity_eligible` (the activity-eligibility gate, `~:184-185`),
  `age_eligible` (age-threshold gate, `~:200`), `copayment_weekly_cents` (copayment params
  lookup, `~:236`). Each is a captured node (the booleans/values feeding the per-child verdict),
  not a throwaway intermediate.
* WIC (per participant): the categorical/income-eligibility ruleset eval (`~:210-218`) → engine
  edge; Rust-side derived nodes: `adjunctive_eligible` (adjunctive jurisdiction validation,
  `~:225-233`), `food_package` (`params::assign_food_package`, `~:269`), `certification_end_date`
  (`params::certification_end_date`, `~:276`). (`nutritional_risk_documented` is a DB lookup, not
  a derivation — capture it as a `Leaf`/input, not a derived node.)
* Tests: per-subject re-hash + dangling-reference (incl. no-duplicate-path) for each program.

MR8 (FINAL) — ADRs + docs + status flip

Files: docs/…​/adrs/adr-028-.adoc, adr-011-.adoc, adr-014-.adoc, data-models/canopy-.adoc, api/canopy-rules.adoc, CHANGELOG.adoc, the master plan, this plan, .claude/CLAUDE.md.

  • ADR-028 — a new == Amendment 2 (leaving Amendment 1 intact): the derivation_graph field; schema_version = 3 semantics + the verify_schema_version enforcement; the rule_version == corpus_hash decision (Decision B verbatim, incl. the tradeoff); provisional-node carriage; the two blocked edge classes.

  • ADR-011: a note that rule→regulation citation is not introduced by #679 (future rule-citations.toml keyed by RuleRef).

  • ADR-014: a note that derivation edges carry fact-id references, not FTI payloads (Decision F), so the FTI hash-chain surface is unchanged.

  • cargo xtask api-docs --update: the rules /evaluate response is schema’d, so its OpenAPI visibly gains derivation_edges. The program snapshot-read endpoint, however, is documented as body = Object because DeterminationSnapshot has no ToSchema (the T2-1 Half-B as-built deferral, snap api/determine_handler.rs:257) — so derivation_graph will not auto-appear there. Do not silently leave it undocumented: document derivation_graph (+ the v3 schema) in the data-models/canopy-*.adoc prose, and file the "full DeterminationSnapshot ToSchema sweep + flip the snapshot endpoint off Object`" follow-up (the T2-1-deferred item, now also covering the graph). CHANGELOG `== Unreleased; master plan T2-2 → Done + this plan → Done + As-built; file the blocked/deferred follow-ups as GitLab issues /relate #679 before merging MR8; Closes #679.

Verification

Per MR: cargo build -p <touched>; cargo clippy -p <…> --all-targets — -D warnings; focused tests on the program’s dedicated postgres (set -a; source .ports.env; set +a; cargo nextest run -p <svc> — snap/tanf/medicaid/caps/wic use their per-service DBs; the contracts
rules crates are unit/lib). cargo xtask quality-budgets (expect flat — typed contract types, no new serde_json::Value in business logic; the engine fold replaces opaque-trace consumption; offset down on a clean cluster, surface any rise per ADR-030). cargo xtask check-docs + docs plan-lint green. Full pre-push battery (cargo xtask validate --skip-docker + Playwright e2e + cargo doc + k6 smoke + git-lfs) on every push.

Load-bearing assertions:

  • MR1: the fold is deterministic + total over the recorded 0.55.0 trace fixture; a decision-table firing carries the winning row _id + its reference_map input fields; a switch node yields no firing; the shape test fails loudly if the fixture’s fields move.

  • MR3: a snapshot with derivation_graph = None canonical-hashes byte-identically to the same snapshot pre-T2-2 (v2/v1 stability golden); a v3 snapshot round-trips; verify_schema_version rejects > 3.

  • MR4 (mirrored per program MR5–7): no dangling edge references and no duplicate DerivedFactNode.path; the whole snapshot re-hashes (serde_jcs) to the signed snapshot_hash; the v2-byte-identity property holds.

  • MR4/MR5: an inferred SNAP utility node + an inferred TANF deprivation node are is_provisional: true, and a regular eligibility-test node is is_provisional: false (the negative case — provisional is not blanket-applied).

  • MR6: the Medicaid ABD chain walks each of the five SOLQ-derived flag nodes → COA with no FTI value copied into any edge (references only).

After cargo xtask dev refresh: a seeded SNAP determination’s snapshot read (GET /v1/determinations/{id}/snapshot, the T2-1 Half B endpoint) returns a populated derivation_graph a re-verifier can walk from each derived fact back to its inputs + rule_ref.

As-built notes

Delivered 2026-06-23 across 8 dependency-sliced MRs + one shared-helper refactor
one prerequisite fix (per-MR as-built detail is in the Status table rows):

  • Foundation — MR1 (engine folds the zen trace into typed RuleFiring`s; B3a lock 736→754 for the 18 structural trace-parsing `Value`s, ADR-003), MR2 (client `evaluate_with_provenance), MR3 (the v3 DerivationGraph/FactPath/EdgeSource types + schema_version gating + verify_schema_version; FactPath/EdgeSource serialize internally-tagged; DerivationGraph::sort() homes Decision I in the contract).

  • Per-program capture — MR4 SNAP (reference impl), MR5 TANF, MR6 Medicaid (per-subject, ADR-035; the ABD/SOLQ CrossProgram chain), MR7 CAPS+WIC (per-subject). The firing→edge rewrite + canonical assembly were extracted to the shared canopy_contracts_eligibility::derivation crate (refactor MR acdabd7b, after MR4) so MR5–7 reuse one implementation.

  • Prerequisite fix #903 — the shared firings_to_edges was turning expression nodes' bare input/context passThrough envelope roots into dangling Derived{input/context} refs (already shipping in SNAP MR4); fixed in the shared crate (drop the bare roots) before MR5, so all programs benefit.

  • Granularity as-built (Decision K / the scope boundary). Because each ruleset is a separate engine evaluation, expression-ruleset edges carry the derived OUTPUT values as nodes with empty/intermediate inputs (the field-precise expression-input refs live authoritatively in the corpus_hash-pinned ruleset — not re-encoded). Decision-table inputs are field-level. SNAP/TANF use a rules_input provenance map; Medicaid/CAPS/WIC need none (expression rulesets).

  • schema_version reality. A program that always captures a Rust-side node (Medicaid’s ABD chain, CAPS/WIC’s gates) emits v3 on every determination; SNAP/TANF are v3 only when a non-empty graph is present. The existing T2-3 SOLQ snapshot tests were updated v2/v1 → v3 (a legitimate version change — the graph is now present), not weakened.

  • Deferred (filed, /relate #679). TMA-upstream-determination by-ref
    FDSH-input edges (literally blocked); Medicaid denial-reasons + cascade-priority as graph edges (#904 — already in program_input); the shared subject_firing_edges/rust_fn_edge DRY hoist (#905); the full DeterminationSnapshot ToSchema sweep (so OpenAPI documents derivation_graph); rule→regulation citation (ADR-011) and field-precise expression-node input edges.

  • Quality budgets held flat through MR3–MR8 (B3a 754 / B5 307 / B3b 191); MR1’s 736→754 raise was the single maintainer-approved structural-trace ratchet.

Follow-ups

File each as a separate GitLab issue and /relate #679 before merging MR8:

  • Medicaid TMA upstream-determination by-reference edge — unblock with the tanf.case_closed det_id contract change (the deferred T2-3 follow-up; T2-3 landing did not unblock it); then Medicaid’s TMA snapshot records TANF determination_id → TMA eligibility. Blocked until that ships.

  • FDSH-input edges — when an FDSH-gating feature lands and determine() consumes FDSH, freeze it (cross_program_inputs.fdsh, the T2-3 pattern) and draw its edges. Blocked on the input existing.

  • Rule→regulation citation (rule-citations.toml keyed by RuleRef) — an ADR-011 extension; not blocked, out of #679’s scope.

  • Field-path-precise expression-node input edges — parse zen-expression ASTs per cell for field-level (not node-level) expr-* inputs; only on a measured audit need.

  • Denormalized per-rule reporting query — a determination_snapshots JSONB-indexed/generated column for "which determinations used rule site X", if reporting demand justifies it.

  • Full DeterminationSnapshot ToSchema sweep — derive utoipa::ToSchema across the snapshot type tree (incl. the new graph types) and flip the program snapshot-read endpoints off body = Object so OpenAPI documents derivation_graph (+ all snapshot fields). This is the T2-1 Half-B-deferred item (snap api/determine_handler.rs:257), now also covering the graph.

Edit this page · default