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).
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 emission —
canopy-rulesfolds the zen-engine per-node execution trace (already collected, today discarded as an opaquetraceblob) into a typed list of rule firings onEvaluateResponse;canopy-rules-clientstops dropping it. -
v3 snapshot schema — a
derivation_graph: Option<DerivationGraph>field onDeterminationSnapshot, reusing the T2-3/T2-4cross_program_inputsadditive-typed pattern verbatim (conditionalschema_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 unshippedtanf.case_closedevent-contract change to publishdet_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-medicaidstore/fdsh.rsis a stub never called bydetermine()(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 (outputper key) and are captured. The exact field references anexpr-node reads live *authoritatively in thecorpus_hash-pinned ruleset (the expression source); re-deriving them into the snapshot (by parsing zen-expression ASTs — andzen-expressionexposes 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-granularexpr-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.tomlkeyed byRuleRef) 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_hashis 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 |
Done (2026-06-22) — the plan commit. |
MR1 — rule identity + engine edge emission |
|
Done (2026-06-23) — merged e3f672bd (MR !665). B3a lock 736→754 for the 18 genuinely-structural trace-parsing |
MR2 — client threading |
|
Done (2026-06-23) — this MR. The new accessor’s input/output |
MR3 — v3 snapshot schema |
|
Done (2026-06-23) — this MR. New dep |
MR4 — SNAP capture (reference impl) |
|
Done (2026-06-23) — this MR. New |
MR5 — TANF capture |
|
Done (2026-06-23) — this MR. New |
MR6 — Medicaid capture (per-subject) |
|
Done (2026-06-23) — this MR. New |
MR7 — CAPS + WIC capture (per-subject) |
|
Done (2026-06-23) — this MR. New |
MR8 (FINAL) — ADRs + docs + status flip |
ADR-028 Amendment 2; ADR-011 + ADR-014 notes; |
Done (2026-06-23) — this MR. ADR-028 Amendment 2 (the realized derivation graph + the |
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-eligibility → canopy-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 rideApplicationContextinto 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), TANFcompute_tanf_earned_income(services/canopy-tanf/src/determine.rs:97-210), Medicaidderive_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. snapdetermine.rs:344-355) return all JDM-computed intermediates in theoutputJSON; only ~5 wire fields are parsed and the rest discarded. The engine also collects a full per-nodetraceand the HTTP contract types it as an opaqueOption<serde_json::Value>(crates/canopy-contracts-rules/src/rule_sets.rs:34-49), whichcanopy-rules-clientthen 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 = |
A JDM node’s |
B — |
The only on-disk version is corpus-level ( |
C — engine emits field-path firings; the program service maps to |
Two coordinate systems. The engine knows only evaluation field paths — and these are
namespaced: |
D — inline |
Add |
E — |
The version is driven by a non-empty graph, not mere |
F — |
|
G — derived facts are nodes carrying their value; provisional is per-node (#669) |
|
H — Rust-side derivations are |
Rust-side derivations are not JDM firings — they have no |
I — graph deterministically sorted via explicit sort keys (not |
|
J — hybrid edge computation (the only correct split) |
JDM-internal edges = the engine (only it has the trace). Rust-side edges
( |
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 |
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 |
M — the fold joins the trace to the loaded |
The serialized trace carries |
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) tocanopy-contracts-rules. -
The fold (Decision M): join the serialized trace to the parsed
DecisionContent. After the pinned eval returns the serializedtrace(aserde_json::Valuekeyed by node id), run the fold on the main task (whereself.loaderis accessible): load the ruleset’sDecisionContentby name (cached,engine.rs:214,249-258) and index its nodes by id. For each trace entry, sorted byorder:-
Look up the joined
DecisionContentnode byid. 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 fromDecisionContent), each paired with its value read from the traceoutputVariable — NOT the raw passThrough-mergedoutputkeys (which would over-record passed-through inputs as derived outputs;transform_attributes.rs:108). -
inputs← decision-table:trace_data.reference_mapkeys (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
DecisionContentnode, or absenttrace_data/reference_map, → skip the field-level refinement (node-granular inputs from the traceinputkeys); never panic. BuildVec<RuleFiring>stamped with the responsecorpus_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 whentracewas requested (computing edges needs the trace;determine()already requests it). The opaquetracefield 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 serializedserde_json::Valueas 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-tabletrace_datareference_map + row_id) are present (this is the version pin). (2) aproptestover synthetic serialized-trace structures asserting the fold is deterministic (same input → same output) and total (no panic on any shape, incl. missingtrace_data). (3) aRuleFiring/RuleRefJCS-stability proptest (the contract type itself, mirroring the MR3 snapshot-type proptests): allRuleNodeKindvariants ×rule_id_in_nodeNone/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’sEvaluateResponsemirror (defaultNone— back-compat with an older engine, mirroring thecorpus_hashempty-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=truequery parameter — verified: the engine toggle isEvaluateParams { trace: bool }(crates/canopy-contracts-rules/src/rule_sets.rs:18,parameter_in = Query), read by the handler asQuery<EvaluateParams>→params.trace(services/canopy-rules/src/api/mod.rs:145,155); it is not a body field onEvaluateRequest(the client’s body,lib.rs:29, has no trace field and gains none). Soevaluate_with_provenanceappends?trace=trueto the POST URL (evaluate_with_corpus_hashposts without it); returns(output, corpus_hash, derivation_edges).evaluate_with_corpus_hashstays unchanged for edge-uninterested callers. -
Test: a client round-trip asserting firings survive the wire via
evaluate_with_provenance; and thatevaluate_with_corpus_hashis 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 aftercross_program_inputs;derivation_graph_is_emptyreturnstruefor bothNoneandSome(empty)(mirroringcross_program_inputs_is_empty).schema_versioncomputed as3iff a non-empty graph —derivation_graph.as_ref().is_some_and(\|g\| !g.is_empty())(else the existing 2/1 logic), soNoneandSome(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.rs—arb_derivation_graph()covering all fiveFactPathvariants + provisional/non-provisional nodes + empty/populated; setschema_version = 3iff the graph is non-empty; extend the canonical-bytes/JCS round-trip proptests to v3; a bidirectional empty-equivalence golden test assertingderivation_graph = Noneis byte-identical to the pre-MR3 encoding in both directions: (i) graph=None + nocross_program_inputs→ identical canonical bytes + hash to a v1 snapshot; (ii) graph=None +cross_program_inputspresent → 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’sverify_schema_versionrejects 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 wrapperalien_eligibility::evaluate(alien_eligibility.rs:95) — today it discards the trace/firings; it must return them sodetermine.rscan capture the alien edges (Decision C/MEDIUM). Build arules_input_provenance: BTreeMap<String, Vec<FactPath>>keyed by the exact namespaced emitted paths (Decision C) as each contributing fact/param is folded intorules_input(the assembly arounddetermine.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
tanfrules_client.rs:269(it currently discards the trace/firings) sodetermine.rscan rewrite them. -
Engine edges for the rulesets that actually fired: eligibility always; benefit_calculation only on the approved branch (
determine.rs:462runs it conditionally) — trace-based capture is naturally conditional (no firing → no edge), so a denied determination simply has no benefit edges. Rust-side edge forcompute_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 anEdgeSource::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
medicaidrules_client.rs:491(it currently discards the trace/firings) sodetermine.rscan rewrite them. -
Per-subject (ADR-035, landed): each member’s snapshot (the
for member in &ctx.membersloop,:220) carries its own subject-localDerivationGraph. -
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:1068skips 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 edgeinputs = 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_solqreturns defaults and the snapshot omitscross_program_inputs,:1255): the flags are defaults, not derivations — emit the flag as aDerivedFactNode { value: false, is_provisional: false }with noCrossPrograminput edge (aRustFnedge 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-1111block) 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): thederivation_graphfield;schema_version = 3semantics + theverify_schema_versionenforcement; therule_version == corpus_hashdecision (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.tomlkeyed byRuleRef). -
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/evaluateresponse is schema’d, so its OpenAPI visibly gainsderivation_edges. The program snapshot-read endpoint, however, is documented asbody = ObjectbecauseDeterminationSnapshothas noToSchema(the T2-1 Half-B as-built deferral,snap api/determine_handler.rs:257) — soderivation_graphwill not auto-appear there. Do not silently leave it undocumented: documentderivation_graph(+ the v3 schema) in thedata-models/canopy-*.adocprose, and file the "fullDeterminationSnapshotToSchemasweep + flip the snapshot endpoint offObject`" 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+ itsreference_mapinput fields; a switch node yields no firing; the shape test fails loudly if the fixture’s fields move. -
MR3: a snapshot with
derivation_graph = Nonecanonical-hashes byte-identically to the same snapshot pre-T2-2 (v2/v1 stability golden); a v3 snapshot round-trips;verify_schema_versionrejects> 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 signedsnapshot_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 isis_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 v3DerivationGraph/FactPath/EdgeSourcetypes +schema_versiongating +verify_schema_version;FactPath/EdgeSourceserialize 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
CrossProgramchain), MR7 CAPS+WIC (per-subject). The firing→edge rewrite + canonical assembly were extracted to the sharedcanopy_contracts_eligibility::derivationcrate (refactor MR acdabd7b, after MR4) so MR5–7 reuse one implementation. -
Prerequisite fix #903 — the shared
firings_to_edgeswas turning expression nodes' bareinput/contextpassThrough envelope roots into danglingDerived{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 arules_inputprovenance map; Medicaid/CAPS/WIC need none (expression rulesets). -
schema_versionreality. 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 inprogram_input); the sharedsubject_firing_edges/rust_fn_edgeDRY hoist (#905); the fullDeterminationSnapshotToSchemasweep (so OpenAPI documentsderivation_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_closeddet_idcontract change (the deferred T2-3 follow-up; T2-3 landing did not unblock it); then Medicaid’s TMA snapshot recordsTANF 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.tomlkeyed byRuleRef) — 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_snapshotsJSONB-indexed/generated column for "which determinations used rule site X", if reporting demand justifies it. -
Full
DeterminationSnapshotToSchemasweep — deriveutoipa::ToSchemaacross the snapshot type tree (incl. the new graph types) and flip the program snapshot-read endpoints offbody = Objectso OpenAPI documentsderivation_graph(+ all snapshot fields). This is the T2-1 Half-B-deferred item (snap api/determine_handler.rs:257), now also covering the graph.