Skip to content

feat(attachments): sys_file orphan lifecycle + non-admin permission matrix (#2755)#2969

Merged
os-zhuang merged 4 commits into
mainfrom
claude/attachments-v1-followups-8jhvin
Jul 15, 2026
Merged

feat(attachments): sys_file orphan lifecycle + non-admin permission matrix (#2755)#2969
os-zhuang merged 4 commits into
mainfrom
claude/attachments-v1-followups-8jhvin

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

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_attachment join row deleted only the join row; the backing sys_file row and its storage bytes were orphaned forever (no delete route, no hook, nothing ever wrote the declared status:'deleted'). One file can back many attachments (ContentDocumentLink), so no naive cascade.

  • objectqlLifecycleService gains 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 reads where.id as a scalar target).
  • service-storagesys_file gains the attachments scope option, a deleted_at tombstone field, and an ADR-0057 lifecycle block (ttl 30d on deleted_at for orphans; retention 7d onlyWhen status=pending for abandoned uploads — committed rows carry neither trigger, so they stay immortal). Tombstone hooks on sys_attachment mark a file deleted when its last attachments-scope join row is removed 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 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.

  • service-storage attachment access hooks (Salesforce parent-derived semantics): create requires caller READ visibility of the parent record (403 ATTACHMENT_PARENT_ACCESS) and server-stamps uploaded_by from the session; delete requires uploader-or-parent-editor via sharing.canEdit (403 ATTACHMENT_DELETE_DENIED; multi-deletes need every row to pass).
  • storage routes — optional resolveSession gate: with an auth service wired, the six upload entry points 401 anonymous callers (fail-closed on resolver errors) and stamp owner_id; bare kernels/tests stay open. Downloads stay anonymous capability URLs (follow-up).
  • sys_attachment.share_type / visibility REMOVED — modeled in v1 with zero runtime consumers (ADR-0049 state 3); translation bundles regenerated.
  • restmapDataError branches 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/verify BootOptions.extraPlugins seam) boots the real serve pairing and drives genuine signed-up members through the presigned three-step upload and /data path. Pinned findings include a dogfood finding: the everyone baseline (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

  • Unit: objectql 857 ✓, service-storage 76 ✓ (new: reap guard, tombstone hooks, access hooks, upload-auth gate), rest 270 ✓, plugin-audit 32 ✓, platform-objects 80 ✓
  • Dogfood: full suite 256 ✓ / 3 skipped incl. the new 13-case matrix (multiTenant block skips without the enterprise package)
  • Gates: check:liveness ✓, check:api-surface

ℹ️ The red Validate Package Dependencies check is a repo-wide infra failure (pnpm audit hits npm's retired audit endpoint → 410 / ERR_PNPM_AUDIT_BAD_RESPONSE), not caused by this diff; the changeset check in that same job passes.

Decisions taken (repo-owner input, confirmed during planning)

Lifecycle class transient with 30d/7d windows (governance-overridable); share_type/visibility removed (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_session lifecycle, enable.trash enforce-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

claude added 4 commits July 15, 2026 13:06
…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
@vercel

vercel Bot commented Jul 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Building Building Preview, Comment Jul 15, 2026 2:46pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file tests tooling size/xl labels Jul 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 6 package(s): @objectstack/dogfood, @objectstack/objectql, @objectstack/platform-objects, @objectstack/rest, packages/services, @objectstack/verify.

25 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx (via @objectstack/rest)
  • content/docs/api/error-handling-server.mdx (via @objectstack/rest)
  • content/docs/api/index.mdx (via @objectstack/rest)
  • content/docs/automation/webhooks.mdx (via packages/services)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/runtime-services/audit-service.mdx (via packages/services)
  • content/docs/kernel/runtime-services/index.mdx (via packages/services)
  • content/docs/kernel/runtime-services/settings-service.mdx (via packages/services)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/permissions/authorization.mdx (via packages/dogfood, packages/rest)
  • content/docs/permissions/delegated-administration.mdx (via packages/dogfood)
  • content/docs/plugins/index.mdx (via @objectstack/objectql, @objectstack/rest)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql, @objectstack/platform-objects, @objectstack/rest, packages/services)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/services)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql, @objectstack/rest, @objectstack/verify)
  • content/docs/releases/v12.mdx (via @objectstack/rest)
  • content/docs/releases/v9.mdx (via @objectstack/objectql)
  • content/docs/ui/setup-app.mdx (via @objectstack/platform-objects)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang marked this pull request as ready for review July 15, 2026 14:56
@os-zhuang
os-zhuang merged commit 274b894 into main Jul 15, 2026
16 of 18 checks passed
@os-zhuang
os-zhuang deleted the claude/attachments-v1-followups-8jhvin branch July 15, 2026 15:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[attachments] v1 follow-ups: sys_file orphan lifecycle + non-admin permission matrix dogfood

2 participants