Skip to main content

Task Breakdown: Downgrade User — Phase 1: Quota Enforcement

Generated from downgrade-user.md (RFC re-review score: 7.5/10 Strong, PROCEED with notes). Mode: Horizontal (Phase 1 UI mocked → Phase 2 API integration). Scope: full-stack, all stories included.

Effort Summary

Phase / AreaFE daysBE daysQA daysTotal
Phase 1 — UI (mocked)4.01.05.0
Phase 2 — API Integration1.510.04.015.5
Grand total5.510.05.020.5

Confidence: medium. Biggest unknowns: (1) FE-1/FE-2 visual layout is Figma-blocked — shell/store estimates assume placeholder markup, real design could add rework once frames land; (2) the restoration branch (Task 2.8) depends on P8 shipping the Resolved field (Decision 6) and the USER_SEAT_BILLING_CODE value (Decision 7) — code can be written now but can't be verified end-to-end in staging until both land.


Phase 1 — UI (APIs mocked)

Task 1.1: [FE] DowngradeWarningModal shell + store (DU-S04)

A logged-in Owner/Admin/Supervisor sees a warning that their company is over its user quota, with a list of at-risk users, when they log in.

Status: ⚠️ Partially blocked — Figma frame for the Warning Popup is pending (§1 Design References says n/a — design pending). Store, fetch call (mocked response), 404/error handling, and a11y wiring are NOT blocked (per Detail 2.G/2.H) and can be built now with placeholder markup, then restyled once the frame lands.

Design reference: n/a — design pending. DS version: @mekari/pixel3@1.0.8 (confirmed in package.json). Frame: TBD. Design QA: TBD.

What to build

downgradeWarningStore.ts (Pinia store, composition API) holding warningActive, milestone, atRiskUsers, popupAcknowledged, plus DowngradeWarningModal.vue — a placeholder MpModal wired to the store, rendered from InitComponent.vue alongside the existing auth/menu init calls.

Correction from RFC: Detail 4.C FE-1 states the store path as app/stores/downgradeWarningStore.ts — that top-level directory doesn't exist in this repo. Every existing Pinia store (authStore.ts, menuStore.ts, ssoCallbackStore.ts) lives in app/common/store/, imported via ~/common/store/xxxStore. Using the corrected path below.

Implementation Plan

ActionFileWhat changes
createapp/common/store/downgradeWarningStore.tsPinia store: warningActive, milestone, atRiskUsers, popupAcknowledged state; fetchDowngradeStatus() action (mocked for now)
createapp/layouts/components/DowngradeWarningModal.vuePlaceholder MpModal (no is-disable-focus-trap, per Detail 2.H), renders atRiskUsers, "Remind me later" dismiss
extendapp/layouts/components/InitComponent.vueAdd downgradeStore.fetchDowngradeStatus() call after menuStore.initializeMenu() (line 30) — separate call, not merged into fetchAuthLaunchpad(), per Detail 2.G
createapp/common/store/__tests__/downgradeWarningStore.spec.tsState transitions, snake_case→camelCase mapping
createapp/layouts/components/__tests__/DowngradeWarningModal.spec.tsRenders when warningActive=true, hidden when false or list empty

Implementation steps

  1. Open app/layouts/components/InitComponent.vue and app/common/store/menuStore.ts — note the defineStore("name", () => {...}) composition style and the onBeforeMount sequencing pattern already used for authStore/menuStore.
  2. Write failing tests: app/common/store/__tests__/downgradeWarningStore.spec.ts — assert store defaults, assert fetchDowngradeStatus() maps at_risk_usersatRiskUsers etc. (Detail 2.G mapping table). Run pnpm test -- downgradeWarningStore.spec.ts, confirm red.
  3. Scaffold downgradeWarningStore.ts with state + a fetchDowngradeStatus() stub returning a mocked response shape ({ warning_active, milestone, at_risk_users, popup_acknowledged }) — real call added in Task 2.9.
  4. Scaffold DowngradeWarningModal.vue with MpModal/MpModalContent/MpModalBody/MpModalCloseButton (same subcomponents as ModalDeleteUser.vue), bind is-open to warningActive, render atRiskUsers as a plain list (placeholder markup — restyle once Figma lands).
  5. Wire InitComponent.vue: import useDowngradeWarningStore, call fetchDowngradeStatus() after menuStore.initializeMenu().
  6. Run pnpm test -- downgradeWarningStore.spec.ts DowngradeWarningModal.spec.ts until green.
  7. Run pnpm lint && pnpm type-check.

Acceptance criteria

  • Store correctly maps all snake_case API fields to camelCase state
  • Modal renders when warningActive=true and atRiskUsers.length > 0
  • Modal does not render when warningActive=false or atRiskUsers is empty (Detail 2.H empty-state spec)
  • MpModal does not pass is-disable-focus-trap (default focus trap stays on, per Detail 2.H)
  • (Pending Figma) Final visual layout/copy — placeholder markup only for now

Test strategy

Mock fetchDowngradeStatus()'s HTTP call; assert the store's camelCase state matches the mocked snake_case response. Assert DowngradeWarningModal renders/hides based on store state, not on a direct prop.

Effort estimate

DisciplineDays
Frontend2.0
Backend
QA0.5
Total2.5

Assumptions: new composable + new component (no existing pattern to fully reuse, though ModalDeleteUser.vue gives the MpModal skeleton); real API wiring excluded (Task 2.9).

Run to verify

pnpm test -- downgradeWarningStore.spec.ts DowngradeWarningModal.spec.ts && pnpm lint

Depends on

None — actionable now.


Task 1.2: [FE] DowngradeRestrictedModal shell (DU-S07)

A logged-in Owner/Admin/Supervisor sees a final notification listing which users were restricted, after the 1-month enforcement fires, and can acknowledge it.

Status: ⚠️ Partially blocked — same Figma gate as Task 1.1 (Final Restriction Popup frame n/a — design pending). Acknowledge-button wiring (mocked) and error-state handling (Detail 2.H: keep modal open on PATCH failure, allow retry) are actionable now.

Design reference: n/a — design pending. DS version: @mekari/pixel3@1.0.8. Frame: TBD. Design QA: TBD.

What to build

DowngradeRestrictedModal.vue, rendered from InitComponent.vue after Task 1.1's modal, gated on popupAcknowledged=false in the same store.

Implementation Plan

ActionFileWhat changes
createapp/layouts/components/DowngradeRestrictedModal.vuePlaceholder MpModal, "Acknowledge" button (mocked action for now)
extendapp/common/store/downgradeWarningStore.tsAdd acknowledgePopup() action (mocked for now — real call in Task 2.10)
extendapp/layouts/components/InitComponent.vueWire DowngradeRestrictedModal after DowngradeWarningModal (per Detail 4.C FE-2)
createapp/layouts/components/__tests__/DowngradeRestrictedModal.spec.tsRenders when popupAcknowledged=false; button click calls acknowledgePopup(); failure keeps modal open

Implementation steps

  1. Open app/layouts/components/DowngradeWarningModal.vue (from Task 1.1) — reuse the same MpModal subcomponent set and store-driven visibility pattern.
  2. Write failing tests in DowngradeRestrictedModal.spec.ts covering render-gate and the error-state AC below. Run pnpm test -- DowngradeRestrictedModal.spec.ts, confirm red.
  3. Scaffold DowngradeRestrictedModal.vue, bind is-open to !popupAcknowledged.
  4. Add acknowledgePopup() stub to the store returning a mocked { acknowledged: true } — real PATCH added in Task 2.10.
  5. Wire the button's @click to acknowledgePopup(); on failure (mocked reject for the test), keep is-open true and re-enable the button (Detail 2.H error-state spec).
  6. Wire in InitComponent.vue below DowngradeWarningModal.
  7. Run pnpm test -- DowngradeRestrictedModal.spec.ts until green, then pnpm lint && pnpm type-check.

Acceptance criteria

  • Modal renders when popupAcknowledged=false
  • Acknowledge button click sets popupAcknowledged=true optimistically only after a 200 (Detail 2.G — no optimistic update on failure)
  • On mocked failure, modal stays open and button re-enables for retry
  • (Pending Figma) Final visual layout/copy

Test strategy

Mock acknowledgePopup() success and failure paths; assert modal visibility and button state react correctly to each.

Effort estimate

DisciplineDays
Frontend2.0
Backend
QA0.5
Total2.5

Assumptions: reuses Task 1.1's store and modal subcomponent pattern — cheaper than 1.1 despite same nominal size class.

Run to verify

pnpm test -- DowngradeRestrictedModal.spec.ts && pnpm lint

Depends on

  • Task 1.1 (shares downgradeWarningStore.ts and InitComponent.vue wiring point)

Phase 2 — API Integration

Task 2.1: [BE] DB migrations — is_system, previous_role_id, popup_acknowledged, feature seed (DU-S01, DU-S02, DU-S07)

No direct user-facing behavior — this lays the schema foundation every other BE task depends on.

Status: ✅ Actionable now.

What to build

Four migrations per Detail 2.3: company_roles.is_system, user_roles.previous_role_id, users.downgrade_popup_acknowledged, and the downgrade_user_enforcement_enabled feature seed row.

Implementation Plan

ActionFileWhat changes
createdb/migration/20260701000001_add_is_system_to_company_roles.up.sql (+.down.sql)ALTER TABLE company_roles ADD COLUMN is_system BOOLEAN NOT NULL DEFAULT false + partial index
createdb/migration/20260701000002_add_previous_role_id_to_user_roles.up.sql (+.down.sql)ALTER TABLE user_roles ADD COLUMN previous_role_id UUID REFERENCES company_roles(id) ON DELETE SET NULL
createdb/migration/20260701000003_add_downgrade_popup_acknowledged_to_users.up.sql (+.down.sql)ALTER TABLE users ADD COLUMN downgrade_popup_acknowledged BOOLEAN NOT NULL DEFAULT false
createdb/migration/20260701000004_seed_downgrade_enforcement_feature.up.sql (+.down.sql)INSERT INTO features (...) VALUES (..., 'downgrade_user_enforcement_enabled', ...) ON CONFLICT (code) DO NOTHING

Implementation steps

  1. Open db/migration/000010_add_status_to_users.up.sql (cited in RFC Detail 2.0 as the migration-style reference) — note the minimal-SQL, no-transaction-wrapper convention.
  2. Write each .up.sql/.down.sql pair per the RFC's Detail 2.3 exact DDL (already fully specified — copy verbatim).
  3. Run make migrate-up locally against dev DB.
  4. Verify: SELECT is_system FROM company_roles LIMIT 1 returns false; SELECT previous_role_id FROM user_roles LIMIT 1 returns null; features table has the new row.
  5. Run make migrate-down once to confirm rollback is clean, then make migrate-up again.

Acceptance criteria

  • All 4 migrations apply cleanly via make migrate-up
  • All 4 .down.sql roll back cleanly
  • New columns have correct defaults; existing rows unaffected (per RFC Compatibility section)

Test strategy

No unit tests — verified via make migrate-up/migrate-down round-trip and manual SELECT checks.

Effort estimate

DisciplineDays
Backend0.5
QA
Total0.5

Assumptions: DDL is already fully specified in the RFC (Detail 2.3) — this is transcription + verification, not design.

Run to verify

make migrate-up && make migrate-down && make migrate-up

Depends on

None.


Task 2.2: [BE] SQLC query — GetCompanyFeatureByCode (DU-S01)

No direct user-facing behavior — enables the feature-toggle check every downgrade flow gates on.

Status: ✅ Actionable now.

Implementation Plan

ActionFileWhat changes
createdb/query/company_features.sql-- name: GetCompanyFeatureByCode :one — join company_featuresfeatures filtered by company_id, code

Implementation steps

  1. Open db/query/user_roles.sql — note the SQLC annotation style and parameterized-query convention.
  2. Write the GetCompanyFeatureByCode query.
  3. Run sqlc generate.
  4. Run go build ./... — confirm the generated function compiles and is callable.

Acceptance criteria

  • GetCompanyFeatureByCode(ctx, company_id, code) compiles and returns the expected row shape
  • Query is parameterized (no string interpolation)

Test strategy

No standalone test file — exercised indirectly via Task 2.5's EvaluateToggle() unit tests with a mocked IStore.

Effort estimate

DisciplineDays
Backend0.5
QA
Total0.5

Assumptions: company_features table (existing) has zero SQLC queries today (confirmed) — this is the first one, but the join is simple.

Run to verify

sqlc generate && go build ./...

Depends on

None.


Task 2.3: [BE] SQLC queries — roles, user_roles, users extensions (DU-S02, DU-S06, DU-S07, DU-S08)

No direct user-facing behavior — provides every query the enforcement/restoration/popup-reset logic needs.

Status: ✅ Actionable now.

Implementation Plan

ActionFileWhat changes
extenddb/query/company_roles.sqlAdd UpsertInactiveRole, GetInactiveRoleByCompanyID
extenddb/query/user_roles.sqlAdd GetExcessMemberRoleUsers (with LIMIT 500, Decision 4 batch guard), BulkAssignInactiveRole, GetInactiveUsersForCompany, RestoreInactiveUserRoles
extenddb/query/users.sqlAdd GetUserDowngradeStatus, ResetDowngradePopupAcknowledgedForCompany (exact SQL in Decision 5)

Implementation steps

  1. Open db/query/user_roles.sql (existing InsertUserRole, DeleteUserRoleByUserId, CountAssociatedUsers) for annotation style.
  2. Add each new query. For GetExcessMemberRoleUsers, order by current_sign_in_at ASC NULLS FIRST and cap LIMIT 500 (Decision 4). For ResetDowngradePopupAcknowledgedForCompany, use the exact SQL from Decision 5 (WHERE user_access IN ('owner','admin','supervisor')).
  3. Run sqlc generate.
  4. Run go build ./....

Acceptance criteria

  • All 7 new query functions compile
  • GetExcessMemberRoleUsers includes LIMIT 500 and correct ORDER BY
  • ResetDowngradePopupAcknowledgedForCompany restricts to owner/admin/supervisor only (Decision 5)

Test strategy

No standalone test file — exercised via Task 2.5's service-layer unit tests with a mocked IStore.

Effort estimate

DisciplineDays
Backend1.5
QA
Total1.5

Assumptions: 7 queries across 3 files; ordering/limit logic (GetExcessMemberRoleUsers) is the only non-trivial one — rest are straightforward parameterized UPDATE/SELECT.

Run to verify

sqlc generate && go build ./...

Depends on

Task 2.1 (needs the new columns to exist).


Task 2.4: [BE] Roles API — filter is_system (DU-S02)

An admin viewing the roles list never sees the internal "Inactive" system role.

Status: ✅ Actionable now.

Implementation Plan

ActionFileWhat changes
extendexisting roles list query in db/query/company_roles.sqlAdd WHERE is_system = false

Implementation steps

  1. Open the existing roles-list query in db/query/company_roles.sql.
  2. Add the is_system = false filter clause.
  3. Run sqlc generate && go build ./....
  4. Write/extend a handler-level unit test asserting a mocked is_system=true row is excluded from the response.

Acceptance criteria

  • GET /iag/v1/roles response contains zero rows with is_system=true (unit test with mock returning one such row)
  • Existing roles-list behavior for is_system=false rows unchanged

Test strategy

Unit test on the handler/repository layer with a mocked IStore returning one is_system=true and one is_system=false row; assert only the latter appears in the response.

Effort estimate

DisciplineDays
Backend0.5
QA0.5
Total1.0

Assumptions: single WHERE clause addition to an existing, already-tested query.

Run to verify

make test

Depends on

Task 2.1 (needs is_system column).


Task 2.5: [BE] DowngradeService + Mailer methods (DU-S01, DU-S03, DU-S05, DU-S06, DU-S08)

The core business logic: evaluates the toggle, sends warning/final emails, and bulk-assigns/restores roles — everything downstream depends on this.

Status: ✅ Actionable now.

Implementation Plan

ActionFileWhat changes
createinternal/app/service/downgrade/service.goIDowngradeService interface, constructor
createinternal/app/service/downgrade/evaluate_toggle.goEvaluateToggle() — Decision 1 branch logic
createinternal/app/service/downgrade/warning.goWarning-flow logic (Redis HSET, SendWarningEmail)
createinternal/app/service/downgrade/enforcement.goEnforceInactiveRole() — upsert + bulk assign, Decision 4
createinternal/app/service/downgrade/restore.goRestoreRoles() — Decision 6/7 aware
createinternal/app/service/downgrade/popup_status.goGetPopupStatus() for the handler
extendinternal/app/mailer/IMailer.goAdd SendWarningEmail, SendFinalRestrictionEmail
createinternal/app/mailer/downgrade_mailer.goSendGrid implementations

Implementation steps

  1. Open internal/app/service/users/assign_role.go — the RFC's own cited pattern for context propagation, repo call, cache invalidation, audit log (auditLogger.CreateAuditLog(ctx, event, data) at line 150+).
  2. Open internal/app/service/users/update_status.go:56-77 — read to confirm what NOT to do (users.status triggers Chat/CRM deletion; downgrade must use role-based restriction instead, per RFC's explicit warning).
  3. Write failing tests per method (toggle OFF/ON, warning idempotency, bulk assign ordering, restore skip-null-previous_role_id). Run go test -race ./internal/app/service/downgrade/..., confirm red.
  4. Implement EvaluateToggle() using Task 2.2's query.
  5. Implement warning flow using Task 2.3's GetExcessMemberRoleUsers, Redis HSET (Decision 2 JSON-array serialization), downgrade_email_sent:{cid}:{seq} idempotency key.
  6. Implement EnforceInactiveRole(): upsert Inactive role (Decision 3 JSONB values — query an existing Member role from staging per the RFC's explicit instruction, don't hardcode), then BulkAssignInactiveRole.
  7. Implement RestoreRoles() per Decision 6: branches on event.Resolved, restores in previous_role_id order by most recent sign-in up to NewRemaining capacity, uses quota_restored_processed:{company_id}:{billing_code} idempotency key.
  8. Add the two IMailer methods, mirroring internal/app/mailer/IMailer.go's existing WorkflowApprovalRequestMail pattern.
  9. Run go test -race ./internal/app/service/downgrade/... ./internal/app/mailer/... until green.
  10. Run go fmt ./... && go vet ./... && make lint.

Acceptance criteria

  • Toggle OFF → returns false, logs downgrade_flow_skipped
  • Toggle ON, trigger_sequence=5BulkAssignInactiveRole called with users ordered oldest-sign-in-first
  • Duplicate trigger_sequence (Redis key hit) → no second email, no second enforcement
  • RestoreRoles() skips users with null previous_role_id, logs a warning, continues others
  • RestoreRoles() filters event.BillingCode per Decision 7 before doing anything else — pending USER_SEAT_BILLING_CODE real value (Decision 7); test uses a placeholder config value

Test strategy

Table-driven tests per method using mockery-generated IStore/IMailer/Redis mocks. Assert exact call arguments (user ID ordering, Redis keys, JSONB shape) rather than just "no error".

Effort estimate

DisciplineDays
Backend3.0
QA1.0
Total4.0

Assumptions: this is the largest single task — 6 new files, the RFC's core domain logic, all decisions (1–7) converge here. Sized as the RFC's own "business logic + DB changes" tier (2–3 days) plus one extra day for the volume of files.

Run to verify

make mocks && go test -race ./internal/app/service/downgrade/... ./internal/app/mailer/... && make lint

Depends on

Tasks 2.1, 2.2, 2.3.


Task 2.6: [BE] Kafka consumer — NegativeBalanceConsumer (DU-S03, DU-S06, DU-S08)

No direct user-facing behavior — this is what turns billing events into the warning/enforcement/restoration flow.

Status: ⚠️ Partially blocked — the BillingCode filter (Decision 7) and Resolved branch (Decision 6) can be written and unit-tested now against a placeholder config value and a hand-built test event; end-to-end verification against real Kafka messages needs P8 to ship the Resolved field and the real USER_SEAT_BILLING_CODE.

Implementation Plan

ActionFileWhat changes
createinternal/app/consumer/negative_balance.goConsumer struct, Process(ctx, msg); filters BillingCode, then branches on Resolved vs trigger_sequence
extendinternal/kafka/topics.goAdd TopicQuotaManagementNegativeBalance = "billing.quota_management.negative_balance" (confirmed absent today)
extendconfig/config.go + config/load.goAdd USER_SEAT_BILLING_CODE config constant (Decision 7)
extendcmd/server/main.go (or consumer runner)Wire the new consumer into the consumer group

Implementation steps

  1. Open internal/app/consumer/update_user_role_change.go (confirmed real file) — the RFC's cited closest existing consumer; note struct shape, message deserialization, error handling.
  2. Write failing tests: event.BillingCode != config → no-op; trigger_sequence 2/3/4 → warning path; trigger_sequence 5 → enforcement path; event.Resolved=true → restore path; duplicate event (Redis idempotency hit) → no-op.
  3. Add TopicQuotaManagementNegativeBalance to internal/kafka/topics.go (verified: does not exist in the repo today, per KafkaTopic constants list).
  4. Add USER_SEAT_BILLING_CODE to config — placeholder/empty value acceptable for now (same non-blocking pattern P9 uses for QUOTA_MANAGEMENT_BILLING_CODE).
  5. Implement Process(): filter → toggle check (via Task 2.5) → branch.
  6. Wire the consumer into the runner alongside existing consumers.
  7. Run go test -race ./internal/app/consumer/... until green; go build ./....

Acceptance criteria

  • BillingCode filter is the first check, before any other branching (Decision 7)
  • trigger_sequence 2/3/4 → warning path; 5 → enforcement path
  • event.Resolved=true → restoration path — end-to-end verification pending P8 (Decision 6)
  • Duplicate Kafka message (Redis idempotency hit) → no-op, commits offset

Test strategy

Table-driven consumer tests constructing NegativeBalanceEvent values directly (not via real Kafka) — this is the standard pattern per update_user_role_change.go's existing test style.

Effort estimate

DisciplineDays
Backend2.0
QA0.5
Total2.5

Assumptions: consumer skeleton follows an existing pattern closely (lower risk than 2.5); most complexity is in branching logic, not infrastructure.

Run to verify

go test -race ./internal/app/consumer/... && go build ./...

Depends on

Task 2.5.


Task 2.7: [BE] DowngradeHandler + routes (DU-S04, DU-S07)

An Owner/Admin/Supervisor's browser gets a real answer from GET /iag/v1/downgrade-status on login, and can acknowledge the final popup via PATCH.

Status: ✅ Actionable now.

Implementation Plan

ActionFileWhat changes
createinternal/app/handler/downgrade_handler.goGET /iag/v1/downgrade-status, PATCH /iag/v1/downgrade-status/acknowledge
extendinternal/server/rest_router.goRegister routes with JWT + role middleware (Owner/Admin/Supervisor only)

Implementation steps

  1. Open internal/app/handler/webhook_handler.go (confirmed real file, SsoUserUpdate at line 76) — handler shape, chi pattern, middleware usage.
  2. Open internal/server/rest_router.go:63-70 (confirmed r.Route("/v1/webhook", ...) pattern) for route-group registration style.
  3. Write failing handler tests: 200 with correct schema when warning active; 403 for Member role; PATCH sets acknowledged=true; 404 semantics N/A (route always exists once deployed — 404 is the FE's pre-deploy concern, not this handler's).
  4. Implement both handlers calling Task 2.5's GetPopupStatus()/service methods.
  5. Register routes in rest_router.go with the existing JWT + role middleware.
  6. Run go test -race ./internal/app/handler/... until green; make build.

Acceptance criteria

  • GET /iag/v1/downgrade-status returns 200 with {warning_active, milestone, at_risk_users, popup_acknowledged} when warning active
  • Member role → 403 on both endpoints
  • PATCH /acknowledge sets downgrade_popup_acknowledged=true, idempotent on repeat call

Test strategy

Handler-level unit tests with mocked IDowngradeService, asserting HTTP status + response body shape per role.

Effort estimate

DisciplineDays
Backend1.0
QA0.5
Total1.5

Assumptions: two simple endpoints on top of already-built service methods (Task 2.5) — this is wiring, not new logic.

Run to verify

go test -race ./internal/app/handler/... && make build

Depends on

Task 2.5.


Task 2.8: [BE] Restoration branch verification + idempotency (DU-S08)

A company that pays down its negative balance gets its restricted users automatically restored, without ops intervention.

Status: 🚫 Blocked for end-to-end verification — P8 (downgrade-webhook) has not shipped the Resolved bool field on NegativeBalanceEvent yet (Decision 6); the code path exists (built in Tasks 2.5/2.6) but cannot be exercised against a real event until P8 ships. Unblocks when: (1) P8 adds Resolved field + publishes from both resolve call sites, (2) USER_SEAT_BILLING_CODE real value confirmed (Decision 7).

Implementation Plan

ActionFileWhat changes
internal/app/service/downgrade/restore.go (from Task 2.5)No new file — this task is staging/integration verification once P8 ships

Implementation steps

  1. Once P8 ships the Resolved field to staging: publish a real test event with Resolved: true against a Bifrost test CID with Inactive-role users.
  2. Verify previous_role_id restoration order (most recent sign-in first, per Decision 6).
  3. Verify idempotency: replay the same event, confirm no double-restore.
  4. Verify partial restoration when new_remaining capacity is less than the number of Inactive users.

Acceptance criteria

  • Real Resolved=true event restores the correct users in the correct order
  • Replaying the same event is a no-op (Redis idempotency key holds)
  • Partial-capacity restoration respects new_remaining

Test strategy

Integration/staging verification, not a new unit test file — unit coverage for this logic already lives in Task 2.5's restore.go tests.

Effort estimate

DisciplineDays
Backend1.0
QA0.5
Total1.5

Assumptions: pure verification effort once P8 ships — no new code, but nontrivial staging setup (seeding Inactive users, triggering a real restoration event).

Run to verify

# staging-only, once P8's Resolved field is live

Depends on

Task 2.5, Task 2.6, and externally: P8 shipping Decision 6 + USER_SEAT_BILLING_CODE value (Decision 7).


Task 2.9: [FE] Wire real GET /iag/v1/downgrade-status (DU-S04)

The warning popup now reflects real backend state instead of a mock.

Status: ✅ Actionable once Task 2.7 ships an endpoint to point at; the FE-side work itself has no blocker.

Implementation Plan

ActionFileWhat changes
extendapp/common/store/downgradeWarningStore.tsReplace mocked fetchDowngradeStatus() with a real HTTP call; add 404/401/403/5xx handling per Detail 2.G/3.B
extendapp/common/store/__tests__/downgradeWarningStore.spec.tsReplace mock-shape assertions with real HTTP mock (401/403/404/5xx/200 cases)

Implementation steps

  1. Open app/features/settings/company/composables/useCompanySettings.ts — the RFC's cited manual snake_case→camelCase mapping pattern (no global Axios interceptor exists in this repo, confirmed).
  2. Update tests first: 404 → warningActive=false (suppress silently); 401 → defer to existing auth middleware redirect; 403 → suppress silently; 5xx → suppress silently + log.
  3. Replace the mocked call with the real GET /iag/v1/downgrade-status request.
  4. Run pnpm test -- downgradeWarningStore.spec.ts until green.
  5. Run pnpm lint && pnpm type-check.

Acceptance criteria

  • 404 treated as warningActive: false (BE-not-yet-deployed tolerance, per §4 Rollout)
  • 401/403/5xx handled per Detail 3.B's error catalog
  • 200 response correctly populates store state

Test strategy

Mock the HTTP client per status code (200/401/403/404/5xx); assert store state and no unhandled exceptions.

Effort estimate

DisciplineDays
Frontend1.0
QA0.5
Total1.5

Assumptions: integration wiring only — store shape and modal already built in Task 1.1.

Run to verify

pnpm test -- downgradeWarningStore.spec.ts && pnpm lint

Depends on

Task 1.1, Task 2.7.


Task 2.10: [FE] Wire real PATCH /iag/v1/downgrade-status/acknowledge (DU-S07)

Acknowledging the final popup now persists to the backend instead of a mock.

Status: ✅ Actionable once Task 2.7 ships the endpoint.

Implementation Plan

ActionFileWhat changes
extendapp/common/store/downgradeWarningStore.tsReplace mocked acknowledgePopup() with a real PATCH call
extendapp/layouts/components/__tests__/DowngradeRestrictedModal.spec.tsReal HTTP mock assertions (200/401/403/5xx)

Implementation steps

  1. Reuse the HTTP client setup from Task 2.9.
  2. Update tests: 200 → popupAcknowledged=true; 401 → redirect (matches GET's behavior, per today's Detail 3.B fix); 403 → suppress silently (shouldn't be called for Member anyway); 5xx → keep modal open, allow retry (Detail 2.H).
  3. Replace the mocked call with the real PATCH request.
  4. Run pnpm test -- DowngradeRestrictedModal.spec.ts until green; pnpm lint && pnpm type-check.

Acceptance criteria

  • 200 → popupAcknowledged=true, modal closes
  • 5xx/network failure → modal stays open, button re-enables (Detail 2.H)
  • 401 → redirect via auth middleware, not a retry prompt

Test strategy

Mock the PATCH call per status code; assert store state and modal visibility react correctly.

Effort estimate

DisciplineDays
Frontend0.5
QA0.5
Total1.0

Assumptions: smallest integration task — single field, no ordering/pagination concerns.

Run to verify

pnpm test -- DowngradeRestrictedModal.spec.ts && pnpm lint

Depends on

Task 1.2, Task 2.7.


Ordering rationale

  • Backend is the critical path. Tasks 2.1→2.2/2.3→2.5→2.6/2.7 form a strict dependency chain (schema → queries → service → consumer/handler); FE Phase 2 wiring (2.9, 2.10) can't start until 2.7 ships an endpoint to call.
  • Phase 1 FE work is parallelizable with all of Phase 2 BE work — Tasks 1.1/1.2 only need the store shape and MpModal pattern, not a real backend, so an FE dev can start on day 1 alongside the BE dev starting 2.1.
  • Push externally on two things now, not later: P8 shipping the Resolved field (blocks Task 2.8) and the billing team confirming USER_SEAT_BILLING_CODE (blocks 2.8's real verification and technically weakens 2.5/2.6's test realism until then) — both are config/data changes on someone else's side, not code this team can accelerate by itself.
  • Figma is the other external blocker, gating only the final visual pass on Tasks 1.1/1.2 — everything else in those tasks is unblocked today.
  • 2.4 (roles API filter) has no hard dependency chain beyond 2.1 and can slot in anywhere convenient — good filler task if someone's blocked elsewhere.

Skipped stories

No stories were fully excluded — "include everything" was selected, so all 8 PRD stories (DU-S01 through DU-S08) and both FE popups appear above, either as actionable/partially-blocked tasks (with their exact unblocking condition stated) or, for Task 2.8, explicitly marked 🚫 Blocked with the external dependency named.