Skip to content

Product archive-only deletion with hardened authorization (Phase 4) - #74

Merged
SylonZero merged 1 commit into
masterfrom
feat/phase4-product-archive
Sep 12, 2026
Merged

SylonZero merged 1 commit into
masterfrom
feat/phase4-product-archive

Conversation

@SylonZero

Copy link
Copy Markdown
Owner

Summary

  • Replaces Product's hard delete with the same archive/soft-delete tombstone pattern proven by Assets in Phase 3 (archivedAt/archivedById/archivedByKind) — the highest blast radius in the schema, done last per the original phasing plan.
  • Archiving does not touch, flag, or cascade to anything beneath the product — assets, code plans, releases, work items, and specs all keep existing exactly as they are. They simply stop resolving through one shared access check (productAccessWhere), which now excludes archived products by default and opts back in only for detail-style reads (viewing/restoring a specific product or something beneath it).
  • An archived product also stops accepting new writes via MCP, since the same check gates assertProductAccess.
  • canDeleteProduct (previously defined in lib/db/authz.ts but never wired to anything) is now the actual authorization gate — org owner/admin or creator, matching the unified rule used everywhere else.
  • New MCP tools archive_product/restore_product, disclosing blast-radius counts (assets, plans, releases, work items, specs) without claiming anything was touched.
  • UI: the products list page's card menu swaps "Delete" for an "Archive" confirmation showing real counts and requiring the product's name to be typed before the button enables. Archived products move into a collapsible "Archived products" section with a Restore action; the product detail page shows an archived banner (with Restore) and hides Edit/Add Asset while archived.
  • Also closes the access-control gap this phase's spec explicitly named as a blocking dependency: updateAssetAction only called requireUser() with no check that the asset was actually accessible to the caller — now uses the same getAssetOptions guard already used by updateAssetContentAction/setAssetOwnersAction.

Test plan

  • 10 new/changed tests (5 in mutations.test.tsarchiveProduct/restoreProduct, deleteProduct rewritten for its new unconditional-by-id contract; 5 in queries.test.ts — archived-visibility for getProducts/getProduct/getCodePlans) — 297/297 passing
  • pnpm exec tsc --noEmit — clean of anything this touched (two pre-existing, unrelated errors remain: passwordHash, CodePlan.productName)
  • pnpm build — succeeds
  • New migration (hand-written for Postgres since drizzle-kit generate needs an interactive TTY not available here; drizzle-kit generate worked directly for SQLite) verified against a real, previously-populated local dev DB
  • Live end-to-end pass against real seeded org data: minted sessions for the org owner, an admin member, and a non-privileged editor; confirmed canDeleteProduct allows owner/admin and blocks the editor against real membership rows (not just fixtures); archived and restored a real product via the actual mutation path and confirmed the products list page, archived-section toggle, and the detail page's archived banner all render correctly against the live dev server

🤖 Generated with Claude Code

https://claude.ai/code/session_016ZM1Fxzj2sqge7EyGQ8LXb

The highest blast radius in the schema, done last per the original
phasing directive. Product hard delete is replaced with the same
archive/soft-delete tombstone pattern proven by Assets in Phase 3:
archivedAt/archivedById/archivedByKind. Archiving does not touch,
flag, or cascade to anything beneath the product — assets, code
plans, releases, work items, and specs all keep existing exactly as
they are. They simply stop resolving through the one shared access
check (productAccessWhere), which now excludes archived products by
default and only opts into including them for detail-style reads
(viewing or restoring a specific product, or something beneath it).
An archived product also stops accepting new writes, since the same
check gates every MCP tool's assertProductAccess.

- lib/db/authz.ts: canDeleteProduct now actually wired to the delete
  path (previously defined but unused) — org owner/admin or creator,
  matching the unified rule used everywhere else.
- lib/db/mutations.ts: archiveProduct/restoreProduct, audit-logged.
  deleteProduct kept for a possible future admin-only purge, no
  longer on the normal UI/MCP path (MCP never had a delete_product
  tool to begin with).
- lib/db/queries.ts: productAccessWhere(userId, { includeArchived })
  — single source of truth, so every listing query (getCodePlans,
  getWorkItems, getReleases, getAssetOptions, dashboard/analytics,
  etc.) automatically excludes archived products' data with zero
  per-query changes, while detail reads (getProduct, getCodePlan,
  getWorkItem, getRelease, getAssetDetail/History/Record) opt back
  in so they keep working for something already resolved as
  accessible. getProducts/getProduct also gain the counts needed for
  the archive confirmation's blast-radius disclosure (planCount,
  releaseCount, workItemCount, specCount, alongside the existing
  assetCount).
- MCP: archive_product/restore_product tools disclose those same
  counts without claiming anything was touched.
- UI: the products list page's card menu swaps "Delete" for an
  "Archive" confirmation showing the real counts and requiring the
  product's name to be typed before the button enables — the
  disclosure the spec calls for given this is the largest blast
  radius in the schema. Archived products move into a collapsible
  "Archived products" section with a Restore action; the product
  detail page shows an archived banner (with Restore) and hides
  Edit/Add Asset while archived.
- Also closes the access-control gap this phase's spec explicitly
  named as a blocking dependency: updateAssetAction only called
  requireUser() with no check that the asset was actually accessible
  to the caller — now uses the same getAssetOptions guard already
  used by updateAssetContentAction/setAssetOwnersAction.

Tests: 5 new/changed in mutations.test.ts (archiveProduct/
restoreProduct, deleteProduct rewritten for its new unconditional-
by-id contract) + 5 new in queries.test.ts (archived-visibility for
getProducts/getProduct/getCodePlans) — 297/297 passing. tsc --noEmit
and pnpm build clean of anything this touched. Migration hand-written
for Postgres (drizzle-kit generate needs an interactive TTY not
available here) and drizzle-kit generate for SQLite; both verified
against a real populated local dev DB, including a live end-to-end
pass through the real archive/restore mutations and page renders
against real seeded org data (owner/admin/editor authorization
checked against real membership rows, not just fixtures).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZM1Fxzj2sqge7EyGQ8LXb
@SylonZero
SylonZero merged commit cc0bb4a into master Sep 12, 2026
1 check passed
@SylonZero
SylonZero deleted the feat/phase4-product-archive branch September 12, 2026 23:50
@SylonZero SylonZero mentioned this pull request Sep 12, 2026
4 tasks
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.

1 participant