Worker portal redesign — Stage 3 MR2 (HTTP live-override APIs)
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
canopy-composition crate write helpers + AmqpAuditEmitter + loader idp_for. Add |
Done (2026-05-22) |
2 |
canopy-web scaffolding: lib+bin restructure, CompositionState + JSON extractors + error envelope. Refactor |
Done (2026-05-22) |
3 |
3 GET handlers + 12 integration tests. |
Done (2026-05-22) |
4 |
6 write handlers (PUT × 3 + PATCH × 3) + 20 integration tests. Each handler implements the Decision 9 atomic-tx pattern: |
Done (2026-05-22) |
5 |
DELETE + archive lifecycle endpoints + 6 integration tests. |
Done (2026-05-22) |
6 |
Documentation finalize. This durable plan + parent plan Status flip + CHANGELOG |
Done (2026-05-22) |
Tracking issue: #491
Epic: &51
Parent plan: worker-portal-redesign.adoc
Branch: feat/wpr-stage3-mr2-live-override-apis (single MR)
Context
Stage 3 MR1 shipped the read-side composition runtime (loader, cache, manifest validation, role filter, baseline RFC 7396 + DB-backed RFC 6902 merge). MR2 closes the loop with the HTTP write surface so Studio (Stage 6 #501) and the worker portal’s user-customize affordance (Stage 5 #498) can mutate the three DB-backed override layers — jurisdiction_live, role, user — without touching git. The endpoints live on canopy-web because the in-process CompositionCache lives there (ADR-021 Option C.i — single-replica invalidate-on-write).
The original "promote" endpoint (POST /v1/composition/{surface}/promote producing a git PR) is out of scope — closed-deferred to #507 (unified canopy config backend) on 2026-05-20.
Design
Decisions locked
Per the implementation plan (6 review rounds; see commit history for the v1→v6 evolution):
-
11 endpoints matching #491 acceptance criteria verbatim — GET/PUT/PATCH on live + role + user/me, plus DELETE live + POST live/archive.
-
PUT semantics — RFC 7232/6585 clean.
If-Match: "<etag>"for replace,If-None-Match:for create, 428 if neither, 400 if both, 400 ifIf-None-Matchvalue ≠(invalid_precondition). -
ETag = RFC 7232 quoted microsecond stamp
format!("\"{}\"", updated_at.timestamp_micros()). Per-row strict monotonicity guaranteed by SQLGREATEST(clock_timestamp(), updated_at + interval '1 microsecond')on every UPDATE — no rapid-fire microsecond collisions even under high write rates. -
Authorization — v1:
JurisdictionAdminmaps toWorkerRole::Admin(single-jurisdiction simplification withTODO(#493)for jurisdiction-scoped admin binding).JsonAuthenticatedWorkerfor/user/me(any worker can mutate their own delta). -
scope_keyresolution —live→"jurisdiction",role→{role}path param (regex^[a-z][a-z0-9_-]*$+ idp.toml presence check),user/me→session.worker_idverbatim. -
JSON error envelope — closed-set
code/message/detailsshape. 12 codes covering invalid_*, ambiguous/invalid/required precondition, precondition_failed (etag_mismatch / row_already_exists), unsupported_media_type, not_found, forbidden, unauthorized, internal. -
Patch op validation — none at write time. Test ops are evaluated at the next composition load by the loader’s existing
apply_json_patch_6902(already in MR1) — a failing test op surfaces asCompositionLoadError::PatchFailedto the next render. This matches ADR-022 Decision 1 "Studio shall validate". -
Cache invalidation —
invalidate_jurisdictionfor live + role writes (blast radius is the whole jurisdiction). Surgicalinvalidate_user(juris, user_id)for /user/me writes (other users' keys unaffected). -
Audit atomicity — mutation envelopes published via
publisher.publish_tx(&mut tx, &envelope)INSIDE the row-write transaction so the outbox row + composition row commit-or-rollback together. Render audit (composition.render) is best-effort (non-transactional, fire-and-warn on publish failure) — render must not break because AMQP is briefly unavailable. -
OpenAPI JSON-only in v1 —
/api-doc/openapi.jsonvia plain Axum handler. Swagger UI descoped because canopy-web’s strict CSP forbids inline script/style; relaxing CSP for/swagger-uiwould be a security regression. Studio consumes the JSON spec programmatically. -
Routing — composition router uses
Extension<Arc<CompositionState>>(NOTRouter<CompositionState>) so it merges cleanly into the existingRouter<AppState>. Axum 0.8 has noRouter<()>→Router<AppState>conversion; Extension is the clean pattern and matches canopy-web’s existingExtension(service_clients)idiom. -
JSON routes on a BFF — scoped to
/v1/compositiononly. CSRF middleware does NOT apply (composition uses JSON not form-submission); CSRF safety depends on the session cookie’sSameSite=Strictattribute. A load-bearing comment inmain.rspins this dependency. -
Test counts — 11 unit (Step 1) + 1 smoke (Step 2) + 12 GET (Step 3) + 20 write (Step 4) + 6 lifecycle (Step 5) + 3 E2E (Step 6) = 53 new tests. (The plan’s draft count of 78 over-counted HTTP-stack matrix slots that are actually covered at the extractor/helper-unit-test level via the
*_innertestable-core pattern.) -
Identity contract — Keycloak
subclaim is parsed as UUID for thecomposition_documents.created_bycolumn. Non-UUID subs fail closed with 500. Stage 4 (#493) will loosen this. -
JSON-aware session extractors — preserve the HTML BFF’s full refresh / fail-closed semantics via a shared
pub(crate) async fn resolve_worker_or_fail(parts) → Result<SessionData, AuthFailure>. HTML BFF mapsAuthFailure::*to /login redirect; JSON extractors map to 401 envelope.
Reused symbols
-
canopy_composition::CompositionLoader::idp_for+with_audit_emitter -
canopy_composition::CompositionCache::{invalidate_jurisdiction, invalidate_user} -
canopy_composition::JurisdictionRegistry::uuid_for+StaticJurisdictionRegistry -
canopy_composition::db::{insert_or_replace_document, append_patch_ops, delete_document, archive_document, compute_etag}+WriteError+WritePrecondition+InsertOrReplaceArgs -
canopy_composition::AmqpAuditEmitter -
canopy_mq::Publisher::publish_tx+EventEnvelope::new -
services/canopy-web/src/session.rs::AuthenticatedWorker(refactored to delegate toresolve_worker_or_fail; behavior unchanged byte-for-byte)
Audit payload (v6 schema, used by all 8 mutation envelopes)
serde_json::json!({
"jurisdiction": juris.0.as_str(),
// Actor — WHO did the mutation. Always populated.
"actor_role": session.role.as_str(), // "admin" | "supervisor" | "caseworker" | ...
"actor_user_id": &session.worker_id, // Keycloak sub
// Target — WHICH layer was mutated and which scope key. Layer-dependent:
// live: layer="jurisdiction_live", target_scope_key="jurisdiction"
// role: layer="role", target_scope_key="<role-slug>"
// user: layer="user", target_scope_key="<worker_id>"
"layer": "jurisdiction_live" | "role" | "user",
"target_scope_key": ...,
"surface": "worker_dashboard" | ...,
"before_etag": Option<String>, // None on first write; serializes as null
"after_etag": String, // sentinel on delete/archive
"action": "live.put" | "live.patch" | ...,
})
source_service = "canopy-web". Topic = composition.{action} (e.g. composition.live.put, composition.role.patch). canopy-security’s wildcard # subscriber catches these and computes the JWS hash chain at persistence per ADR-014.
Consequences
Positive
-
Studio unblocked — Stage 6 (#501) can read + mutate overrides via 11 RESTful endpoints without touching git.
-
Atomic audit —
publish_txguarantees the outbox row + row mutation commit-or-rollback together. No chain gaps under publisher errors. -
Race-free preconditions — both
IfNoneMatchStar(INSERT ON CONFLICT DO NOTHING RETURNING) andIfMatch(SELECT FOR UPDATE + UPDATE) are race-free under concurrent writes. Verified by 8 db_writes_test cases. -
Surgical cache invalidation — /user/me writes don’t blast the whole jurisdiction cache. Other users' keys survive.
-
No CSRF surface — JSON routes inherit
SameSite=Strictcookie protection; no CSRF token middleware needed.
Negative
-
JSON routes on a BFF — canopy-web is now both HTML BFF and JSON API host. Scoped to
/v1/compositiononly; documented in.claude/docs/coding-conventions.md. If future JSON endpoints land on canopy-web, evaluate whether the pattern still holds. -
No Swagger UI in v1 — Studio + tooling consume the JSON spec. Filing UI as a follow-up if humans want it.
-
Render audit is best-effort —
composition.renderevents can drop silently if AMQP is briefly down. Chain integrity per ADR-014 applies to mutations only. -
Single-jurisdiction admin —
JurisdictionAdminmaps toWorkerRole::Adminglobally. Stage 4 (#493) will tighten. -
Keycloak sub assumed UUID — non-UUID subs fail closed with 500 (Decision 16). Stage 4 may loosen.
References
-
Plan: Worker portal redesign (#460)
-
Plan: Stage 3 MR1 (#489 + #490)
-
RFC 7232 (HTTP Conditional Requests — If-Match / If-None-Match)
-
RFC 6585 (Additional HTTP Status Codes — 428 Precondition Required)
-
RFC 6902 (JSON Patch)
-
Epic &51 — Worker portal redesign.