Status: Em execução
Autor: Claude Opus 4.7 (garra-routine 2026-05-07, America/New_York)
Data: 2026-05-07 (America/New_York)
Issue: GAR-539
Branch: routine/202605071835-task-subscriptions-api
Epic: epic:ws-api, epic:ws-tasks
Parent: GAR-396
Land the task subscriptions REST API (ROADMAP §3.8 Tier 1), delivering three
endpoints on the garraia_app RLS-enforced pool — all keyed to the current
authenticated user (no user_id body parameter):
POST /v1/groups/{group_id}/tasks/{task_id}/subscriptions— current user subscribes to the task (201 on success; 409 if already subscribed; 404 if task not in this group).DELETE /v1/groups/{group_id}/tasks/{task_id}/subscriptions— current user unsubscribes (204 idempotent; 404 if task not in this group).GET /v1/groups/{group_id}/tasks/{task_id}/subscriptions— list subscribers for a task (200 with array; 404 if task not in this group).
CREATE TABLE task_subscriptions (
task_id uuid NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
subscribed_at timestamptz NOT NULL DEFAULT now(),
muted boolean NOT NULL DEFAULT false,
PRIMARY KEY (task_id, user_id)
);task_subscriptions — JOIN via tasks (FORCE RLS, same class as
task_assignees, task_label_assignments):
CREATE POLICY task_subscriptions_through_tasks ON task_subscriptions
USING (task_id IN (SELECT id FROM tasks));Because tasks is filtered by app.current_group_id, this transitively scopes
subscriptions to the current group.
Unlike task_assignees (where any group member can be assigned by any other
member) and task_labels (where the actor passes the label_id), subscription
operations always act on the current user. POST and DELETE take no body, no
user_id parameter. This keeps the wire surface minimal and avoids the
question of "can Alice subscribe Bob to a task?".
- POST and DELETE first verify
task_idexists in the current group and is not soft-deleted. Cross-grouptask_idreturns 404 (RLS would also filter, but the explicit check yields clear UX). - GET requires the same check.
- 23505 unique violation on
(task_id, user_id)PK on POST → 409. - DELETE is idempotent (no 404 on missing row, only 404 on missing task).
Default false. Exposed in GET response. Mutation path (PATCH to flip muted)
is out of scope for this slice — it's simple and can ship in a follow-up
if the digest worker (GAR-397) needs it.
- Axum 0.8 +
RestV1FullState(same as tasks.rs) sqlx::query/sqlx::query_as— no SQL string concatgarraia_auth::{Action, Principal, WorkspaceAuditAction, audit_workspace_event}- 2 new
WorkspaceAuditActionvariants:TaskSubscribed,TaskUnsubscribed utoipaOpenAPI annotations
- SET LOCAL both
app.current_user_idANDapp.current_group_idin every tx. - Audit metadata structural-only:
{ subscriber_user_id_len: 36 }— never the user's email or display name. - Cross-group
task_idreturns 404 (explicit check + RLS). - DELETE is idempotent on the subscription row (204 either way), but still returns 404 if the task itself is missing from this group.
- POST 409 on SQLSTATE
23505for the PK(task_id, user_id). - Subject is always
principal.user_id— nouser_idrequest body. muteddefaults tofalseon insert (DB default); response exposes it verbatim from the row.- No
unwrap()outside tests. - GET response sorted by
subscribed_at ASCfor stable pagination.
-
task_subscriptionsschema in migration 006 (PK + ON DELETE CASCADE). -
task_subscriptions_through_tasksFORCE RLS JOIN policy. -
Action::TasksWrite/Action::TasksReadalready inaction.rs. -
set_rls_contextparameterized SET LOCAL pattern (plan 0056). -
audit_workspace_eventsignature (plans 0077, 0078). - 23505 unique violation catch pattern (plan 0078).
- No body for parameterless POST — Axum 0.8 accepts empty body when no
Json<T>extractor present.
In scope:
POST /v1/groups/{group_id}/tasks/{task_id}/subscriptionsDELETE /v1/groups/{group_id}/tasks/{task_id}/subscriptionsGET /v1/groups/{group_id}/tasks/{task_id}/subscriptionsWorkspaceAuditAction::TaskSubscribed+WorkspaceAuditAction::TaskUnsubscribed- 7 integration test scenarios (S1–S7)
- OpenAPI registration for the 3 endpoints + the new
SubscriptionResponse
Out of scope:
PATCH /v1/groups/{group_id}/tasks/{task_id}/subscriptions/me/mute(togglemuted) — separate slice, ships when digest worker (GAR-397) lands.- Subscribing other users on someone else's behalf — never, by design.
- Bulk subscribe (subscribe-all-task-list) — separate slice.
- Notification fan-out to channels — owned by GAR-397 digest worker.
- WebSocket push of subscription events — GAR-396 parent scope.
No schema changes. If the implementation is defective, revert the 3 route
registrations in rest_v1/mod.rs, the 3 handler functions in tasks.rs, the
2 audit variants in audit_workspace.rs, and the OpenAPI additions in
openapi.rs. No migration rollback needed.
None. All design decisions are direct echoes of plan 0078 (labels) and plan 0077 (assignees), plus the design choice that subscription subject = principal.
crates/
garraia-auth/
src/
audit_workspace.rs ← +2 variants (TaskSubscribed, TaskUnsubscribed)
+ as_str() entries
+ matching distinct-strings test entries
garraia-gateway/
Cargo.toml ← [[test]] required-features for the new test
src/
rest_v1/
mod.rs ← +2 route registrations (1 path, GET+POST+DELETE
share the path, plus DELETE has same path)
in fact: 1 .route() entry with .post().get().delete()
tasks.rs ← +3 handler functions + Subscription{Row,Response}
openapi.rs ← +3 path entries + SubscriptionResponse schema
tests/
rest_v1_task_subscriptions.rs ← NEW — 7 integration scenarios (S1–S7)
plans/
README.md ← +1 row for plan 0079
0079-gar-539-task-subscriptions-api.md ← THIS FILE
- Add
TaskSubscribed,TaskUnsubscribedtoaudit_workspace.rs. - Add
as_str()entries:"task.subscribed","task.unsubscribed". - Update the distinct-strings test (now 32 entries).
- Update the per-variant
as_str()assertion test (add 2 new asserts). -
cargo check -p garraia-authgreen.
-
subscribe_to_task(POST, 201 on success, 409 on 23505 PK violation, 404 if task not in group). -
unsubscribe_from_task(DELETE, 204 idempotent on subscription row, 404 if task not in group). -
list_task_subscriptions(GET, 200 withVec<SubscriptionResponse>, ordered bysubscribed_at ASC, 404 if task not in group). - New types:
SubscriptionRow(sqlx::FromRow) +SubscriptionResponse(Serialize + utoipa::ToSchema) with fields{ task_id, user_id, subscribed_at, muted }. - All 3 handlers call
set_rls_context(&mut tx, principal.user_id, group_id)before any FORCE-RLS table access. - Audit metadata uses
{ subscriber_user_id_len: 36 }— PII-safe. -
cargo check -p garraia-gateway --features test-helpersgreen.
- One
.route("/v1/groups/{group_id}/tasks/{task_id}/subscriptions", …)block with.post(subscribe_to_task).get(list_task_subscriptions).delete(unsubscribe_from_task). - Marker comment:
// Plan 0079 (GAR-539) — task subscriptions API slice 6. -
cargo check -p garraia-gateway --features test-helpersgreen.
- Add
super::tasks::subscribe_to_task,list_task_subscriptions,unsubscribe_from_taskto thepaths(...)macro arm. - Add
SubscriptionResponseto thecomponents(...)macro arm. - Add
SubscriptionResponseto theuse super::tasks::{ ... }import. -
cargo check -p garraia-gateway --features test-helpersgreen.
Single bundled #[tokio::test] (avoid sqlx runtime-teardown race per plan 0016 M3).
- S1. POST 201 — Alice subscribes herself to her task; verify response
shape (
task_id,user_id,subscribed_at,muted=false); audit rowtask.subscribedwithresource_type=task_subscriptions,resource_id={task_id}, metadata hassubscriber_user_id_len: 36, nouser_idvalue or PII. - S2. POST 409 — second subscribe by same user → 409 Conflict.
- S3. GET 200 — list returns 1 entry with Alice as subscriber.
- S4. DELETE 204 — Alice unsubscribes; audit row
task.unsubscribed; second DELETE on same task → 204 idempotent. - S5. POST 404 — Bob (different group) tries to subscribe to Alice's
task by passing the foreign
task_idunder his own group → 404. - S6. POST 403 — path
group_id≠ principalgroup_id(alice's token, bob's group in path) → 403 (covered bycheck_group_match). - S7. GET 200 — empty array when no subscribers (re-list after the unsubscribe in S4).
-
cargo test -p garraia-gateway --features test-helpers --test rest_v1_task_subscriptionsgreen against testcontainers Postgres.
- Add
[[test]] name = "rest_v1_task_subscriptions"block withrequired-features = ["test-helpers"]. Mirrors the existing entry forrest_v1_task_labels/rest_v1_task_assignees.
-
cargo clippy --workspace --tests --exclude garraia-desktop --features garraia-gateway/test-helpers --no-deps -- -D warningsclean. -
cargo fmt --all -- --checkclean.
- Add row for plan 0079 (GAR-539, this plan) — initial state
⏳ In Progress. Will flip to✅ Mergedpost-merge in a follow-up doc-only PR.
| Risk | Likelihood | Mitigation |
|---|---|---|
| Empty-body POST/DELETE rejected by Axum at 415 | Low | Don't include Json<T> extractor; Axum default body parser accepts empty body when no extractor demands it. Verified with unsubscribe_from_task (DELETE) which is parameter-only. |
| Notification fan-out side-effects expected on subscribe | Out of scope | task.subscribed audit row only; no notification side effect this slice. |
| GET ordering instability across subsecond subscribes | Low | Order by subscribed_at ASC then user_id ASC as tiebreaker. |
muted accidentally exposed and someone tries to set it on POST |
Out of scope | POST takes no body; tests validate POST returns muted=false; mute toggle is a future PATCH. |
- All ≥16 actual CI workflow checks green on the PR.
- 7 integration scenarios pass against testcontainers Postgres.
- Cross-group
task_idreturns 404 (S5). - Duplicate subscribe returns 409 (S2).
- DELETE is idempotent — second DELETE returns 204 (S4 second call).
- Path-vs-principal
group_idmismatch returns 403 (S6). - Audit metadata never contains the subscriber email or display name.
- Plan 0078 (GAR-536, task labels) — same RLS class + 23505 + audit pattern.
- Plan 0077 (GAR-533, task assignees) — same JOIN policy class.
- Plan 0069 (GAR-520, task comments) — JOIN policy class.
- ROADMAP §3.8 Tier 1 — task_subscriptions schema.
- Migration 006 (
006_tasks_with_rls.sql) —task_subscriptionsdefinition. - GAR-397 — digest worker that fans out to subscribers (downstream consumer).
Baixa: 1.0h | Provável: 1.5h | Alta: 2.5h