Worker portal redesign — Stage 3 MR2 (HTTP live-override APIs)

On this page
NOTE

MR2 of Stage 3 of group epic &51 (#460). Closes #491 (HTTP live-override APIs). Predecessor: MR1 (#489 + #490) — composition runtime + DB migrations.

GitLab MR labels: type::feature, priority::medium, service::web, service::shared-crates, workflow::ready.

Status

Step Description Status

1

canopy-composition crate write helpers + AmqpAuditEmitter + loader idp_for. Add db::{insert_or_replace_document, append_patch_ops, delete_document, archive_document, compute_etag, WriteError, WritePrecondition, InsertOrReplaceArgs} (all take &mut Transaction for atomic outbox). cache::invalidate_user. audit::AmqpAuditEmitter (canopy-mq backed, render-path only). loader::idp_for (fresh disk read of idp.toml). Per-row ETag strict monotonicity via SQL GREATEST(clock_timestamp(), updated_at + interval '1 microsecond'). 11 unit tests.

Done (2026-05-22)

2

canopy-web scaffolding: lib+bin restructure, CompositionState + JSON extractors + error envelope. Refactor session.rs to expose resolve_worker_or_fail(parts) → Result<SessionData, AuthFailure> (HTML BFF unchanged byte-for-byte). NEW api/composition_session.rs (JsonAuthenticatedWorker, JurisdictionAdmin). NEW api/composition_errors.rs (CompositionApiError enum + RFC 7232/6585-clean envelope, From impls sanitize sqlx/serde_json/PublishError display strings). NEW api/composition.rs (CompositionState + helpers + PutPatchOps/PatchPatchOps extractors + stub handlers + routes()). NEW openapi.rs (CompositionApi utoipa aggregator). main.rs bootstraps Arc’d state as Extension on the composition sub-router (Decision 11); mounts /api-doc/openapi.json plain-Axum handler (Decision 10 — no Swagger UI in v1 due to strict CSP). Load-bearing SameSite=Strict comment on session cookie config (Decision 12). 10 helper unit tests + 1 router-builds smoke.

Done (2026-05-22)

3

3 GET handlers + 12 integration tests. get_live / get_role / get_user_me, each with a *_inner testable core. get_role_inner calls composition_loader.idp_for(&juris) + validate_role per Decision 5. Shared fetch_and_render helper returns 200 + ETag header + JSON GetResponse on hit, 404 on miss. Tests use EphemeralSchema::new_for_web + TempDir rulesets/georgia/idp.toml so role validation has a known role set.

Done (2026-05-22)

4

6 write handlers (PUT × 3 + PATCH × 3) + 20 integration tests. Each handler implements the Decision 9 atomic-tx pattern: db.begin() → write helper → publisher.publish_tx with v6 audit payload (actor_role/actor_user_id/layer/target_scope_key) → tx.commit() → post-commit cache invalidate. PUT enforces If-Match / If-None-Match: * per Decision 2. PATCH uses PatchPatchOps extractor which validates Content-Type FIRST (415 not 400). Role handlers gate on validate_role. user/me uses surgical cache.invalidate_user.

Done (2026-05-22)

5

DELETE + archive lifecycle endpoints + 6 integration tests. delete_live returns 204 (composition falls back to baseline per loader’s SILENT-OK). archive_live moves row to composition_documents_archive with archived_at/archived_by populated + returns ArchiveResponse { archive_id }. Both follow Decision 9 atomic-tx pattern. 404 + idempotent-404 cases covered.

Done (2026-05-22)

6

Documentation finalize. This durable plan + parent plan Status flip + CHANGELOG === Added + coding-conventions update + 3 Playwright E2E.

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):

  1. 11 endpoints matching #491 acceptance criteria verbatim — GET/PUT/PATCH on live + role + user/me, plus DELETE live + POST live/archive.

  2. PUT semantics — RFC 7232/6585 clean. If-Match: "<etag>" for replace, If-None-Match: for create, 428 if neither, 400 if both, 400 if If-None-Match value ≠ (invalid_precondition).

  3. ETag = RFC 7232 quoted microsecond stamp format!("\"{}\"", updated_at.timestamp_micros()). Per-row strict monotonicity guaranteed by SQL GREATEST(clock_timestamp(), updated_at + interval '1 microsecond') on every UPDATE — no rapid-fire microsecond collisions even under high write rates.

  4. Authorization — v1: JurisdictionAdmin maps to WorkerRole::Admin (single-jurisdiction simplification with TODO(#493) for jurisdiction-scoped admin binding). JsonAuthenticatedWorker for /user/me (any worker can mutate their own delta).

  5. scope_key resolutionlive"jurisdiction", role{role} path param (regex ^[a-z][a-z0-9_-]*$ + idp.toml presence check), user/mesession.worker_id verbatim.

  6. JSON error envelope — closed-set code/message/details shape. 12 codes covering invalid_*, ambiguous/invalid/required precondition, precondition_failed (etag_mismatch / row_already_exists), unsupported_media_type, not_found, forbidden, unauthorized, internal.

  7. 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 as CompositionLoadError::PatchFailed to the next render. This matches ADR-022 Decision 1 "Studio shall validate".

  8. Cache invalidationinvalidate_jurisdiction for live + role writes (blast radius is the whole jurisdiction). Surgical invalidate_user(juris, user_id) for /user/me writes (other users' keys unaffected).

  9. 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.

  10. OpenAPI JSON-only in v1 — /api-doc/openapi.json via plain Axum handler. Swagger UI descoped because canopy-web’s strict CSP forbids inline script/style; relaxing CSP for /swagger-ui would be a security regression. Studio consumes the JSON spec programmatically.

  11. Routing — composition router uses Extension<Arc<CompositionState>> (NOT Router<CompositionState>) so it merges cleanly into the existing Router<AppState>. Axum 0.8 has no Router<()>Router<AppState> conversion; Extension is the clean pattern and matches canopy-web’s existing Extension(service_clients) idiom.

  12. JSON routes on a BFF — scoped to /v1/composition only. CSRF middleware does NOT apply (composition uses JSON not form-submission); CSRF safety depends on the session cookie’s SameSite=Strict attribute. A load-bearing comment in main.rs pins this dependency.

  13. 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 *_inner testable-core pattern.)

  14. Identity contract — Keycloak sub claim is parsed as UUID for the composition_documents.created_by column. Non-UUID subs fail closed with 500. Stage 4 (#493) will loosen this.

  15. 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 maps AuthFailure::* 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 to resolve_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

  1. Studio unblocked — Stage 6 (#501) can read + mutate overrides via 11 RESTful endpoints without touching git.

  2. Atomic auditpublish_tx guarantees the outbox row + row mutation commit-or-rollback together. No chain gaps under publisher errors.

  3. Race-free preconditions — both IfNoneMatchStar (INSERT ON CONFLICT DO NOTHING RETURNING) and IfMatch (SELECT FOR UPDATE + UPDATE) are race-free under concurrent writes. Verified by 8 db_writes_test cases.

  4. Surgical cache invalidation — /user/me writes don’t blast the whole jurisdiction cache. Other users' keys survive.

  5. No CSRF surface — JSON routes inherit SameSite=Strict cookie protection; no CSRF token middleware needed.

Negative

  1. JSON routes on a BFF — canopy-web is now both HTML BFF and JSON API host. Scoped to /v1/composition only; documented in .claude/docs/coding-conventions.md. If future JSON endpoints land on canopy-web, evaluate whether the pattern still holds.

  2. No Swagger UI in v1 — Studio + tooling consume the JSON spec. Filing UI as a follow-up if humans want it.

  3. Render audit is best-effortcomposition.render events can drop silently if AMQP is briefly down. Chain integrity per ADR-014 applies to mutations only.

  4. Single-jurisdiction adminJurisdictionAdmin maps to WorkerRole::Admin globally. Stage 4 (#493) will tighten.

  5. Keycloak sub assumed UUID — non-UUID subs fail closed with 500 (Decision 16). Stage 4 may loosen.

Implementation

This MR is tracked under Stage 3 of epic &51 (#460):

  • MR1 (#489 + #490) — composition runtime + DB migrations (done).

  • MR2 (this plan) — #491 — HTTP live-override APIs.

  • ~~MR3 (#492)~~ — promote-live-to-baseline closed-deferred 2026-05-20 to #507 (unified config backend).

References

Edit this page · default