For agentic workers: REQUIRED SUB-SKILL: Use
superpowers:subagent-driven-development(recommended) orsuperpowers:executing-plansto implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Linear issue: GAR-393 — "Rotas POST/GET/PATCH /v1/groups com OpenAPI" (In Progress, High). Slice 4 de GAR-393 — fecha a descrição canônica. Slice 3 (accept invite) entregue em plan 0019 (PR #25, d590313).
Status: Draft — pendente aprovação.
Scope split note: este plan é a fatia 0020-a do split combinado com o usuário após o review do PR #25. A fatia 0020-b (plan 0021 quando escrito — SEC-01 rate-limit do accept + SEC-04 audit_events do accept) é ortogonal e fica fora deste documento.
Goal: fechar o contrato de membership management: Owner/Admin pode mudar o papel de um membro existente (excluindo promoção para owner) e remover um membro do grupo via soft-delete (status = 'removed'). Também permite que qualquer membro se auto-remova (leave group), exceto quando fizer isso o deixaria o grupo sem nenhum owner.
-
Dois novos handlers em
crates/garraia-gateway/src/rest_v1/groups.rs(não um módulo separadomembers.rs— path é/v1/groups/{id}/members/*, manter coesão):set_member_role→POST /v1/groups/{id}/members/{user_id}/setRoledelete_member→DELETE /v1/groups/{id}/members/{user_id}
-
Path shape (convention decision):
/members/{user_id}/setRole(dois segmentos para o verbosetRole). Mesma limitação do Axum 0.8 /matchitque forçou/acceptem vez de:acceptno plan 0019. Futuras custom actions seguem o mesmo ajuste.Explicit divergence from Linear canonical description:
Canônico (GAR-393 description) Entregue por este plan POST .../{user_id}:setRolePOST /v1/groups/{id}/members/{user_id}/setRoleDELETE .../{user_id}DELETE /v1/groups/{id}/members/{user_id}(inalterado)Decisão ratificada em 2026-04-20 antes da execução do slice 4. Registrada em (1) este plan, (2) comentário de abertura do slice em GAR-393, (3) body do PR de código. A descrição canônica da issue não será editada — o custom-verb
:actioncontinua a notação de especificação; o/actioncontinua a entrega real. -
Authz em duas camadas:
- Camada 1 (capability):
can(&principal, Action::MembersManage)— filtra Owner+Admin. Member/Guest/Child → 403. - Camada 2 (hierarchy, dentro do handler): Admin não pode modificar Owner nem outros Admins. Owner pode modificar qualquer papel (respeitando last-owner invariant).
- Self-action bypass: se
target_user_id == principal.user_id, pula a camada 1 (permite Member/Guest/Child se auto-removerem via DELETE, e permite Admin/Owner se auto-demoterem respeitando a regra do último owner).
- Camada 1 (capability):
-
Last-owner invariant: toda UPDATE/DELETE que possa reduzir o número de owners ativos para zero retorna 409 Conflict. Verificado via
SELECT COUNT(*) FILTER (WHERE role = 'owner' AND status = 'active')dentro da mesma transação. -
Promote-to-owner rejeição:
set_member_rolecomrole = 'owner'retorna 400 Bad Request. Ownership transfer é operação distinta (fora do escopo do v1; mapeia para um futuro endpoint dedicado). -
DELETE é soft:
UPDATE group_members SET status = 'removed' WHERE .... A linha permanece para preservar FKs (messages.author_id,tasks.created_by, etc.). Re-invite do mesmo user no mesmo grupo retornará 409 via colisão PK — limitação v1 documentada (plano 0022+ tratará reativação). -
Self-remove leave-group: qualquer papel pode DELETE-se, sujeito ao last-owner invariant. Um Owner que tenta self-leave sem outro Owner no grupo → 409.
Axum 0.8, utoipa 5, garraia-auth::{Principal, Action, can}, sqlx 0.8, testcontainers + harness (inalterado desde plan 0016 M2).
- Capability gate:
MembersManagerequerido exceto em self-action (ver #2). - Self-action bypass na camada de capability: quando
target_user_id == principal.user_id, ocan(&principal, MembersManage)check é pulado. Mantém self-leave acessível a Member/Guest/Child. - Hierarchy gate (não-self): Admin não pode modificar Owner nem outros Admins. Tentativa → 403 Forbidden com detail PII-safe
"admin cannot modify owners or other admins". Owner pode modificar qualquer papel. - Last-owner invariant:
setRoleque demota o único Owner OUDELETEdo único Owner → 409 Conflict com detail"cannot leave the group without an owner". Verificado porCOUNT(*)dentro da tx. - No-promote-to-owner:
setRolecomrole = 'owner'→ 400 Bad Request com detail"cannot promote to owner via setRole". Reforçado defensivamente pelogroup_members_single_owner_idx(migration 002) que rejeitaria o UPDATE com 23505 — mas handler filtra antes para mensagem de erro clara. - Allowed setRole values:
{'admin', 'member', 'guest', 'child'}.ownerrejeitado (invariant #5). Mesmo subset queALLOWED_INVITE_ROLESdo plan 0018 — reutilizar a constante se possível, renomear se o domínio ficar ambíguo. - Target-must-exist-and-be-active:
setRoleeDELETEoperam apenas em linhas comstatus = 'active'. Target comstatus IN ('invited','removed','banned')ou não-existente → 404. - X-Group-Id header exigido: mesmo padrão de PATCH/invites.
Principalextractor 403 não-membros; handler 400 em mismatch header/path. - Transação atômica: SET LOCAL tenant context + SELECT (hierarchy/last-owner guards) + UPDATE (setRole ou soft-delete). Tudo em uma tx para que as invariants sejam verificadas contra um snapshot consistente.
- Idempotência do DELETE:
DELETEde um member já comstatus = 'removed'retorna 404 (não foi encontrado como ativo). Cliente deve tratar 404 como "já removido ou nunca existiu".
- ✅
group_members.statusCHECK inclui'removed'(migration 001 line 123:CHECK (status IN ('active', 'invited', 'removed', 'banned'))). - ✅
group_members_single_owner_idx(migration 002 line 146) — partial unique indexWHERE role = 'owner'. Bloqueia promote-to-owner a nível DB com 23505. Handler filtra antes para 400 com mensagem específica. - ✅
Action::MembersManageexiste emgarraia-auth(action.rs line 36, can.rs line 55) e está mapeado para Owner+Admin. - ✅
Principalextractor +X-Group-Idheader pattern já usado por PATCH (0017) e invites (0018) — consistência garantida. - ⏳ A ser validado durante T1:
garraia_approle tem GRANT UPDATE emgroup_members? (Migration 007 / 010 grant verificar.) - ⏳ A ser validado durante T1:
Principalextractor filtragroup_members.status = 'active'no lookup de membership? (Se não, um member comstatus = 'removed'passaria como membro ativo — precisa corrigir no extractor, não no handler.)
Se qualquer uma das validações pendentes (⏳) falhar, T1 reporta e ajusta o plano antes de avançar.
| Condition | Status | Guard |
|---|---|---|
| No JWT | 401 | Principal extractor |
| Non-member of group | 403 | Principal extractor |
X-Group-Id missing/mismatch |
400 | handler |
Body invalid (unknown role, role = 'owner') |
400 | validate() |
Caller not self AND lacks MembersManage |
403 | can() |
| Caller is Admin trying to modify Owner/Admin (non-self) | 403 | handler (hierarchy) |
| Target not found or not active | 404 | UPDATE RETURNING empty |
| Would demote last Owner | 409 | handler (last-owner invariant) |
| Happy path | 200 |
| Condition | Status | Guard |
|---|---|---|
| No JWT | 401 | Principal extractor |
| Non-member of group | 403 | Principal extractor |
X-Group-Id missing/mismatch |
400 | handler |
Caller not self AND lacks MembersManage |
403 | can() |
| Caller is Admin trying to delete Owner/Admin (non-self) | 403 | handler (hierarchy) |
| Target not found or not active (already removed) | 404 | UPDATE RETURNING empty |
| Would leave group ownerless | 409 | handler (last-owner invariant) |
| Happy path | 204 | (No Content — no body) |
A descrição canônica de GAR-393 lista 6 endpoints. Entregues até aqui:
- ✅
POST /v1/groups(plan 0016 M4) - ✅
GET /v1/groups/{id}(plan 0016 M4) - ✅
PATCH /v1/groups/{id}(plan 0017) - ✅
POST /v1/groups/{id}/invites(plan 0018) - ✅
POST /v1/invites/{token}/accept(plan 0019 — slice adicional)
Este plano (slice 4) entrega os 2 restantes:
- 🎯
POST /v1/groups/{id}/members/{user_id}/setRole - 🎯
DELETE /v1/groups/{id}/members/{user_id}
Com plan 0020 merged, GAR-393 estará 6/6 conforme a descrição canônica e pode ser fechado no Linear. Os endpoints entregues por plan 0019 (accept) ficam como extensão natural documentada no comentário de scope-adjustment de GAR-393.
- Ownership transfer (promover alguém para
owner). Mapeia para um endpoint separado tipoPOST /v1/groups/{id}/transfer-ownershipcom semântica atômica de demote-old-owner + promote-new-owner em uma tx. Plan futuro. - Hard delete + reativação — rows soft-deleted permanecem. Um user soft-deleted que receber novo invite + accept vai hitar 23505 (PK collision) porque
(group_id, user_id)já existe na tabela. Retornará 409 "already a member" (mensagem tecnicamente ambígua mas comportamento correto — caller não consegue re-entrar sem intervenção admin). Reativação explícita via UPDATEstatus='active'OU endpoint dedicado é trabalho de plan 0022+. - Audit trail do setRole/delete member. SEC-04 já cobre o accept; um follow-up análogo para setRole/delete é recomendado mas fica para plan 0021 (0020-b) ou além.
- Soft-delete cascading para referências em
messages,tasks,memory_items. Deliberadamente NÃO cascade — soft-delete preserva histórico. - Rate-limit dedicado em setRole/delete. Governor global cobre.
- Bulk operations (setRole de múltiplos users em uma chamada). v2.
| File | Action | Responsibility |
|---|---|---|
crates/garraia-gateway/src/rest_v1/groups.rs |
Modify | Add SetRoleRequest struct, set_member_role handler, delete_member handler, shared check_member_hierarchy + assert_last_owner_preserved helpers, ALLOWED_SETROLE_VALUES constant |
crates/garraia-gateway/src/rest_v1/mod.rs |
Modify | Wire /v1/groups/{id}/members/{user_id}/setRole (POST) + /v1/groups/{id}/members/{user_id} (DELETE) in all 3 modes |
crates/garraia-gateway/src/rest_v1/openapi.rs |
Modify | Register set_member_role + delete_member paths, add SetRoleRequest schema |
crates/garraia-gateway/tests/rest_v1_groups.rs |
Modify | Add M1–M8 (setRole) + D1–D6 (DELETE) scenarios |
crates/garraia-gateway/tests/authz_http_matrix.rs |
Modify | Extend matrix with cases 27..N for setRole + DELETE |
plans/README.md |
Modify | Mark 0020 entry Draft → In Execution → Merged over lifecycle |
No migrations. No crate-level additions. No new dependencies.
Files:
- Modify:
crates/garraia-gateway/src/rest_v1/groups.rs
Gate (must run before any code):
- Step 0a: Verify
garraia_apphas UPDATE ongroup_members.
-- Run via admin_pool in the harness or via migration grep.
SELECT has_table_privilege('garraia_app', 'group_members', 'UPDATE');Expected: true. If false, add a migration 012 granting UPDATE before continuing — but from migration 007 + 010 the grant is expected to already exist.
- Step 0b: Verify
Principalextractor filtersgroup_members.status = 'active'.
Read crates/garraia-auth/src/extractor.rs (or wherever the membership lookup lives). The SELECT MUST filter status = 'active'. If it doesn't, a user with status = 'removed' would still be treated as a member — a security bug that this plan would inadvertently ship. If missing, fix it in the extractor BEFORE proceeding with the plan.
- Step 1: Write failing unit tests for
SetRoleRequest.
In groups.rs mod tests:
#[test]
fn set_role_request_rejects_owner() {
let req: SetRoleRequest = serde_json::from_str(r#"{"role":"owner"}"#).unwrap();
assert_eq!(
req.validate().unwrap_err(),
"cannot promote to owner via setRole"
);
}
#[test]
fn set_role_request_rejects_unknown_role() {
let req: SetRoleRequest = serde_json::from_str(r#"{"role":"superadmin"}"#).unwrap();
assert!(req.validate().is_err());
}
#[test]
fn set_role_request_accepts_admin_member_guest_child() {
for role in &["admin", "member", "guest", "child"] {
let req = SetRoleRequest { role: role.to_string() };
assert!(req.validate().is_ok(), "role {role} should be valid");
}
}
#[test]
fn set_role_request_rejects_unknown_field() {
let err = serde_json::from_str::<SetRoleRequest>(r#"{"rle":"admin"}"#);
assert!(err.is_err(), "deny_unknown_fields should reject typo");
}Run: cargo test -p garraia-gateway --lib -- rest_v1::groups::tests::set_role_request. Expect FAIL (struct missing).
- Step 2: Add
SetRoleRequest+ALLOWED_SETROLE_VALUES.
/// Accepted values for `SetRoleRequest::role`. Excludes `'owner'` —
/// ownership transfer is a separate operation (plan 0022+, not this plan).
/// Also excludes `'personal'` for the same reason `groups.type` excludes it.
const ALLOWED_SETROLE_VALUES: &[&str] = &["admin", "member", "guest", "child"];
#[derive(Debug, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct SetRoleRequest {
/// New role. Must be one of: `admin`, `member`, `guest`, `child`.
/// `owner` is rejected — ownership transfer is a separate operation.
pub role: String,
}
impl SetRoleRequest {
pub fn validate(&self) -> Result<(), &'static str> {
if self.role == "owner" {
return Err("cannot promote to owner via setRole");
}
if !ALLOWED_SETROLE_VALUES.contains(&self.role.as_str()) {
return Err("role must be one of: admin, member, guest, child");
}
Ok(())
}
}-
Step 3: Run tests, expect pass.
-
Step 4: Commit.
git add crates/garraia-gateway/src/rest_v1/groups.rs
git commit -m "feat(gateway): SetRoleRequest struct + validation (plan 0020 t1)"Files:
-
Modify:
crates/garraia-gateway/src/rest_v1/groups.rs -
Step 1: Write the handler.
High-level sequence (details in the code comments):
pub async fn set_member_role(
State(state): State<RestV1FullState>,
principal: Principal,
Path((id, target_user_id)): Path<(Uuid, Uuid)>,
Json(body): Json<SetRoleRequest>,
) -> Result<Json<MemberResponse>, RestError> {
// 1. Header/path coherence.
// 2. Structural body validation (no DB).
// 3. Authz camada 1: self-action bypass OR MembersManage.
// 4. Open tx, SET LOCAL tenant context.
// 5. Fetch target's current role (FOR UPDATE to serialize concurrent setRole).
// -> NOT FOUND if missing or status != 'active'.
// 6. Hierarchy check (camada 2): Admin caller cannot modify Owner/Admin (non-self).
// 7. UPDATE group_members SET role = $new_role WHERE group_id=$1 AND user_id=$2 AND status='active'.
// 8. Last-owner invariant check (POST-UPDATE): COUNT owners >= 1. If not, abort tx (return 409).
// NOTE: uncommitted tx sees its own writes, so COUNT reflects post-UPDATE state.
// 9. Commit tx.
// 10. Return 200 with MemberResponse { group_id, user_id, role, status, updated_at }.
}Key notes:
-
FOR UPDATE in step 5 (
SELECT ... FOR UPDATE) acquires a row lock on the target member. Prevents two concurrent setRole calls from racing: the second one blocks until the first commits, then sees the new role and can re-evaluate its own hierarchy check. -
Last-owner check in step 8 happens INSIDE the tx. If the UPDATE demoted the last owner,
COUNT(*) FILTER (WHERE role='owner' AND status='active') = 0. Handler returnsRestError::Conflict→ tx drops without commit → UPDATE is rolled back. Invariant preserved. -
Self-demote special case (caller == target, role was owner, new role isn't owner): falls through the hierarchy check (self-bypass) but hits the last-owner check. If caller is the sole owner, 409. If there's another owner, proceeds.
-
Step 2: Add
MemberResponseToSchema struct (shared with delete_member for the 200 body in setRole; DELETE returns 204 and has no body).
#[derive(Debug, Serialize, ToSchema)]
pub struct MemberResponse {
pub group_id: Uuid,
pub user_id: Uuid,
pub role: String,
pub status: String,
pub updated_at: DateTime<Utc>,
}- Step 3: Verify compilation.
cargo check -p garraia-gateway. Expected: fails at route wiring (next task), OR compiles clean if route wiring is deferred. Either way acceptable — the handler must typecheck.
- Step 4: Commit.
git add crates/garraia-gateway/src/rest_v1/groups.rs
git commit -m "feat(gateway): set_member_role handler with hierarchy + last-owner guards (plan 0020 t2)"Files:
-
Modify:
crates/garraia-gateway/src/rest_v1/groups.rs -
Step 1: Write the handler.
pub async fn delete_member(
State(state): State<RestV1FullState>,
principal: Principal,
Path((id, target_user_id)): Path<(Uuid, Uuid)>,
) -> Result<StatusCode, RestError> {
// 1. Header/path coherence.
// 2. Authz camada 1: self-action bypass OR MembersManage.
// 3. Open tx, SET LOCAL tenant context.
// 4. Fetch target's role (FOR UPDATE).
// -> NOT FOUND if missing or status != 'active' (soft-deleted is idempotent 404).
// 5. Hierarchy check (camada 2): Admin cannot delete Owner/Admin (non-self).
// 6. UPDATE group_members SET status='removed' WHERE group_id=$1 AND user_id=$2 AND status='active'.
// 7. Last-owner invariant: if the removed role was 'owner', COUNT owners FILTER status='active' >= 1; else abort tx.
// 8. Commit tx.
// 9. Return 204 No Content.
}Notes:
-
Idempotent semantics: DELETE on an already-removed member returns 404 (not 204). Some REST styles prefer 204 for idempotence on DELETE, but here 404 is cleaner because the caller can distinguish "I performed the removal" (204) from "already gone or never existed" (404). Plan 0019 followed the same convention (accept-twice → 404 via filtered SELECT).
-
Soft-delete only:
UPDATE status='removed'preserves the row. Existing FKs inmessages,tasks, etc. continue to resolve. -
Optimization opportunity (deferred): could skip the last-owner check when the removed role is NOT
'owner'. Current plan runs the COUNT unconditionally for simplicity — cost is ~µs on any realistic group size. -
Step 2: Verify compilation.
cargo check -p garraia-gateway.
- Step 3: Commit.
git add crates/garraia-gateway/src/rest_v1/groups.rs
git commit -m "feat(gateway): delete_member handler (soft-delete + last-owner guard) (plan 0020 t3)"Files:
-
Modify:
crates/garraia-gateway/src/rest_v1/mod.rs -
Modify:
crates/garraia-gateway/src/rest_v1/openapi.rs -
Step 1: Wire routes in all 3 modes.
Mode 1 (full state):
.route(
"/v1/groups/{id}/members/{user_id}/setRole",
post(groups::set_member_role),
)
.route(
"/v1/groups/{id}/members/{user_id}",
delete(groups::delete_member),
)Modes 2 and 3: mirror with unconfigured_handler.
- Step 2: Register in OpenAPI.
In openapi.rs:
-
Add to
paths(...):super::groups::set_member_role,super::groups::delete_member. -
Add to
components(schemas(...)):SetRoleRequest,MemberResponse. -
Update the
use super::groups::{...}import block. -
Step 3: Verify compilation + lib tests.
cargo check -p garraia-gateway && cargo test -p garraia-gateway --lib. Expected: compiles, lib tests still pass (unit tests from T1 already pass).
- Step 4: Commit.
git add crates/garraia-gateway/src/rest_v1/mod.rs crates/garraia-gateway/src/rest_v1/openapi.rs
git commit -m "feat(gateway): wire setRole + DELETE member routes + OpenAPI (plan 0020 t4)"Files:
- Modify:
crates/garraia-gateway/tests/rest_v1_groups.rs
Scenarios (all bundled into the existing v1_groups_scenarios test function or a new sibling — decision: new sibling v1_groups_member_scenarios because v1_groups_scenarios is already dense):
| # | Name | Expected |
|---|---|---|
| M1 | Owner setRole member→admin (happy) | 200, MemberResponse.role == "admin" |
| M2 | Admin setRole member→guest | 200 |
| M3 | Admin tries setRole of Owner | 403 "admin cannot modify owners or other admins" |
| M4 | Admin tries setRole of another Admin (non-self) | 403 |
| M5 | Owner self-demote to admin with another owner existing | 200 (requires seeding a 2nd owner via admin_pool) |
| M6 | Owner self-demote to admin without another owner | 409 "cannot leave the group without an owner" |
| M7 | Setrole body role="owner" |
400 "cannot promote to owner via setRole" |
| M8 | Member tries setRole of another member (non-self, lacks MembersManage) |
403 |
| M9 | Target user_id not a member | 404 |
| M10 | Missing bearer | 401 |
Note: M5 needs a fixture helper seed_second_owner_via_admin(&h, group_id, email) because the product API does not expose a way to create two owners (by design). Can be added in common/fixtures.rs as a test-only utility.
-
Step 1: Write all 10 scenarios bundled into one
#[tokio::test](same pattern as plan 0018/0019 — avoids sqlx teardown race). -
Step 2: Run
cargo test -p garraia-gateway --features test-helpers --test rest_v1_groups. Expected: all M scenarios + all pre-existing 1..7 scenarios + P1..P6 + I1..I6 pass. Total tests in file: ~30.
- Step 3: Commit.
git add crates/garraia-gateway/tests/rest_v1_groups.rs crates/garraia-gateway/tests/common/fixtures.rs
git commit -m "test(gateway): setRole integration scenarios M1-M10 + seed_second_owner fixture (plan 0020 t5)"Files:
- Modify:
crates/garraia-gateway/tests/rest_v1_groups.rs
| # | Name | Expected |
|---|---|---|
| D1 | Owner DELETE member (happy) | 204, admin_pool assertion: group_members.status = 'removed' |
| D2 | Admin DELETE guest | 204 |
| D3 | Admin tries DELETE of Owner | 403 |
| D4 | Owner self-delete without another owner | 409 |
| D5 | Owner self-delete with another owner seeded | 204 (owner leaves; co-owner remains) |
| D6 | Member self-delete (leave group) | 204 |
| D7 | DELETE of already-removed member | 404 |
| D8 | DELETE target not a member | 404 |
| D9 | Missing bearer | 401 |
-
Step 1: Add scenarios to the same bundled test.
-
Step 2: Run + verify.
-
Step 3: Commit.
git add crates/garraia-gateway/tests/rest_v1_groups.rs
git commit -m "test(gateway): DELETE member integration scenarios D1-D9 (plan 0020 t6)"Files:
- Modify:
crates/garraia-gateway/tests/authz_http_matrix.rs
Current matrix: 26 cases. Add:
| # | Name | Expected |
|---|---|---|
| 27 | POST setRole as alice (owner) on bob (non-member of alice_group) | 404 |
| 28 | POST setRole as bob (non-member of alice_group) on alice | 403 |
| 29 | POST setRole as eve (no group) on alice | 403 |
| 30 | POST setRole no bearer | 401 |
| 31 | DELETE member as alice on bob (non-member of alice_group) | 404 |
| 32 | DELETE member as bob (non-member) on alice | 403 |
| 33 | DELETE member as eve on alice | 403 |
| 34 | DELETE member no bearer | 401 |
Update assert_eq!(matrix.len(), 34, ...).
- Step 1: Append 8 cases, update len assertion.
- Step 2:
cargo test --features test-helpers --test authz_http_matrix. - Step 3: Commit.
git add crates/garraia-gateway/tests/authz_http_matrix.rs
git commit -m "test(gateway): expand authz matrix with setRole + DELETE cases 27-34 (plan 0020 t7)"Files: none — validation only.
- Step 1:
cargo fmt --check --all. Fix any diffs. - Step 2:
cargo clippy -p garraia-gateway --no-deps --features test-helpers --tests -- -D warnings. Must not introduce new warnings ingroups.rs. Pre-existing warnings inbootstrap.rsstay tolerated via CIcontinue-on-error. - Step 3: Full test matrix:
cargo test -p garraia-gateway --lib
cargo test -p garraia-gateway --features test-helpers --test rest_v1_groups
cargo test -p garraia-gateway --features test-helpers --test rest_v1_invites
cargo test -p garraia-gateway --features test-helpers --test authz_http_matrix
cargo test -p garraia-gateway --features test-helpers --test rest_v1_me_authed
cargo test -p garraia-gateway --features test-helpers --test rest_v1_me
cargo test -p garraia-gateway --features test-helpers --test harness_smoke
cargo test -p garraia-gateway --features test-helpers --test router_smoke_testEvery binary must pass.
- Step 4: Commit any cleanups.
git add -u
git commit -m "style(gateway): validation pass fixes (plan 0020 t8)"POST /v1/groups/{id}/members/{user_id}/setRolereturns 200 withMemberResponseon happy path.setRolerejectsrole = "owner"with 400 Bad Request and detail"cannot promote to owner via setRole".setRolethat would demote the last owner returns 409 Conflict with detail"cannot leave the group without an owner"and DB state unchanged.- Admin cannot
setRoleorDELETEan Owner or another Admin (non-self) — 403 Forbidden. DELETE /v1/groups/{id}/members/{user_id}returns 204 No Content andUPDATE group_members SET status='removed'succeeded.DELETEof the last owner returns 409 with DB unchanged; DELETE of an already-removed member returns 404.- Member/Guest/Child can self-remove (DELETE self) with 204 — last-owner rule still applies if they happened to be the sole owner.
Principalextractor membership check filtersstatus='active'— a user withstatus='removed'gets 403 on subsequent requests.- All authz matrix cases 1..34 pass.
- CI goes 9/9 green.
cargo fmt --check --allclean.- OpenAPI spec at
/docsshows both new endpoints.
All changes additive:
- 2 new handlers (
set_member_role,delete_member) - 1 new request struct (
SetRoleRequest) - 1 new response struct (
MemberResponse) - 1 new fixture helper (
seed_second_owner_via_admin) - route wiring in 3 modes (3 new route calls each for setRole and DELETE)
- test scenarios (additive)
Rollback via revert: reverting the squash commit returns the codebase to plan 0019's state. No migrations, no schema changes, no state dependencies outside git.
Partial rollback: if only one of setRole/DELETE proves problematic post-merge, a follow-up commit can remove the wiring of the affected route (keeping the handler in code but inaccessible) while a proper fix is authored.
- OQ-1: Does
Principalextractor already filterstatus = 'active'? (Gate 2, T1 Step 0b.) If no, we fix it in the extractor, not in this plan — that's an auth-crate concern. - OQ-2: Does
garraia_apphave UPDATE grant ongroup_members? (Gate 2, T1 Step 0a.) - OQ-3: Should DELETE of last owner be 409 or 422? Chosen 409 (Conflict) because it matches the semantic "you conflict with the invariant" — same reasoning used for double-accept race in plan 0019.
- OQ-4: Self-remove: should it require a confirmation header (e.g.
X-Leave-Confirm: true)? No for v1 — keeps the API simple. Add if UX feedback suggests accidental self-leaves are a problem.
- Plan 0018 (create invite) — produces the invites that plan 0019 accepts.
- Plan 0019 (accept invite) — produces the
group_membersrows that this plan modifies/soft-deletes. - Plan 0021 (TBD, "0020-b") — absorb SEC-01 (rate-limit accept) + SEC-04 (audit_events accept). Ortogonal.
- Plan 0022+ — ownership transfer endpoint, member reactivation (hard-revive from
status='removed'), audit trail for membership changes.
Com plan 0020 merged, GAR-393 estará 6/6 conforme a descrição canônica (+ 1 slice adicional accept-invite) e pode ser fechado no Linear.