Plan: Field Ownership + Authz Extension (Plan Y)

On this page

Status

Step Description Status

Y1

craig-authz field-permission capability: a FieldPermission enum + a NEW resolve_field_permission evaluation path (sibling to check/auto_scope_list) + parse_field_permission_output, proven against a fixture ruleset. The boolean check is untouched. (Also moves this plan’s nav entry Planned → Active.)

Done (2026-06-19) — FieldPermission + resolve_field_permission (default-deny default impl + ZenAuthzEngine impl) + parse_field_permission_output in craig-authz; fixture-ruleset matrix + the hitPolicy-first discriminator contract (both directions) + cache-miss/malformed/default-impl tests (11 tests); nav moved Planned→Active. MR !740 / c56c00c2 (merge 26e7234a).

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 resolve_field_permission in shared-crates.adoc, record the Phase-11 hand-off.

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). cargo xtask docs plan-archive git-mv’d this body to plans/archive/; nav Active entry removed; a plans/archive.adoc § Architecture row added. Umbrella Step 24 → Done + its Y1 cite (!740 / c56c00c2 / merge 26e7234a) backfilled. The Phase-11 §4–§7 application issue (#634) filed under epic &50 (kept open as the durable tracker). resolve_field_permission + craig-composition-engine registered in shared-crates.adoc (the latter folds in #628). #631 + #632 + #628 closed. !MR / sha backfilled at umbrella Step 25 (lag-by-one).

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 } in craig-authz — a typed result, the role ListScope plays for auto_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] AuthzEngine trait (default-deny default impl; ZenAuthzEngine concrete impl), reusing the existing ruleset + cache + RMQ-invalidation infrastructure and a NEW parse_field_permission_output reading a disjoint field_permission output token.

  • Fixture-ruleset unit/integration tests proving owner-classification × role → permission (incl. the provider token parsed for Phase-11 readiness), the hitPolicy-first ordering/discriminator contract in both directions, cache-miss → None, malformed-output → Err, and the existing check/auto_scope_list behaviour unperturbed.

Out of scope — sequenced to Phase 11 (the CWCA provider portal, its real consumer + 2nd tenant):

  • The BundleContribution.field_ownership axis (ADR-037 §4) — lands with the consumer (mirrors how craig-composition reads rulesets/ directly rather than consuming its compositions axis).

  • 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_forlazy_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

state

caseworker (non-owner)

Read

state

supervisor (owner)

Write

shared

caseworker (non-owner)

Propose

shared

supervisor (owner)

Write

provider

(token parsed; Phase-11 role)

parsed (no panic; resolves per rule)

(a field a role may not access)

None

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

  1. cargo nextest run -p craig-authz — the fixture-ruleset resolution matrix (both discriminator directions) + the check/auto_scope_list-unchanged regression.

  2. cargo xtask validate green (quality budgets, axis-coverage, clippy -D warnings incl. the docs + any pub use).

  3. The capability is callable (Arc<dyn AuthzEngine>::resolve_field_permission) and fail-closed by default.

Documentation Updates

  • shared-crates.adoc — the new resolve_field_permission on the craig-authz surface (Y2).

  • CHANGELOG.adoc — an == Unreleased entry per step.

  • Antora — this plan body; archive at Y2.

Edit this page · latest