Skip to content

feat(docs): archive deleted pages with 30-day retention (#37) - #39

Merged
christianhuening merged 1 commit into
mainfrom
feat/doc-archive-retention
Oct 13, 2026
Merged

christianhuening merged 1 commit into
mainfrom
feat/doc-archive-retention

Conversation

@christianhuening

Copy link
Copy Markdown
Contributor

Fixes #37.

Why

Issue #37 reports that a folder in the sidebar can't be deleted. There were two causes:

  • The sidebar offered Delete to editors, but DELETE /api/docs/{id} required the owner role. Editors got a bare "Delete failed".
  • Deleting a doc only marked that one row. Its subpages stayed live and popped up at the top level of the sidebar, so the folder looked undeleted. They also stayed in search and export.

This PR fixes both and replaces the soft delete with an archive. Deleted pages can be restored for a retention period and are then deleted permanently. Some content must sometimes be removed immediately, so owners can also delete permanently ahead of time.

Design: docs/superpowers/specs/2026-10-13-document-archive-retention-design.md
Plan: docs/superpowers/plans/2026-10-13-document-archive-retention.md

What changes for users

  • Move to archive. The sidebar's row menu item is now Move to archive. Editors and owners can use it.
    • The confirm says how many subpages go with the page and when it will be deleted for good.
    • The whole subtree moves to the archive together.
  • Archive page. Under Archive in the sidebar, editors and owners see what was deleted: by whom, when, and how many days are left.
    • They can open a read-only preview or restore it.
    • Restore brings the same set back where it was, or to the top level if the old parent is itself archived.
    • Owners can Delete permanently.
  • Archived pages are read-only everywhere.
    • Every write answers 409 doc.archived.
    • Open editors reconnect read-only.
    • Public share links stop working until the page is restored.
    • Search, My Tasks and export leave archived pages out.
    • Viewers don't see archived pages at all (404).
  • Retention. After KNOT_ARCHIVE_RETENTION_DAYS (default 30; Helm value archive.retentionDays), an hourly job deletes the page for good.
    • It runs on one replica at a time, using a Postgres advisory lock.
    • It deletes the attachments' S3 objects first; the database cascade takes history, comments, boards, tasks, share links and grants.
    • If S3 deletes fail part-way, the page can no longer be restored, and the job finishes the deletion on its next run.
  • Upgrade. Pages soft-deleted before this release appear in the archive with a fresh 30 days. Their live subpages, which the sidebar already showed at the top level, are now actually moved there, so purging the old parent can't take them with it.
  • Related fixes.
    • The sidebar row menu now works with the keyboard. Before, drag-and-drop took Enter and Space everywhere in the row.
    • A page whose access changed while it was open no longer sends you away with "You no longer have access" if you can still read it. It reconnects in place.

Notes for review

  • Data model. Every row archived with a page carries archive_root_id = the page the user deleted. Restore and purge act on that set; reads stay simple column checks.
  • Read-only enforcement. require_doc_role_mw now gets DocAccess { role, archived } from the ACL cache. For archived docs it returns 404 for viewers and 409 for any non-GET request, except the restore and purge routes. Routes outside that middleware use archive::refuse_if_archived: shares, task checkbox, blobs, boards, and import into a parent.
  • Concurrency.
    • Create and move lock the target parent FOR SHARE.
    • Archive re-stamps until a pass finds nothing new.
    • Restore locks the parent before the doc.
    • The archive, restore and purge handlers evict the local ACL cache synchronously, so a write right after an archive on the same replica can't slip through.
  • Purge fence. purge_requested_at is committed before any S3 delete. For the Postgres blob backend there is no separate delete at all; the bytes cascade inside the purge transaction.
  • Database backups. Backups and PITR keep purged content for as long as they're kept. This is documented in the chart README.

Testing

  • cargo test --workspace: 441 passed. New coverage:
    • storage: subtree archive and restore, races, purge cascade, the migration backfill
    • HTTP: archive integration, including a table of every write route on an archived doc
    • purge job: lock held, blob failure, Postgres backend
  • Web (Vitest): 375 passed, including the archive page, the archived banner, the sidebar menu and keyboard, and the reconnect on 4403.
  • E2E: the new archive.spec.ts, plus the existing suite (86 passed, without the OIDC and ws-reconnect specs, which need Dex and toxiproxy).
  • cargo clippy --all-features -D warnings, cargo fmt, tsc, eslint and helm lint are clean.
  • A known flaky test: contributors_integration::revoked_editor_is_closed_with_4403_and_its_edits_are_dropped failed once under full-suite load with ConnectionReset (the test sends right after revoking). It passed 10 out of 10 in isolation and in the next full run. This PR doesn't touch that code path.

🤖 Generated with Claude Code

Deleting a page now moves it and its whole subtree to an archive. Editors
(not only owners) can delete, preview and restore; owners can delete
permanently. An hourly job purges archive roots older than
KNOT_ARCHIVE_RETENTION_DAYS (default 30), deleting S3 objects first.

Fixes #37: the sidebar offered Delete to editors but the server required
owner, and deleting a folder left its subpages behind at the top level.

- migration: archived_by, archive_root_id, purge_requested_at; backfill
  of earlier soft-deletes (fresh clock, live children detached)
- archived docs are read-only for editors+, invisible to viewers; every
  write path answers 409 doc.archived; share links act revoked; search,
  My Tasks and export skip them
- restore locks the parent first; purge is fenced against restore while
  S3 deletes run, and retried by the job if they fail
- web: Move to archive, Archive page, archived banner, the page stays open
  when access changes but is still readable, keyboard-usable row menu

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@christianhuening
christianhuening merged commit a452f4c into main Oct 13, 2026
7 checks passed
@christianhuening
christianhuening deleted the feat/doc-archive-retention branch October 13, 2026 15:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: cannot delete a folder

1 participant