ADR-002: Black-Box Determination Contract
On this page
Status
Accepted
determination.requested trigger) and designates the existing signed previous_determination_id (ADR-028 §57), plus a typed trigger enum, as the initial-vs-re-determination signal for that path. The Decision text is unchanged; see Amendment 1 §D2 for the async signing invariants and §D9 for the signal.
Context
Once program services are isolated (see ADR-001), a question arises about what the contract between program services and the eligibility orchestrator (canopy-eligibility) looks like.
Two broad approaches are possible:
-
The orchestrator is given access to the program service’s data and makes the determination itself.
-
The program service makes the determination internally and returns only the result.
The first approach — orchestrator-as-brain — is how most integrated eligibility systems are designed. It is also how the data isolation requirements are violated: once the orchestrator can query FTI, the logical isolation of canopy-tanf is defeated.
The second approach — program-as-black-box — follows directly from ADR-001 and has independent technical and legal advantages worth documenting explicitly.
Decision
Program services operate as black boxes with respect to the eligibility orchestrator.
The contract between a program service and canopy-eligibility is a signed determination object:
{
"program": "snap",
"household_id": "...",
"application_id": "...",
"determination": "approved",
"benefit_amount": 847.00,
"benefit_unit": "monthly_usd",
"effective_date": "2026-04-01",
"expiration_date": "2027-03-31",
"renewal_date": "2027-02-01",
"basis": "magi_gross_income_under_130pct_fpl",
"program_service_version": "1.4.2",
"determined_at": "2026-03-25T14:32:00Z",
"signature": "<detached JWS>"
}
The signature is a detached JWS (JSON Web Signature) produced by the program service’s private key.
canopy-eligibility verifies the signature before accepting the determination.
No program service may accept a determination that fails signature verification.
The orchestrator:
-
Submits an application context (household composition, self-reported income, program applied for) to each relevant program service
-
Receives signed determinations
-
Applies the federal eligibility hierarchy (EE15 — most advantageous group assignment) across determinations
-
Assembles the combined result for the applicant
The orchestrator does not:
-
Query program databases
-
Access verification data (FTI, SSA, IEVS)
-
Reconstruct the reasoning behind a determination
-
Override a determination without routing a new evaluation through the program service
Rationale
Legal
The black-box contract is the logical enforcement mechanism for ADR-001’s data isolation requirement. An orchestrator that can query program data defeats isolation in practice even if the isolation exists in principle. By making the contract a determination object rather than a data query, the boundary is architecturally enforced rather than policy-enforced.
An IRS Pub 1075 auditor can observe that canopy-eligibility receives a determination from canopy-tanf without receiving the FTI that produced it.
The audit boundary is clear and demonstrable.
Cryptographic integrity
The JWS signature on each determination object serves two purposes:
-
Tamper-evidence: No downstream service can modify a determination in transit or at rest without invalidating the signature. A SNAP determination that says "approved" cannot be changed to "denied" — or vice versa — without `canopy-snap’s private key.
-
Non-repudiation: The determination is attributable to a specific program service at a specific version. This matters for audit trails, appeals, and federal reporting — all of which require knowing which system made a determination and when.
This pattern is derived from the CRAIG project’s JWS intake signing (CRAIG ADR-010, external to this repository), which uses ECDSA P-256 detached JWS for partner-submitted intake reports.
The same cryptographic approach applies here with the program service as the signer and canopy-eligibility as the verifier.
Operational resilience
A program service can be deployed, updated, or restarted independently without affecting determinations already in flight. Signed determinations are immutable records — a canopy-snap deployment does not invalidate existing approved determinations.
Alternatives considered
Alternative 1: Orchestrator queries program databases directly Rejected. Defeats the data isolation guarantee of ADR-001. Also creates tight coupling between the orchestrator’s data model and each program’s schema, making independent program deployment impossible.
Alternative 2: Shared determination database, programs write, orchestrator reads A compromise — programs write determinations to a shared store, orchestrator reads from it. Rejected because a shared determination store becomes a target for cross-program data leakage: if the store contains TANF determinations alongside SNAP determinations, and the TANF determination includes any FTI-derived fields, the shared store is subject to Pub 1075 controls. The black-box contract keeps the determination object minimal by design.
Alternative 3: Unsigned determination objects Simpler to implement but rejected because it provides no tamper-evidence guarantee and no non-repudiation for appeals and audit purposes.
Consequences
-
Each program service generates and manages a signing key pair.
-
canopy-eligibilitymaintains a verification key registry — one public key per program service. -
Key rotation must be coordinated; key rotation plan documented separately.
-
Determination objects are append-only records. Once signed and accepted, they are not modified — only superseded by a new determination from a new evaluation.
-
The black-box contract means
canopy-eligibilitycannot explain why a program approved or denied without querying the program service for a human-readable explanation separately — which the program service may provide via a/determination/{id}/explanationendpoint that returns narrative, not data.
Amendment 1 — Async/bulk determination variant (mass-change machinery) (scale audit epic &73, #1237, 2026-07-27)
Status unchanged (still Accepted; amendments extend, they do not supersede). The 2026-07-25 scale-readiness
audit (epic &73) found (H6) that a determination is only ever triggered by a synchronous, single-attempt, ~30-call
HTTP fan-out per household inside one interactive request lifetime
(services/canopy-eligibility/src/orchestrator.rs:1398-1412) — there is no queued/bulk path and no async trigger.
So a mandatory mass change — the canonical case is the annual 7 CFR 273.12(e) COLA rebudgeting that re-determines
every ongoing SNAP case (canopy-policy pins snap-cola to Oct 1 with grace_days=0, so the ruleset flips on time
but nothing applies it to the caseload; GA ≈ 800K households ≈ 6.7h of saturated synchronous fan-out best-case) —
has no contract-level home, and a naive bulk driver would collide with the one-pending-slot invariant and starve
live interactive determinations. This amendment adds the async/bulk determination variant: a queued
determination.requested trigger, the async-path signing invariants, stable idempotency, durable
checkpoint/resume, bounded retry, one-pending-slot deferral, and — reusing the existing signed
previous_determination_id (ADR-028 §57) as the supersession linkage plus a typed trigger enum as the
classifier — an initial-vs-re-determination signal. The synchronous signed-determination Decision (§Decision) is left byte-immutable — the per-program
signed determination object and its detached-JWS trust boundary are unchanged; what this amendment adds is
when/how a determination is triggered and the lifecycle/dedup/retry semantics around it.
This amendment pins the contract (D1–D10), the invariants, and the acceptance criteria; the implementation
children own the byte-level. #1213 (the mass-change / October-COLA driver) builds the determination.requested
consumer, the bulk-enqueue admin surface (cohort selection, dry-run then enact), the durable checkpoint/resume
tables, bounded concurrency, and the per-dispatch idempotency keys; #1133 owns enrollment apply-semantics for an
already-enrolled re-determination. Everything beyond the shipped synchronous path is not built yet — the
clauses are normative (MUST/SHALL), not as-built. This amendment governs the determination contract only; how
enrollment applies a re-determination (adjust-in-place vs supersede under the #1130 one-live-enrollment fence,
and the 7 CFR 273.13 reduction-type adverse-action routing) is #1133 and is explicitly out of scope (§D9,
§Consequences).
Settled decisions
-
The async path reuses the exact synchronous signer — no "system" key. Every determination on the async path is signed with the same boot-acquired per-program signing key, the same ADR-036 key-derived
kid, and the samesigning_key_historyregistration as the interactive path, and runs the full ADR-028 seal-hash-then-sign ritual. A separate "system" signing key would make the orchestrator’s per-Program verification registry treat every async determination as unknown and quarantine it (§D2). -
The signal reuses the existing signed
previous_determination_id(ADR-028 §57) — this amendment introduces no new field. The determination envelope already carries a signedprevious_determination_id: Option<DeterminationId>(ADR-028 §57, part ofcanonical_signing_payload); this amendment designates it the async supersession linkage #1133 uses to pick which live enrollment to adjust/supersede. Because itsNoneis tri-valued (a first determination, a legacy row, or a program not yet implementing supersession),Nonealone is NOT a sound initial-vs-re-determination signal — the authoritative classifier is a typed trigger/reason enum (§D9), withprevious_determination_idcarrying the linkage. Both are inside the signed JWS payload, so neither can be stripped or spoofed by envelope/routing metadata (§D9). -
An already-enrolled household’s re-determination is a first-class, expected contract outcome — not a defect, not a DLQ candidate. The current enrollment PARK (
services/canopy-enrollment/src/auto_enroll.rs:143-171) and the create-API 409 are documented as the bridge until #1133 implements adjust-vs-supersede (§D9). -
The Decision text is left byte-immutable — this amendment extends, it does not supersede.
The async/bulk determination contract (children own the byte-level)
D1 — Async trigger: a determination.requested command event. The async path is triggered by a new
determination.requested routing key — a request/COMMAND event, explicitly distinct from the existing
determination.completed* fact events. No such key exists today. It MUST carry a typed payload defined in a
contracts crate (never inline JSON): cohort_run_id, the subject (application_id, household_id), the
programs, the pinned as_of + corpus/ruleset hash (§D8), the typed trigger classification (§D9), and the stable
idempotency key or its derivation inputs (§D4). It MUST obey the ADR-004 no-PII/no-FTI event allowlist (ids
status + scalars only). canopy-eligibility owns the producer AND the bulk consumer; the consumer is the enqueue
trigger only and calls the existing POST /v1/eligibility/determine (canopy-eligibility’s synchronous
orchestration entry — which internally fans out to each program’s signed /v1/determine, applies the EE15
hierarchy, holds the one-pending-slot 409, and assembles the CombinedResult) — so the signed-determination path
is reused unchanged. mq obligations: the consumer lands in-tree before the producer activates (binding-first, #1089) or an
xtask/mq-topology-allow.toml entry with a reason; canopy-eligibility’s topic-write ACL in
`devstack/rabbitmq/definitions.json MUST be extended to the key (#1122) or the broker ACCESS_REFUSE`s the
publish; the consumer MUST be idempotent (ADR-018 at-least-once; `event_inbox PK dedupe).
D2 — JWS signing/verification is byte-identical on the system-initiated path. The determination JWS binds only
determination content + the ADR-028 snapshot_hash and carries NO per-request auth identity; verification is a
pure function of (Program, kid, canonical bytes), so trust semantics are identical whether a determination is
interactive or system-initiated. The async path MUST reuse the same boot-acquired program signing key + key-derived
kid (ADR-036) and the same signing_key_history registration, and MUST run the full seal-hash-then-sign ritual
(assemble the ADR-028 snapshot; AEAD-seal leaves under a per-determination DEK from the KEK; set snapshot_hash
before signing; preserve the byte-stability invariants — micros truncation, money rescale-2, JCS
canonicalization). It MUST NOT introduce a separate "system" signing key. Verify-before-accept/persist/count is
preserved: an unverified determination is signature_quarantined and excluded from totals.
D3 — accessed_by for the FTI audit chain (not a signed field). The accessed_by actor feeds the ADR-014 FTI
hash-chain (NOT the signature) and is MANDATORY for FTI-bearing programs (no ADR-028 snapshot without a chain
row). A system-initiated run has no inbound worker, so the contract defines a well-defined principal: a real
originating-worker actor when the batch was human-queued, else a reserved system principal encoding the
cohort_run/job id (e.g. system:cola-redetermination:<cohort_run_id>) — never an empty or misleading "the
service did it". This reaffirms the ADR-019 separation: the actor JWT (aud=canopy-internal-actor) and the
determination JWS (typ=canopy-determination+jwt) are distinct token systems, keys, and registries; omitting the
actor JWT has zero effect on determination verifiability.
D4 — Stable, deterministic idempotency key. The per-dispatch Idempotency-Key MUST be a deterministic
hash(cohort_run_id, application/household_id, program, as_of/corpus) — stable across resend and
checkpoint-resume (a replay hits the #1003 middleware), yet distinct across cohort-runs and across the COLA
ruleset flip (so a post-COLA determination is not a false replay of the pre-COLA one). This replaces the
orchestrator’s freshly-generated (non-stable) per-dispatch UUID-v7. Two layers, and the durable one lives in the driver: the HTTP
idempotency middleware (#1003) is transport resend-safety only (24h TTL; at-least-once on
crash-after-side-effect) and MUST NOT be the durable dedup layer; determination persistence MUST be idempotent on
a stable natural key so a replayed unit UPSERTs/supersedes rather than duplicating (consistent with the
append-only/supersede consequence, §Consequences).
D5 — Durable, driver-owned checkpoint/resume. The driver owns durable checkpoint state — a cohort-run row
per-case item rows — mirroring the renewals NOT EXISTS + ON CONFLICT domain-state idiom, surviving
multi-hour / 800K-case runs and restarts; cohort selection via a persisted keyset cursor (the
ADR-001 Amendment 1 B2 shape); a per-unit state
machine (pending/dispatched/succeeded/failed-terminal/failed-retryable); and a single-active-run guard
via advisory-lock leader election. This is contract-visible only insofar as each emitted determination is
independently signed + idempotent — the exact table/schema is #1213’s byte-level.
D6 — Bounded retry + failure classification. Off the request path the deliberate single-attempt-no-retry
rationale (don’t amplify a synchronous ~30-call fan-out) no longer applies. The driver MUST apply bounded
retry-with-backoff + a per-job deadline + dead-letter/park, with a terminal-vs-transient split mirroring
DryRunError: 4xx = terminal (do not retry); 5xx/transport = transient (bounded retry then DLQ). A
re-determination-not-yet-done MUST NOT be converted into a terminal synthetic pending_verification
worker-queue item; and pending_verification items MUST be deduped on (application, household,
verification_type) (the verifications table has no dedup key today).
D7 — One-pending-slot interaction + interactive priority. idx_unique_pending_request (at most one live
eligibility run per (application_id, household_id)) stays the race-safe backstop that maps to a 409. A bulk
member that finds the slot held by a live interactive determination MUST skip-and-requeue (defer with
bounded backoff) — never clobber, never surface a 409 to a worker; interactive > bulk. The driver enforces this
priority as a fast-path yield before it races the INSERT, with the DB index as the backstop for a lost race.
Bulk runs MUST use bounded concurrency (a semaphore / a separate low-priority lane) so a caseload-wide drain
cannot starve the interactive fan-out (audit H6). A queued/deferred representation MUST exist so a slot-busy
member is "waiting" rather than forced into the run-or-fail closed set; prefer an idempotent enqueue keyed on
(application_id, household_id) that collapses duplicate submits into the existing handle instead of a raw 409.
D8 — as_of / corpus pinned once per cohort-run. The as_of date + the target corpus/ruleset MUST be resolved
once per cohort-run and stamped on every dispatch, so all cases score the same COLA-effective day and ruleset
(ADR-028/ADR-034), and folded into the idempotency key (§D4) so pre-/post-COLA determinations are correctly
distinct. requested_by + the inbound authz decision MUST be captured at enqueue (the caller’s session/clock is
absent at worker time); the ADR-019 service-token boundary already survives async (the orchestrator dispatches
with a service identity, not the caller bearer).
D9 — Initial-vs-re-determination signal + supersession linkage (reuses ADR-028 §57). The determination envelope
already carries a signed previous_determination_id: Option<DeterminationId> (ADR-028 §57, part of
canonical_signing_payload, skip_serializing_if so an absent value keeps the canonical bytes identical). This
amendment introduces no new field — it designates that existing signed field the async supersession linkage
#1133 uses to pick which live enrollment to adjust/supersede, and pins how the async path uses it: on a
re-determination the producer MUST set previous_determination_id = Some(prior) before signing, so the
signature binds the chain link (tamper-evident + non-repudiable; an envelope/routing-only field could be stripped
or spoofed). Its None is tri-valued (a first determination, a legacy row, or a program not yet implementing
supersession), so None alone MUST NOT be read as "initial"; the authoritative initial-vs-re-determination
classifier is a typed trigger/reason enum (cola|fpl|ruleset-migration|change-report|renewal|worker-initiated)
carried as signed provenance (also driving downstream 273.13-vs-benign routing). An already-enrolled
re-determination is BLESSED as a first-class expected outcome (today it PARKs at enrollment / 409s — the bridge
until #1133); effective_date is carried faithfully but the contract does NOT force immediate application (the
notice-timing shift is #1133, with the 7 CFR 273.13 reduction path as #1002’s remainder). The completion events
(determination.completed.snap + the orchestrator determination.completed) extend additively with the
trigger classification so notices' status-based routing still resolves.
D10 — Completion / notification edge. Async submit becomes 202 + a request_id handle (vs today’s
200-carries-result); determination.completed is designated the authoritative push edge and MUST be made
atomic with the CombinedResult write (closing the #477 non-atomic best-effort gap). The existing
GET requests/{id}, /determinations, /results/{application_id} poll endpoints are retained — a hybrid
poll+event model.
Consequences
-
A mass change (COLA / FPL / ruleset-migration) becomes N new immutable signed determinations that supersede, never mutate, their priors at caseload scale — preserving the append-only consequence (§Consequences) unchanged.
-
The black-box signed-determination trust boundary is identical on the async path; verification stays a pure function of
(Program, kid, canonical bytes), and no separate "system" key is introduced. -
Bulk runs never starve or clobber live interactive determinations (one-pending-slot deferral + bounded concurrency).
-
The doc flip surfaces the already-returned-but-undocumented 409 on
POST /v1/eligibility/determineand the async collision-deferral behavior indocs/modules/ROOT/pages/api/canopy-eligibility.adoc. -
Enrollment apply-semantics (#1133) and the driver implementation (#1213) ride separate MRs; this amendment is the contract keystone both build against. The eligibility one-pending-determination-slot (
idx_unique_pending_request, existing; interaction owned by #1213) and the enrollment one-live-enrollment-per-household #1130 fence (#1133) are distinct layers and MUST NOT be conflated.
Amendment 2 — The D4/D8 policy identity is the composite target (#1467, 2026-08-14)
Status unchanged (still Accepted). In-document amendment to Amendment 1’s D4 + D8 wording: both said "corpus" where
the policy identity is really the composite {corpus_hash, params_digest, effective_period}
(canopy_common::policy_target::PolicyTarget — ADR-028 Amendment 6). The corpus alone cannot witness an annual COLA:
the rules loader deliberately skips parameter JSONs, so a parameters-only change under an unchanged corpus would
neither perturb the D4 idempotency key nor be visible in the D8 pin.
-
D4: the deterministic key’s policy component is the composite target, not
as_of/corpus— a post-COLA determination differs from its pre-COLA twin byparams_digesteven when the corpus is unchanged. -
D8: what is resolved once per cohort-run and stamped on every dispatch is the composite target. The corpus half comes from canopy-rules
GET /v1/corpus(#1469); the parameters half from the program service’sGET /v1/params/provenance(#1467). Dispatches carry it asexpected_policy_target— the program service answers 409policy_target_mismatchBEFORE any evaluation or write, and (whenemit_policy_attestationis enabled) binds the same target into the signed envelope, which the #1213 core’s dispatcher MUST validate on return (its signed-provenance check — a later plan step; #1479 tracks persisting the returned target eligibility-side).
Related: #1467, ADR-028 Amendment 6, the #1213 COLA program plan.
Amendment 3 — The bulk core as built: epochs, the results ledger, and recorded refinements (#1213, 2026-08-16)
Status unchanged (still Accepted). Amendment 1’s D1–D10 shipped in the #1213 core MR with two structural refinements and eight recorded deviations, all reviewed in the core plan round (rev 4).
Structural. (1) Delivery generations vs program epochs. dispatch_generation fences DELIVERY only
(event, x-canopy-bulk-generation self-call header, consumer CAS); the program_epoch is the logical
execution epoch — it alone enters the per-program idempotency key and the results ledger, and it bumps only
on a non-definitive completion or an operator re-arm (enact / retry-failures). A delivery re-arm therefore
replays the SAME key: crash recovery converges through the idempotency cache or the ledger instead of
re-executing into a consumed supersession slot. (2) cohort_case_results is the durable D4 layer,
inserted in the SAME transaction as the program-determination row on the bulk arm — recovery reads THIS
ledger at any generation, never eligibility-request status.
Recorded refinements (as built). 1. No idempotency-key header on the bulk self-call (H19: replays must
surface as guarded-state 409s). 2. The durable dedup layer is the deterministic per-program key + the
ledger, not HTTP middleware. 3. D7 is serialized admission (one per-household advisory lock on BOTH paths)
plus consumer yields — bulk try-locks and never blocks interactive. 4. The D1 payload is ids-only; the
durable case row is the source and the CAS validates run/case/generation/phase. 5. Superseded pre-flip
re-runs narrowed to adopt-or-skip: a successor matching baseline + trigger + the pinned parameter
set (digest + window) is adopted (adopted=true on the ledger; the read view exposes neither corpus nor
evaluated_as_of, so the matcher is deliberately narrower than the live H2 binding — re-baselining is
#1482). 6. Terminal request rows leave the partial indexes automatically and are retained as audit
anchors. 7. Resume matches definitive results by (case, program, epoch) via the ledger. 8.
BulkDispatchContext carries NO generation — the frozen body stays byte-stable across re-arms.
Sequencing. #1473 (snap stages its post-determination events in the determination-persist transaction) landed FIRST as its own MR — a bulk success whose NOA/enrollment/ELE events could silently vanish would have made every bulk enact unsafe.
Core-MR deviations from the frozen plan text (all reviewed in-MR): BulkRunAccepted names the field
run_id (the plan draft said request_id); cohort_cases gained successor_determination_id (B9
requires the foreign successor queryable on the failures surface); provenance-rejected envelopes persist
quarantine-marked (signature_verified=false + a labelled basis) so a signed-but-wrong-pins verdict can
never read as ordinary; enact and retry-failures each grant a FRESH deadline window from the configured
default (the create-time deadline bounds the preview phase); run creation refuses an empty cohort (422
cohort_empty — a zero-case run would wedge in previewing).
Related: #1213, #1473, #1480–#1483, the
#1213 COLA program plan, and the
bulk COLA scaling runbook.
Amendment 4 — The single-case Order arm (#1504/#575, epic &77, 2026-08-18)
Amendment 1’s D1 field list is now the literal wire shape: determination.requested
is an internally-tagged two-arm enum — Cohort (the #1213 cohort-case projection,
unchanged semantics) and Order, carrying the full D1 list verbatim: a
RequestOrigin {source, ref_id} requester identity, the subject
(application_id/household_id), programs, the pinned as_of (D8), the signed
D9 trigger, and requested_by captured at enqueue.
-
Producers extend beyond eligibility: a program service reporting a change (canopy-medicaid’s CMD subsystem first) publishes the Order; the broker topic-write ACL extends per producer. Eligibility remains the sole consumer.
-
The durable order substrate realizes D4/D5/D6 for one-case work:
determination_ordersmaterializes idempotently onUNIQUE (origin_source, origin_ref); settles fence on the claim’s own timestamp token; retry is backoff-scheduled (60s doubling, 1h cap, jitter) through an always-on 1-minute sweep — never hot MQ redelivery — with a deliberately-larger attempt cap (12) so slot-conflict chains cannot terminalize a legal re-determination quickly; the D4 deterministic dispatch key ishash("canopy-order-dispatch-v1\0" ‖ origin ‖ program ‖ epoch)(KAT-pinned) and crash recovery ADOPTS the latest DEFINITIVE completed run by origin instead of re-executing. -
The D10 push edge carries the correlation:
DeterminationCompletedV1additively gains anoriginecho, so the requester settles its own row — pure event choreography, no requester-side HTTP retry loop. -
Boot consequence: the
determination.requestedconsumers attach on every canopy-eligibility boot (no longer gated onbulk_runs_enabled);CANOPY_MQ_PREFETCH_COUNT=1is a fleet-wide boot requirement. -
The Decision text and Amendments 1–3 are left byte-immutable — this amendment extends, it does not supersede.