feat(attachments): sys_file orphan lifecycle + non-admin permission matrix (#2755)#2969
Merged
Merged
Conversation
…ombstone hooks (#2755) Part 1 of #2755: deleting a sys_attachment join row orphaned the backing sys_file row and its storage bytes forever. - objectql: LifecycleService reap guards (registerReapGuard) — a guarded object is never blind-deleted; candidates are fetched in batches, the guard confirms (after external cleanup) or vetoes each row, an erroring guard fails safe, and engines without find skip guarded objects. - service-storage: sys_file gains the 'attachments' scope option, a deleted_at tombstone field, and an ADR-0057 lifecycle declaration (ttl 30d on deleted_at for orphans; retention 7d onlyWhen pending for abandoned uploads). Tombstone hooks on sys_attachment mark a file deleted when its last join row goes away (attachments scope only) and un-tombstone on re-attach. The sys_file reap guard re-verifies zero references at sweep time and deletes the storage bytes before confirming the row reap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187NT3Qer9oep5dCRb9b8Lt
…ead share_type/visibility (#2755) Part 2 of #2755 (enforce-or-remove, ADR-0049). sys_attachment rows ride the generic data path where the default member sets grant wildcard CRUD — any member could attach files to records they cannot see and delete other users' attachments; uploaded_by was client-spoofable; the storage upload routes were fully anonymous. - service-storage attachment access hooks (Salesforce parent-derived semantics): beforeInsert requires caller READ visibility of the parent record (403 ATTACHMENT_PARENT_ACCESS, caller-scoped findOne so RLS/OWD/ sharing apply) and server-stamps uploaded_by from the session; beforeDelete requires uploader-or-parent-editor (sharing.canEdit; 403 ATTACHMENT_DELETE_DENIED; multi-deletes need every row to pass; read- visibility fallback when no sharing service). System context bypasses. - storage routes: optional resolveSession gate — with an auth service wired, the six upload entry points 401 anonymous callers (fail-closed on resolver errors) and new sys_file rows are stamped with owner_id; without one (bare kernels/tests) routes stay open. Download capability URLs stay anonymous (tracked follow-up). - sys_attachment: share_type/visibility removed — modeled in v1 with zero runtime consumers (ADR-0049 state 3); translation bundles regenerated. - rest: mapDataError branches for the two new 403 codes (#2707 lesson). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187NT3Qer9oep5dCRb9b8Lt
…ycle e2e (#2755) The dogfood the issue asked for: the #2742 E2E only ever drove a seeded admin. New in-process matrix boots a purpose-built fixture (att_case public / att_secret private+owner / att_nofiles) with the REAL serve pairing (StorageServicePlugin + AuditPlugin) via a new BootOptions.extraPlugins seam in @objectstack/verify, signs up genuine members, and drives the presigned three-step upload + /data path. Pinned findings: - anonymous upload 401; owner_id + uploaded_by server-stamped (spoof attempt loses) - FILES_DISABLED enforced e2e for the first time - ATTACHMENT_PARENT_ACCESS: member cannot attach to an invisible record - DOGFOOD FINDING: the everyone baseline (member_default) carries NO delete bit (ADR-0090 D5) — an ungranted member cannot delete even their own attachment (PERMISSION_DENIED before the attachment gate); apps enable attachment management via an ordinary granted set (the fixture ships one), after which uploader-delete works and stranger-delete stays 403 - KNOWN GAP pins with follow-up pointers: sys_attachment listing does not inherit parent visibility; downloads stay anonymous capability URLs - Part 1 e2e: tombstone on last ref, shared-file survival, re-attach un-tombstone, sweep reaps expired tombstones AND deletes the bytes on disk, NULL deleted_at / fresh tombstones / committed rows survive, hook-bypass re-reference is un-tombstoned by sweep-time re-verification, abandoned pending uploads reaped - multiTenant cross-org block (skips without the enterprise package) Also: guarded reap now deletes per id — the engine's delete path reads where.id as a scalar target, so the previous $in filter was bound as an object by the SQLite driver. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187NT3Qer9oep5dCRb9b8Lt
Document the LifecycleService reap-guard seam (domain re-verification + external-resource reclaim) as an ADR-0057 amendment, with sys_file as the first consumer, and add the release changeset for the framework packages. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187NT3Qer9oep5dCRb9b8Lt
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
📓 Docs Drift CheckThis PR changes 6 package(s): 25 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
7 tasks
os-zhuang
marked this pull request as ready for review
July 15, 2026 14:56
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2755. Follow-up to the #2727 generic Attachments surface (#2742 + objectui #2374), closing the two v1 boundaries the close-out review named.
Part 1 — sys_file orphan lifecycle (ADR-0057)
Deleting a
sys_attachmentjoin row deleted only the join row; the backingsys_filerow and its storage bytes were orphaned forever (no delete route, no hook, nothing ever wrote the declaredstatus:'deleted'). One file can back many attachments (ContentDocumentLink), so no naive cascade.LifecycleServicegains a reap guard seam (registerReapGuard(object, guard)). A guarded object is never blind-deleted: candidates are fetched in batches, the guard confirms each id (after external cleanup) or vetoes it, an erroring guard fails safe, and an engine without row reads skips the object (reap-guard-unsupported). Deletes are per-id (the engine's delete path readswhere.idas a scalar target).sys_filegains theattachmentsscope option, adeleted_attombstone field, and an ADR-0057lifecycleblock (ttl 30dondeleted_atfor orphans;retention 7d onlyWhen status=pendingfor abandoned uploads — committed rows carry neither trigger, so they stay immortal). Tombstone hooks onsys_attachmentmark a filedeletedwhen its last attachments-scope join row is removed and un-tombstone on re-attach; thesys_filereap guard re-verifies zero references at sweep time and deletes the storage bytes before confirming each reap. Detection stays inside the single platform sweep (ADR-0057 §3.3 — no bespoke sweeper).Part 2 — non-admin permission matrix + enforcement (ADR-0049)
The #2742 E2E used only the seeded admin. Verified statically and now enforced/dogfooded: members inherited wildcard CRUD with zero row scoping.
ATTACHMENT_PARENT_ACCESS) and server-stampsuploaded_byfrom the session; delete requires uploader-or-parent-editor viasharing.canEdit(403ATTACHMENT_DELETE_DENIED; multi-deletes need every row to pass).resolveSessiongate: with an auth service wired, the six upload entry points 401 anonymous callers (fail-closed on resolver errors) and stampowner_id; bare kernels/tests stay open. Downloads stay anonymous capability URLs (follow-up).sys_attachment.share_type/visibilityREMOVED — modeled in v1 with zero runtime consumers (ADR-0049 state 3); translation bundles regenerated.mapDataErrorbranches for the two new 403 codes ([spec/cli] 对象 enable.files/trackHistory/activities/feeds 为 dead property:schema 接受、liveness 仅告警不拦 #2707 lesson).Dogfood deliverable
New
packages/dogfood/test/attachments-permission-matrix.dogfood.test.ts(+ fixture, +@objectstack/verifyBootOptions.extraPluginsseam) boots the real serve pairing and drives genuine signed-up members through the presigned three-step upload and/datapath. Pinned findings include a dogfood finding: theeveryonebaseline (member_default) carries no delete bit (ADR-0090 D5), so an ungranted member cannot delete even their own attachment — apps enable attachment management via a granted set (the fixture ships one). The Part-1 block proves bytes actually disappear from disk after the sweep and that re-referenced/fresh/committed files survive.Verification
objectql857 ✓,service-storage76 ✓ (new: reap guard, tombstone hooks, access hooks, upload-auth gate),rest270 ✓,plugin-audit32 ✓,platform-objects80 ✓check:liveness✓,check:api-surface✓Decisions taken (repo-owner input, confirmed during planning)
Lifecycle class
transientwith 30d/7d windows (governance-overridable);share_type/visibilityremoved (vs experimental-mark); attach/delete gate strength = read-visibility for create + uploader∨canEdit for delete; upload auth = 401 only when a resolver is wired.Follow-ups
Consolidated into tracking issue #2970 (parent-visibility inheritance, authenticated downloads, edit-on-parent parity,
sys_upload_sessionlifecycle,enable.trashenforce-or-remove, optional showcase browser dogfood) — each is a known-gap pin in the matrix today.Companion
objectui #2532 (RecordAttachmentsPanel: authenticated uploads + friendly denial copy). Merge order: this PR first (server gates before the panel adapts).
🤖 Generated with Claude Code