Soft-delete cascade was the missing half of archive: stamping workspaces.archived_at alone left objects visible to anyone with a direct id. The cascade runs in one transaction so the partial state isn't reachable, and restore inverts it for any archived row in the workspace — provenance-blind on purpose until we have a use case that needs to distinguish per-workspace from per-object archives. audit_log keeps the keyset index on (workspace_id, created_at) and the actor_user_id FK with onDelete set null. recordAudit() refuses to write a null actor without a metadata.system_actor label so the audit view always has something to render. workspaces and invites mutations call recordAudit on success; objects-router instrumentation and the markdown importer's system-actor flow are filed as P2 follow-ups because each needs a thoughtful "what's audit-worthy?" pass, not mechanical wiring. Settings → Audit log lives at /<slug>/settings/audit, owner-gated, keyset-paginated. ACTION_LABELS is small on purpose; new actions fall back to their raw key so missing a label degrades gracefully. Co-authored-by: Cursor <cursoragent@cursor.com>
2.1 KiB
2.1 KiB
| kind | slug | title | plan_slug | epic_slug | status | priority | tenant_id | owner | cursor_todo_id | updated_at |
|---|---|---|---|---|---|---|---|---|---|---|
| task | audit-instrument-rate-limit-trips | Write audit-log rows on every rate-limit trip | multitenant-saas-hardening | tenant-lifecycle | draft | P2 | global | unassigned | null | 2026-06-02 |
Task summary
Rate-limit trips currently only go to stdout (console.warn). The parent task spec called for an audit_log row on every trip so we can:
- See in the audit view whether anyone's actually hitting the limits in practice.
- Build abuse-signal dashboards on top of
audit_loginstead of scraping logs.
This is a small follow-up because we kept Task-rate-limit-and-abuse-guardrails scoped to "the wire-ins ship cleanly" and didn't want to churn auth.ts and invites.ts a second time inside the same task.
Subtasks
- At every
if (!rl.allowed) { ... }site, callrecordAudit(...)before the rejection. For the Credentials path, the workspace context isn't known (the user isn't even authenticated yet), so we need a different write path:- Option A: skip audit for sign-in trips and only log to stdout (current behavior).
- Option B: add a
system_audit_logtable for non-workspace events (sign-in attempts, rate-limit trips on unauthenticated routes). - Option C: stamp
workspace_id = '00000000-0000-0000-0000-000000000000'as a sentinel "system" workspace. - Recommended: A for v1 (no schema change), revisit when we have a real need for cross-workspace audit. For workspace-scoped trips (invite-create), use the regular
recordAudit()flow.
- Wire
recordAuditinto theinvites.createrate-limit branch. Action:rate_limit.tripped. Metadata:{ key, retry_after_ms, limit, window_ms }. - Add the
rate_limit.trippedaction to the audit page'sACTION_LABELSmap.
Acceptance criteria
- Triggering the
invites.createrate-limit produces arate_limit.trippedrow in the audit view. - Sign-in trips remain logged to stdout (decision A above) until a
system_audit_logtable is justified.
Links
- Parent:
./Task-rate-limit-and-abuse-guardrails.md - Epic:
./Epic-tenant-lifecycle.md