Attachment System Tables¶
Attachments (attachments)¶
Purpose: Manages file attachments for various entities or as shared resources
Use Case Example: Upload a project poster image that displays on the project dashboard, or attach a call sheet PDF to a specific shooting day.
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier |
| file_name_original | TEXT | Original filename as uploaded |
| file_name_stored | TEXT | System-generated unique filename |
| storage_path_or_url | TEXT | Full path in storage or external URL |
| mime_type | TEXT | File MIME type (e.g., 'image/jpeg', 'application/pdf') |
| file_size_bytes | BIGINT | File size in bytes |
| file_hash | TEXT | SHA-256 hash of file content for duplicate detection |
| description | TEXT | Optional description of the file |
| uploaded_by_user_id | UUID | User who uploaded the file |
| parent_entity_type | TEXT | Type of parent entity (e.g., 'project', 'budget_item') |
| parent_entity_id | UUID | ID of the parent entity |
| external_id | TEXT | External ID from third-party services (e.g., WhatsApp media ID) |
| external_id_created_at | TIMESTAMPTZ | When the external ID was created (for expiration tracking) |
| project_id | UUID | Optional reference to the project (FK to projects) |
| organisation_id | UUID | Optional reference to the organisation (FK to organisations). NULL for user-level attachments like avatars |
| superseded_by_id | UUID | Reference to the attachment that supersedes this one (FK to attachments) |
| form_submission_id | UUID | Form submission a form-fill upload belongs to (FK to form_submissions, ON DELETE SET NULL). Server-managed |
| created_at | TIMESTAMPTZ | Upload timestamp |
| updated_at | TIMESTAMPTZ | Last update timestamp |
Key Features:
- Can be linked to any entity type via parent_entity_type/id
- Tracks both internal storage and external URLs
- Maintains upload history with user tracking
- Supports project posters and document attachments
- Supports attachment versioning via superseded_by_id self-reference
form_submission_id(partial indexidx_attachments_form_submission_id WHERE NOT NULL) durably links a form-fill upload to its submission, replacing the old description-marker association
form_submission_id — server-managed with DB-point guards: the column is set only by the service-role form-fill flow; three private trigger functions enforce this and serialise attachment writes against the submission lifecycle. All are no-ops when form_submission_id is NULL, so every non-form attachment flow is untouched.
private.guard_attachment_form_submission_id(SECURITY INVOKER, onBEFORE INSERT OR UPDATE OF form_submission_id): rejects any INSERT that sets, or UPDATE that changes,form_submission_idwhen the caller is theauthenticatedoranonrole. INVOKER socurrent_userreflects the actual caller (a DEFINER fn would see its owner); this catches both JWT-carrying and claim-less sessions whereauth.role()alone would not.private.guard_form_attachment_insert(SECURITY DEFINER,BEFORE INSERT WHEN form_submission_id IS NOT NULL): locks the submission rowFOR UPDATE, requiresstatus IN ('requested','in_progress'), and enforces a per-submission cap of 20 attachments. Serialises against the submission's terminal transition.private.guard_form_attachment_delete(SECURITY DEFINER,BEFORE DELETE WHEN form_submission_id IS NOT NULL): locks the submissionFOR UPDATE, requires active status, and rejects the delete when the attachment has already been promoted (referenced byproject_relationship_attachmentsorentity_attachments).
See VIEWS_AND_FUNCTIONS.md for the trigger-function ACL/definer details and FORMS.md for the capability flow.
RLS (SELECT / INSERT) — scoped to the owning tenant via the row's own project_id / organisation_id columns (NOT the parent entity type):
- SELECT a row if any of: you uploaded it; it is project-owned and you have access to that
project_id(caller_has_permission('{}', project_id, true), incl. org cascade); it is org-owned (no project) and you are a member of thatorganisation_id(v_user_accessible_organisations); or it has neither tenant column set. The no-tenant case is intentional — those are public-assets attachments (integration-provider logos, import-mapping templates) whose files live in the publicpublic-assetsbucket and are not tenant data. Therow_is_visible_to_callersensitivity gate also applies. - INSERT mirrors the same tenant scoping and additionally requires
uploaded_by_user_id = auth.uid(). - Earlier the SELECT/INSERT policies had a
parent_entity_type <> 'project'carve-out that treated every non-project attachment (transaction receipts, entity docs, budget files) as effectively public — a cross-organisation metadata leak (storage paths, file names, hashes). That carve-out was removed.
Entity Attachments (entity_attachments)¶
Purpose: Links attachments to entities with categorization and versioning support for identity documents, contracts, and other entity-related files.
Use Case Example: Upload a passport scan for a cast member entity, which can later be superseded by a newer passport when renewed.
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier |
| entity_id | UUID | Reference to entity (FK to entities) |
| attachment_id | UUID | Reference to attachment record (FK to attachments) |
| attachment_type | entity_attachment_type | Type of attachment (identity_document, contract, certificate, etc.) |
| document_key | TEXT | Unique key for document type per entity (e.g., 'gb_passport', 'nz_driver_license') |
| description | TEXT | Optional description of the attachment |
| metadata | JSONB | Additional metadata (OCR extracted data, expiry dates, etc.) |
| is_superseded | BOOLEAN | Whether this attachment has been superseded by a newer version (default false) |
| created_at | TIMESTAMPTZ | Creation timestamp |
| created_by_user_id | UUID | User who created the link (FK to users) |
Key Features:
- Links attachments to entities with type categorization
- document_key enables versioning of same document type (e.g., multiple passports from different countries)
- Partial unique index ensures only one active (non-superseded) document per entity per document_key
- is_superseded flag maintains historical records while tracking current valid documents
- metadata field stores OCR-extracted data for identity documents
- RLS policies use v_user_accessible_organisations for access control
Project Relationship Attachments (project_relationship_attachments)¶
Purpose: Links attachments to a specific project_relationship (a person-in-this-project), distinct from entity_attachments which are global to the entity. Used for documents that belong to the relationship rather than the person — contracts, starter forms, proof of work, certificates, and the per-relationship copy of an identity document used to verify the person for THIS project role.
Use Case Example: Upload a signed contract for a crew member when they accept their role on a specific film production. The same person might have a different contract on a different project — each relationship gets its own row.
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier |
| project_relationship_id | UUID | Reference to the project_relationship this attachment belongs to (FK to project_relationships, ON DELETE CASCADE) |
| attachment_id | UUID | Reference to the underlying attachment record (FK to attachments) |
| attachment_type | project_relationship_attachment_type | Type/category of the attachment (identity_document, contract, starter_form, proof_of_work, certificate, other) |
| description | TEXT | Optional description or notes about the attachment |
| metadata | JSONB | Additional metadata about the attachment (e.g., OCR extraction results, parsed document details) |
| is_superseded | BOOLEAN | Whether this attachment has been replaced by a newer version (default false). TRUE means it is a historical record |
| superseded_by_id | UUID | When is_superseded is true, points to the newer project_relationship_attachments row that replaced this one (self-FK). Forms an explicit replace chain |
| created_at | TIMESTAMPTZ | Creation timestamp |
| created_by_user_id | UUID | User who created this record (FK to users, default auth.uid()) |
Key Features:
- Distinct from
entity_attachments— these are scoped to a specific person-in-this-project, not globally to the entity attachment_typecovers contracts, starter forms, proof of work, certificates, identity documents (the per-relationship copy), and a catch-allother- Partial unique index
(project_relationship_id, attachment_type) WHERE is_superseded = false AND attachment_type <> 'other'enforces "one active doc per type per relationship", withotherexempt as a multi-use bucket superseded_by_idis a self-FK forming an explicit replace chain so previous versions can be walked back from the current row- The legacy
project_relationships.identity_attachment_idcolumn was dropped in migration20260403000000and existing references were backfilled into this table withattachment_type = 'identity_document' - The corresponding
attachments.parent_entity_typeis set toproject_relationshipsandparent_entity_idto the project_relationship id, so attachment-history queries (useSupersededAttachmentsQuery) work the same way as for transactions and entities - RLS policies gate access by joining through
project_relationships → projectstov_user_accessible_organisationsforauth.uid()
Storage-object ownership (storage.objects) — the ingest asymmetry¶
Attachment ROWS live in attachments; the bytes live in the files bucket as storage.objects. The two have different access models, and the mismatch is a recurring source of bugs.
The files bucket policies (verified live on production):
| Command | Policy | Predicate |
|---|---|---|
| SELECT | Users can view files they have access to |
owner = auth.uid() OR a matching attachments row the caller can reach (own upload, or project_id in caller_accessible_project_ids) |
| INSERT | Users can upload files to their organisations |
path-scoped: organisations/<code>/… via caller_can_access_organisation_code, or users/<auth.uid()>/… |
| UPDATE | Users can update their own files |
owner = auth.uid() |
| DELETE | Users can delete their own files |
owner = auth.uid() |
The asymmetry: SELECT is generous (it falls back to the attachments join), but UPDATE/DELETE are strictly owner-scoped. Every service-role write — email ingest, WhatsApp/Sana, server-side adoption of a staged file — records owner = NULL, because there is no auth.uid() in that context. On production 1205 of 1305 files objects (~92%) have owner IS NULL.
Consequences for any flow that mutates an existing object:
- A user-session
move/rename/overwriteof an ingested object always fails.storageHelper.moveFilereturnsfalsefor this (it collapses both API errors and thrown exceptions intofalse), so an uncheckedif (moved)silently skips the follow-up bookkeeping. - A subsequent
uploadto the same key withupsert:falsethen 409s, because the original object is still sitting there. - It looks intermittent in testing: an admin replacing their own upload is owner-matched and succeeds, and a re-upload whose hash matches an existing project file takes a pure-DB path that never touches storage at all. The real-world case (replacing an emailed invoice) fails every time. This was the DEV-823 production bug.
The pattern to follow: perform the storage writes under a service-role client — runWithContext({ ...ctx(), database: createServiceRoleClient() }, …) — from the server layer, behind an explicit authorization gate that (a) reads the record under the caller's RLS, (b) derives project/organisation/current-attachment from THAT read rather than trusting client-supplied ids, and (c) mirrors the UI's real entitlement rules. Thread the actor explicitly (uploaded_by_user_id = ctx().userId) — under service role there is no auth.uid() to default to. Reference implementation: replaceTransactionDocumentFromStagedDocument in services/transaction/server.ts.
Do NOT loosen the bucket's UPDATE policy to work around this — it is deliberately owner-scoped (migration 20260615222658), and widening it affects every consumer of the bucket.
To check the current split in any environment:
SELECT owner IS NULL AS service_role_written, count(*)
FROM storage.objects
WHERE bucket_id = 'files'
GROUP BY 1;