Plan: Field Ownership + Authz Extension (Plan Y)
On this page
Status
| Step | Description | Status |
|---|---|---|
Y1 |
|
Done (2026-06-19) — |
Y2 |
Plan-completion audit + archive: close the Y1/Y2 issues, file the Phase-11 §4–§7 application issue under epic &50 (which stays open as the durable tracker), backfill the lag-by-one umbrella Step 24 Y1 cite, register |
Done (2026-06-19) — this archive MR. Fresh plan-completion-audit subagent run (caught + fixed the Y1 cell’s "12 tests" → 11, the only finding). |
Epic: &50
Issues: #631 (Y1), #632 (Y2)
Branch: feat/plan-y-step{N}-{slug} per step
Context
ADR-037 decides that per-field ownership for multi-tenant edit surfaces is enforced at the owning backend (not the BFF; ADR-004 preserved). Field ownership is intrinsically cross-tenant (state agency vs external provider), so a faithful end-to-end enforced surface requires the external-provider second tenant — the Phase-11 CWCA provider portal, the real first consumer.
Plan Y therefore delivers the one part that is real, reusable, and consumer-independent now — the craig-authz field-permission evaluation capability (ADR-037 §2/§3) — and sequences the surface-bound application (ADR-037 §4–§7) to land with the portal. This is normal incremental implementation of an ADR: Plan Y satisfies §2/§3, Phase 11 satisfies §4–§7. ADR-037 §Out-of-scope’s "the CWCA provider portal is the first instance Plan Y implements" framing is realized in Phase 11 per the umbrella’s sequencing (the umbrella service-boundary table: "initially craig-cases for case-attached fields; future scope: NEW craig-cwca-provider if the CWCA boundary is substantial enough"). Building §4–§7 now would be either a fig leaf (a synthetic intra-agency surface that dresses role-hierarchy approval as cross-tenant ownership and mis-fits the state/provider/shared taxonomy) or throwaway (a synthetic host the portal replaces). The latter is empirically grounded: craig-composition does not consume its own compositions bundle axis — it reads rulesets/ directly — so a field_ownership bundle axis added now, ahead of its consumer, would be unconsumed ceremony.
Scope
In scope (Plan Y, executes at umbrella Step 24):
-
FieldPermission { Read, Write, Propose, None }incraig-authz— a typed result, the roleListScopeplays forauto_scope_list— with doc comments and the derive set nearby public enums carry. -
resolve_field_permission(&Claims, ResourceRef<'_>, field: &str, field_owner: &str) → Result<FieldPermission, ApiError>on the#[async_trait] AuthzEnginetrait (default-deny default impl;ZenAuthzEngineconcrete impl), reusing the existing ruleset + cache + RMQ-invalidation infrastructure and a NEWparse_field_permission_outputreading a disjointfield_permissionoutput token. -
Fixture-ruleset unit/integration tests proving owner-classification × role → permission (incl. the
providertoken parsed for Phase-11 readiness), the hitPolicy-firstordering/discriminator contract in both directions, cache-miss →None, malformed-output →Err, and the existingcheck/auto_scope_listbehaviour unperturbed.
Out of scope — sequenced to Phase 11 (the CWCA provider portal, its real consumer + 2nd tenant):
-
The
BundleContribution.field_ownershipaxis (ADR-037 §4) — lands with the consumer (mirrors howcraig-compositionreadsrulesets/directly rather than consuming itscompositionsaxis). -
Per-surface ownership tables
rulesets/<jurisdiction>/<surface>_ownership.toml+ the boot loader/validator (ADR-037 §5). -
pending_edits_<surface>+ propose/accept/reject + field-attached audit (ADR-037 §6). -
The BFF per-field permission-map endpoint + lock-icon reflection + Studio pending-changes view (ADR-037 §7).
-
All consumer-driven (shape fixed by the real provider surface) — built in Phase 11, tracked under epic &50 (which stays open past Plan Y).
Design
Y1 — the field-permission evaluation capability (ADR-037 §2/§3)
The coexistence pattern is already proven in craig-authz: check and auto_scope_list share ONE ruleset via input discrimination (build_check_input includes resource.attrs; build_scope_input omits it) and separate parsers reading disjoint outputs (allow vs scope). resolve_field_permission mirrors this exactly, adding a third disjoint output (field_permission).
// crates/craig-authz/src/types.rs — derives + docs matching nearby pub enums (e.g. ListScope);
// add to the crate-root `pub use types::{…}` re-export like ListScope/Action (crate denies missing_docs + unreachable_pub).
/// The effective per-field permission for the acting worker on one field (ADR-037 §3).
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum FieldPermission { Read, Write, Propose, None }
// crates/craig-authz/src/engine.rs — new method on the #[async_trait] AuthzEngine trait (Tier-O dyn trait, ADR-038 §1)
/// Resolve the acting worker's effective permission for `field` (owner classification
/// `field_owner`, e.g. "state"/"provider"/"shared") on `resource`. Fail-closed.
async fn resolve_field_permission(
&self, _claims: &Claims, _resource: ResourceRef<'_>, _field: &str, _field_owner: &str,
) -> Result<FieldPermission, ApiError> {
Ok(FieldPermission::None) // default-deny; ZenAuthzEngine overrides (params unused here → `_`-prefixed)
}
field + field_owner are EXPLICIT method params (not a hidden ResourceRef::attrs contract). The ZenAuthzEngine impl builds the zen input by calling build_check_input(claims, &resource, Action::Update) — which already serializes resource.attrs into the input’s resource.attrs object — then inserts field + field_owner into that object, so a JDM rule reads resource.attrs.field_owner + resource.attrs.field. Then ruleset_name_for → lazy_load (cache-miss → None, fail-closed) → evaluate → a NEW parse_field_permission_output reading a NEW field_permission string token (read/write/propose/none). This is the method signature ADR-037 §Open-questions explicitly defers to Plan Y; the existing ResourceRef::attrs plumbing it builds on is unchanged.
hitPolicy-first ordering/discriminator contract (the load-bearing correctness rule): the existing {jurisdiction}-authz-<resource> table is hitPolicy first with broad rows (e.g. a supervisor matches any action) that do NOT gate on field_owner. So field-aware rows MUST be prepended (ordered before the broad rows) AND gate on resource.attrs.field_owner — then: (a) for a field-eval input (field_owner present) a field-aware row matches first → emits field_permission; (b) for a record-level check input (field_owner absent) the field-aware rows' field_owner condition is unsatisfied → they don’t match → evaluation falls through to the existing broad rows unchanged. This is the contract Phase-11’s production ruleset rows must follow; Plan Y PROVES it in a fixture (broad rows + prepended field-aware rows, tested in BOTH directions). The field_permission output is disjoint from allow/scope/reason, so the existing check/parse_check_output (which read only allow + reason) are UNTOUCHED and no -fieldauth- ruleset fork is needed (ADR-037 §2). A default-deny default impl keeps existing in-tree AuthzEngine test doubles compiling unchanged (the Tier-O dyn-trait + test-double rationale, ADR-038 §1).
Tests use a FIXTURE ruleset (the crates/craig-authz/tests/fixtures/ pattern), NOT a production ruleset — production field_permission rows land in Phase 11 with the real surface, so Plan Y adds no dormant production config. Test matrix (aligned to Contract 5: a non-owner READS state-/provider-owned fields, both roles read shared, the non-owner may PROPOSE on shared):
| field_owner | role | expected FieldPermission |
|---|---|---|
|
caseworker (non-owner) |
|
|
supervisor (owner) |
|
|
caseworker (non-owner) |
|
|
supervisor (owner) |
|
|
(token parsed; Phase-11 role) |
parsed (no panic; resolves per rule) |
(a field a role may not access) |
— |
|
Plus: cache-miss → None; malformed field_permission output → Err; and a regression asserting check/auto_scope_list outputs are unchanged for inputs WITHOUT field_owner (the discriminator contract, direction (b)).
The Phase-11 hand-off (recorded, not built)
ADR-037 §4–§7 (the field_ownership bundle axis, per-surface TOML + boot-validate, the pending_edits_<surface> queue + propose/accept/reject + field audit, the BFF lock-icon + Studio) are the surface-bound application — consumer-driven, landing in the Phase-11 CWCA provider portal (the first multi-tenant instance + 2nd tenant), which calls resolve_field_permission unchanged. The verified design details (the pending_edits_<surface> shape, single-transaction accept reusing Action::Approve, per-GET read audit, the craig-cases-vs-craig-cwca-provider owning-service rubric, encryption-aware writes for PII + multi-row person addressing) live in ADR-037 as the Phase-11 spec. Y2 files the durable Phase-11 tracker issue under epic &50.
Steps
Step Y1: craig-authz field-permission capability
Files: crates/craig-authz/src/types.rs (the FieldPermission enum + docs), crates/craig-authz/src/engine.rs (the trait method + ZenAuthzEngine impl + parse_field_permission_output), crates/craig-authz/src/lib.rs (add FieldPermission to the existing pub use types::{…} crate-root re-export, matching ListScope/Action), a test fixture ruleset + tests under crates/craig-authz/tests/. ALSO move this plan’s nav entry Planned → Active (lifecycle). See Y1 — the field-permission evaluation capability (ADR-037 §2/§3). One MR, ≤ ~350 LOC.
Step Y2: plan-completion audit + archive + Phase-11 hand-off
Files: the umbrella, nav.adoc, plans/archive.adoc, CHANGELOG.adoc, shared-crates.adoc (the new resolve_field_permission surface). Run a fresh plan-completion-audit subagent; cargo xtask docs plan-archive; close the Y1/Y2 issues; file the Phase-11 §4–§7 application issue under epic &50 and KEEP &50 open as the durable tracker for the deferred surface-bound work (link the implementation guide Portals material); backfill the lag-by-one Step 21/22 umbrella cells.
Verification
-
cargo nextest run -p craig-authz— the fixture-ruleset resolution matrix (both discriminator directions) + thecheck/auto_scope_list-unchanged regression. -
cargo xtask validategreen (quality budgets, axis-coverage, clippy-D warningsincl. the docs + anypub use). -
The capability is callable (
Arc<dyn AuthzEngine>::resolve_field_permission) and fail-closed by default.
Documentation Updates
-
shared-crates.adoc— the newresolve_field_permissionon thecraig-authzsurface (Y2). -
CHANGELOG.adoc— an== Unreleasedentry per step. -
Antora — this plan body; archive at Y2.