ADR-037: Per-Field Ownership Tables for Multi-Tenant Edit Surfaces; Backend Service Enforcement, BFF Reflection
On this page
Status
Accepted 2026-06-18. Anchors Plan S — Multi-Jurisdiction Foundation Step 22; the implementation lands in Plan Y (Field Ownership + Authz, epic &50). This is the fifth and final design-team engineering contract to get its own ADR (completing the set after ADR-033 / ADR-034 / ADR-035 / ADR-036). It realizes Contract 5 (Field ownership + authz) and refines that contract’s enforcement home: per-field ownership is enforced at the OWNING backend service that owns each multi-tenant edit surface’s live data (e.g. craig-cases for case-detail fields), NOT in the craig-web BFF — the same service-home refinement ADR-035 made to Contract 3. It also refines ADR-032 A11’s field_ownership shape via a new ADR-032 Amendment (A13, the A12 precedent — see Related decisions). Contract 5’s three-way ownership taxonomy (state-owned / provider-owned / shared), its per-role per-jurisdiction enforcement, the "propose is a write-shaped action producing an approval-queue entry" rule, the mandatory audit on every read AND every write, and field_ownership: FieldOwnershipContribution as the BundleContribution extension point are all PRESERVED. Builds on ADR-023 (the zen-engine authz stack this extends to field granularity), ADR-024 (the ruleset cache + RMQ invalidation this reuses), ADR-004 (the stateless-BFF property this protects), ADR-028 (the X-Craig-Actor on-behalf-of seam the BFF uses to reach the backend and that attributes the proposing worker), ADR-032 §4 + A11 (additive BundleContribution growth), ADR-038 §3 (the pre-materialized field-shape rule A11 requires the anchoring ADR to cite), ADR-035 §8 (the declaration-in-bundle / data-on-disk precedent field_ownership mirrors), and ADR-022 (the transactional outbox that stages the field-edit audit).
Context
CRAIG’s multi-jurisdiction mission includes multi-tenant edit surfaces: the planned CWCA provider portal (and future surfaces) let an external provider and a state caseworker edit different fields of the same record. Contract 5 specifies the model — every field is state-owned (provider reads, state writes), provider-owned (provider writes, state reads), or shared (both read; the non-owner may propose an edit the owner accepts or rejects). The ownership map is per-jurisdiction (different states slot fields differently), every read AND every write emits an audit event, and a non-owner’s "propose" produces an approval-queue entry, never a direct write.
The open architectural questions this ADR settles are where field-ownership enforcement runs and how per-field permissions are evaluated — and, given those, how "propose" maps onto CRAIG’s existing record-level authz model without a fleet-wide ripple. Contract 5 as drafted located enforcement "at the BFF" with the propose-approve queue as a "NEW BFF surface + table." That cannot stand: craig-web is a stateless aggregator with no sqlx / craig-db / craig-mq dependency and no zen-engine authz stack (ADR-004), and the umbrella plan makes "the BFF must not gain DB/MQ deps" an explicit Plan Y constraint. The field-ownership concern has a database (the per-field ownership map + the propose-approve queue), a per-request authz-evaluation requirement, and an audit-emission requirement — all of which already live in the backend services, none of which belong in the BFF. This is the same scope-creep reasoning ADR-035 applied to composition; the difference is that field ownership needs no new service of its own by default — it attaches to whichever backend already owns the surface’s live data.
Decision
1. Field-ownership enforcement runs at the OWNING backend, not the BFF
The owning backend is defined as the service that owns the live data of a multi-tenant edit surface (e.g. craig-cases for case-detail fields) and already boots a zen-engine AuthzEngine. That service is where per-field ownership is enforced: it loads the ownership map, resolves each field to an effective permission, hosts the propose-approve queue, and emits the field-level audit. Pinning enforcement to the live-data owner is load-bearing for the §6 single-transaction guarantee (the queue and the live rows must share a database). craig-web stays a pure HTTP client: it asks the owning backend "what may this worker do with each field of this surface?" and renders the answer (§7). No database, message broker, or authz stack enters the BFF — its ADR-004 defining property is preserved.
Which backend owns the CWCA surface specifically — the existing craig-cases versus a NEW services/craig-cwca-provider — is a Plan Y authoring decision governed by an explicit rubric (see Open questions). A split to a new service is only valid when that service also owns the edited live rows; if the fields stay case-owned, the queue stays in craig-cases (§6). This ADR fixes only that enforcement lives with the live-data owner, wherever Plan Y pins it.
2. Ownership is a static jurisdiction-scoped table; a NEW field-permission evaluation path resolves the dynamic permission
Field ownership has two separable axes, and conflating them into one artifact is the mistake to avoid:
-
The static axis — which of
state-owned/provider-owned/shareda field is — is per-jurisdiction config that changes by file + PR, identical in lifecycle to the composition baseline (ADR-035 layer 4). It lives on disk inrulesets/<jurisdiction>/cwca_ownership.toml(the home Contract 5 / Plan Y name), the samerulesets/<jurisdiction>/home CRAIG already uses for JDM authz rulesets and composition baselines. A 30-field surface is 30 rows of pure data; it does not belong in a decision table. -
The dynamic axis — given a field’s owner classification + the acting worker’s roles + the action, what is the effective permission (
read/write/propose/none)? — is exactly what a zen-engine decision table is for, and is where multi-jurisdiction role-semantics variation lives.
The owning backend reads a field’s owner from the loaded ownership table and feeds it as a field_owner attribute through the already-present ResourceRef::attrs free-form map (the zen context already serializes attrs into resource.attrs via build_check_input, so a JDM rule reads resource.attrs.field_owner with no new plumbing on the input side). The output side is new: today AuthzEngine::check is strictly boolean (its parse_check_output reads only allow + reason), so per-field resolution is NOT an overload of check. Plan Y adds a new field-permission evaluation entry on craig-authz — a sibling to check and auto_scope_list — that evaluates the SAME {jurisdiction}-authz-<resource> ruleset (which gains owner-aware rows and a new permission output token) through a new output parser, returning a typed FieldPermission (§3). This reuses the ruleset + the ADR-024 cache + RMQ-invalidation infrastructure WITHOUT forking the {jurisdiction}-authz-<resource> naming into a -fieldauth- family or duplicating the per-resource coverage gate; the existing boolean check / parse_check_output are untouched. This honors the umbrella’s field_ownership.<surface>.<field> framing — that map IS the loaded table, consulted to produce the field_owner attribute the predicate reads.
3. "Propose" is an effective-permission value, not a new Action variant
CRAIG’s Action enum (Read / List / Create / Update / Delete / Approve) is a fleet-wide coupling: every value is serialized into every zen context and matched by every {jurisdiction}-authz-<resource> ruleset, and adding a variant is a boot-coverage obligation across the authz rulesets of both georgia/ and texas/. Field-level "propose" applies to a handful of shared fields on one surface; it must NOT pay that cost.
Instead, the §2 field-permission evaluation returns an effective permission read / write / propose / none — a typed FieldPermission, the role ListScope plays as the typed result of auto_scope_list. The owning backend’s surface handler runs the record-level check under the existing Action::Update, then resolves each touched field via the §2 path: write → direct write + audit; propose → an entry in the propose-approve queue (§6), never a live write; read / none → the field mutation is rejected. The owner’s accept of a proposed edit reuses the existing Action::Approve variant (documented for custom approve / promote handlers) — a fit needing no new verb. "Propose" is thus a per-field permission value the handler routes on, not a new record-level verb the policy engine arbitrates; the coarse Action enum and the boolean check are unchanged.
4. field_ownership: FieldOwnershipContribution — pre-materialized declaration in the bundle, owner map on disk
Per ADR-032 §4 + A11, the BundleContribution aggregate grows one additive field — field_ownership: FieldOwnershipContribution. Per A11’s requirement that the anchoring ADR document the field’s shape choice + cite ADR-038 §3: the contribution is pre-materialized (a value, not a boot-time factory — no per-state operator resource is needed at construction, ADR-038 §3). It carries a pre-materialized DECLARATION (which field-bearing surfaces the bundle provides + each surface’s field roster), NOT the owner classifications. The per-jurisdiction owner MAP is runtime-loaded by the owning backend from rulesets/<jurisdiction>/cwca_ownership.toml. This mirrors ADR-035 §8 exactly (the compositions field is a pre-materialized surface declaration; the jurisdiction baseline data is runtime-loaded from rulesets/, never bundle-embedded) and refines A11’s "static ownership mappings" wording, which read as bundle-embedded owner data: ADR-032 Amendment A13 records this refinement (the A12 precedent). The contribution is self-contained primitive data (&'static str field/surface names), so non-owning consumers of craig-state-bundle (the BFF, craig-exchange, craig-reporting) inherit no enforcement dependency, exactly as the compositions / theme / terminology fields do. A bundle with no field-ownership surface sets field_ownership: FieldOwnershipContribution::default() (the cheap empty default ADR-032 §4 requires). The exact payload is a Plan Y detail; the invariant fixed here is: pre-materialized declaration in the bundle, owner map on disk, never bundle-embedded.
5. Boot validation: every declared field has an owner entry, and vice-versa
The owning backend validates the ownership map at boot, fail-fast: every field in the bundle’s surface declaration must have an owner classification in the loaded cwca_ownership.toml, and every TOML entry must name a declared field. This is the field-ownership analogue of the composition required-floor and the authz coverage gate — a boot tripwire, so a missing field-ownership entry surfaces at the boundary, not as a silent un-enforced field discovered by an end user editing data they should not. (The umbrella risk register makes this boot check a Plan Y requirement: every field on every multi-tenant edit surface MUST have a field_ownership entry.) It mirrors ADR-036 §8’s structural boot gate and ADR-034 §8’s missing-locale gate.
6. Audit covers every read and every write; the propose-approve queue lives on the owning backend; accept is one transaction
Contract 5’s mandate is audit on every read AND every write (including propose + approve). This ADR preserves it in full — both halves are owning-backend obligations Plan Y must implement, via the existing per-service audit pipeline, NOT a new BFF audit path:
-
Write/propose/accept emit role + jurisdiction + field + before/after.
-
Read emits role + jurisdiction + field (a read has no before/after). The read-audit granularity — per-field versus per-surface-read, and the high-volume implications of auditing every field read on every render — is a Plan Y Open question; the requirement (reads are audited) is fixed here so Plan Y cannot ship write-only audit.
The propose-approve queue is a per-surface table — pending_edits_<surface> — on the owning backend’s database (NOT a BFF table). A non-owner’s proposed edit inserts a pending row attributed to claims.acting_worker() (ADR-028); no live row is touched. The owning role accepts or rejects from a queue served by accept/reject endpoints on the same backend (the accept gated by Action::Approve, §3). Accept applies the proposed value to the live row AND stages the field-edit audit event in ONE transaction — the transactional-outbox pattern (ADR-022) — so the live write and its audit cannot diverge. Because the queue and the live rows share one database (§1, owning backend = live-data owner), accept is a single local transaction; a cross-service accept-write is disallowed (it would break this atomicity). craig-cases already emits a field-level change event of this shape (publish_case_updated, {field, old_value, new_value}); Plan Y extends it with role + jurisdiction + the proposing/deciding worker subs on whichever backend owns the surface — this ADR does not assume the shape exists fleet-wide.
7. The BFF reflects the backend decision; the lock-icon is a reflection, never the source of truth
The BFF renders a lock-icon per field by fetching the backend-resolved per-field permission map for the acting worker, over the existing ADR-028 on-behalf-of seam — the exact flip craig-web already performs to reach craig-composition (its client_credentials service token + an X-Craig-Actor JWT for the worker; the backend resolves the map keyed on claims.acting_worker(), so the icons reflect the WORKER, not the BFF service principal). The lock-icon is a pure reflection of the backend’s authority — the load-bearing half of Contract 5’s "the lock icon is a reflection of this table, not the source of truth," with only the enforcement home corrected from BFF to backend. The BFF never decides; a tampered client that ignores a lock still hits the backend’s per-field enforcement (§1-§3). The transport shape — a standalone GET /v1/field-ownership/{surface} probe versus ride-along annotations on the surface DTO — is a Plan Y API-ergonomics detail (Open questions).
8. Supersession-in-part of Contract 5
This ADR supersedes two Contract 5 statements that conflict with the backend-enforcement decision, the way ADR-036 superseded Contract 4’s disk-path home:
-
"Record-level authz is enforced at the BFF" → enforcement moves to the OWNING backend service (the live-data owner); the BFF lock-icon is the reflection (§1, §7).
-
"Propose-approve queue: NEW BFF surface + table" → the table (
pending_edits_<surface>) + the accept/reject API live on the owning backend; the BFF hosts only the non-authoritative Studio "Pending changes" view whose accept/reject actions call the backend API (§6).
PRESERVED unchanged: the three-way ownership taxonomy, per-role per-jurisdiction enforcement, "propose is a write-shaped action producing a queue entry," mandatory audit on every read AND every write with role + jurisdiction + field + before/after, and field_ownership: FieldOwnershipContribution as the BundleContribution extension point.
Consequences
Positive
-
The BFF keeps its ADR-004 defining property — no database, no broker, no migrations, no authz stack. Field-ownership’s stateful + authz concerns sit where the data, the zen-engine, and the audit pipeline already are.
-
No new
ActionorResourceTypevariant ⇒ no fleet-wide ruleset ripple; field-level propose is an effective-permission value under the existingUpdateverb, and owner-accept reusesApprove. -
The static-table + new-evaluation-path split keeps the per-field owner map as reviewable config (file + PR) while the dynamic role→permission resolution stays in the engine, leaving the boolean
checksurface untouched. -
Reuses proven CRAIG patterns end-to-end: the ADR-024 ruleset cache, the ADR-022 transactional outbox for audit, the ADR-028 actor seam for BFF reflection + proposer attribution, and the ADR-035 §8 declaration-in-bundle / map-on-disk shape.
Negative
-
Per-surface
pending_edits_<surface>tables are migration surface that grows with each new multi-tenant edit surface. -
The two-artifact field model (bundle declaration + on-disk owner map) is one more split contributors must learn (the same one composition introduced).
-
Which backend owns the CWCA surface is deferred to Plan Y; the rubric is constrained by the live-data-ownership requirement (§1/§6), so a session-boundary motive alone cannot move the queue off the data owner.
-
Read auditing on every field access is potential volume; the granularity is a Plan Y decision (Open questions), but the requirement is non-negotiable.
-
A new
craig-authzfield-evaluation entry + a new ruleset output token is net authz surface Plan Y must add and test (additive; the booleancheckis unchanged).
Mitigations
-
The boot validation (§5) makes a missing ownership entry a fail-fast, not a silent gap.
-
The
pending_editsaccept path is single-transaction + transactional-outbox-staged (§6), so a live write and its audit cannot diverge even under a broker outage. -
The owning-backend decision rubric (Open questions) is explicit, testable, and bounded by the live-data-ownership invariant; Plan Y records the verdict before any table lands.
Open questions
Deferred to Plan Y, not blocking acceptance:
-
Owning-service home.
craig-casesversus a NEWservices/craig-cwca-providerfor the CWCA surface, bounded by the §1/§6 rule that the chosen service must own the edited live rows. Rubric (umbrella Step 23): pin tocraig-casesIF the field surface is small (≤ ~30 fields) AND the propose-approve tables fit alongside existing case attachments (single migration, no new event topics); spincraig-cwca-providerIF (a) field count > 30, OR (b) the provider portal needs its own session/auth boundary AND owns its own live data, OR (c) the CWCA contract spans > 2 new tables. Plan Y authoring picks and records the verdict. -
Read-audit granularity. Per-field versus per-surface-read audit emission, and how to bound the volume of auditing every field read on every render.
-
The field-permission evaluation surface. The exact new
craig-authzmethod (sibling tocheck/auto_scope_list), the new ruleset output token name, theread/write/propose/nonetoken strings, and where the typedFieldPermissionenum lives incraig-authz. -
Lock-icon transport. A standalone
GET /v1/field-ownership/{surface}permission-map endpoint versus ride-along per-field annotations on the surface DTO. -
Ownership-table reload granularity. Boot-only (the §5 default, matching ADR-035’s composition-baseline decision) versus RMQ-reloadable like JDM rulesets. Resolve consistently with composition baselines.
-
pending_editswrite semantics. JSONB versus a typedproposed_valuecolumn, an optimistic-concurrency / ETag guard on accept (the same RFC 7232 question ADR-035 §5 deferred for composition writes), accept idempotency, and archive-on-reject. -
FieldOwnershipContributionpayload. Whether it carries just the surface + field roster or more (Plan Y settles, as ADR-035 §8 left `CompositionContribution’s payload to Plan X Step 1).
Alternatives considered
-
Enforce in the BFF (the literal Contract 5 sketch) — rejected. Pushing the ownership table, the
pending_editsqueue, the per-field authz resolution, and the field audit intocraig-webforcessqlx+craig-db+craig-mq+ a zen-engine stack into a deliberately stateless service (ADR-004). The umbrella makes "no DB/MQ in the BFF" an explicit Plan Y constraint. Rejected for the scope-creep reason in Context (the same rejection ADR-035 made for composition). -
Overload the existing boolean
checkto return a per-field permission — rejected.check/parse_check_outputare strictly allow/deny today; widening the boolean contract to a 4-valued permission would entangle the record-level gate with the field-level one and complicate every existing caller. A new sibling evaluation entry (§2) keeps the boolean surface intact. -
A new
{jurisdiction}-fieldauth-<surface>.jsonruleset family — rejected. Cleaner conceptual separation, but it forks the{jurisdiction}-authz-<resource>naming convention, needs a parallel coverage gate (the resource-iterating boot check does not know about surfaces), and pushes the static owner data INTO a decision table where it becomes rows of config-as-rules — the exact static/dynamic conflation §2 avoids. -
Add
Action::Propose— rejected. A newActionvariant is a fleet-wide obligation: every existing{jurisdiction}-authz-<resource>ruleset acrossgeorgia/+texas/would need a row for it or fall to default-deny, a wide ripple for a semantic that applies to a few shared fields. The effective-permission value underUpdate(§3) expresses propose without touching the record-level enum. -
Embed the owner map in the bundle (the A11 "static ownership mappings" reading) — rejected. One fewer artifact, but the ownership map is ops-edited per-jurisdiction config that varies by state and changes on a different cadence than compiled code — the identical reason ADR-035 §8 and ADR-036 §3 rejected bundle-embedding per-jurisdiction config.
rulesets/<jurisdiction>/is CRAIG’s established home; embedding would force a recompile to re-slot a field. (ADR-032 Amendment A13 records the refinement of A11’s wording to this effect.) -
A single generic
pending_editstable across all surfaces — rejected. One migration, but the umbrella rubric counts per-surface tables (the ">2 new tables" service-split trigger), and per-surface tables let each surface’sproposed_valueschema + indexes evolve independently. Contract 5’spending_edits_<surface>naming implies per-surface.
Out of scope
-
The Studio "Pending changes" admin UI and the lock-icon CSS/template work (Plan Y UI steps).
-
The owning-service pick (
craig-casesversuscraig-cwca-provider) — Plan Y authoring, per the rubric above. -
Non-CWCA edit surfaces — this ADR fixes the pattern; the CWCA provider portal is the first instance Plan Y implements.
-
The exact
cwca_ownership.tomlschema + the JDM row authoring for the CWCA field set (Plan Y). -
Record-level (row) authz — unchanged; this ADR is strictly the field-level extension under ADR-023.
Related decisions
-
ADR-023 — the multi-jurisdictional zen-engine authz stack this ADR extends to field granularity; the
{jurisdiction}-authz-<resource>rulesets gain thefield_ownerinput attribute + a new effective-permission output read by a new evaluation path (not the booleancheck). -
ADR-024 — the policy-engine ruleset cache + RMQ invalidation this reuses (the ownership map’s reload question resolves against it).
-
ADR-004 — the stateless-BFF property this decision protects (the reason enforcement is at the backend, not
craig-web). -
ADR-028 — the
X-Craig-Actoron-behalf-of seam the BFF uses to fetch the per-field permission map + the attribution source for the proposing/deciding worker. -
ADR-032 — §4 + A11 additive
BundleContributiongrowth (thefield_ownershipfield); this ADR adds ADR-032 Amendment A13 refining A11’sfield_ownershipshape (pre-materialized declaration + on-disk owner map), the same amendment mechanism A12 used for ADR-033’s plugin axis. -
ADR-038 — §3 pre-materialized field-shape rule A11 requires this ADR to cite (the contribution is a value, not a factory).
-
ADR-035 — §8 declaration-in-bundle / data-on-disk precedent
field_ownershipmirrors; the sibling that refined Contract 3’s service home as this refines Contract 5’s enforcement home. -
ADR-036 — the supersession-in-part precedent (Contract 4’s disk-path home); the mirrored §8 boot-validation posture.
-
ADR-022 — the transactional outbox staging the field-edit audit on accept.
-
ADR-031 — ADR format mirror.
-
Plan S — Multi-Jurisdiction Foundation — umbrella; this ADR is Step 22, implementation is Plan Y (Steps 23-24).
-
Contract 5: Field ownership + authz — the design-team contract this ADR realizes and whose enforcement home it refines (taxonomy + invariants preserved; the BFF-enforcement language superseded per §8).