Appearance
Media and form attachments
Media and form attachments let coaches and clients share files, images, videos, and actionable form references inside the conversation timeline.
Tags: Communication, Signed uploads, Form links, Mobile retry
Area: Communication / Message Attachments / Forms
The V1 baseline attachment contract covers signed uploads, attachment manifests, media validation, thumbnails and previews, download grants, form-assignment links, form-submission links, offline mobile retry, and redaction. Communication owns the message timeline and attachment reference. Object storage owns bytes. Forms owns form definitions, assignments, submissions, review state, and validation.
Attachment Flows
The feature separates upload reservation, binary transfer, processing, and message acceptance so mobile clients can recover from weak networks without duplicating timeline items.
Send Media Or File
- User selects one or more images, short videos, PDFs, or approved document files from the composer.
- Client creates a local pending message and stable
client_message_idplus oneclient_attachment_idper selected file. - Client calls upload-intent with file names, MIME hints, byte sizes, checksums, dimensions or duration when known, and optional reply target.
- Backend verifies conversation access, validates attachment policy, reserves the pending message and asset rows, and returns signed upload instructions.
- Client uploads bytes directly to object storage and completes each upload session.
- Processing validates MIME type from bytes, checksum, size, scan status, dimensions or duration, thumbnail generation, and preview readiness.
- Message becomes recipient-visible when required attachments are ready or when policy allows a visible processing state.
Attach A Form Assignment
- Coach opens a conversation and chooses a published form template or a pending assignment to send.
- API checks coach relationship access and asks Forms to create or return the target assignment according to idempotency rules.
- Communication records a
FORM_ASSIGNMENTattachment with the assignment id, form version id, title, purpose, due window, and safe preview snapshot. - Client sees an actionable message card that opens the Forms submission flow, not a Communication-owned form renderer.
- Forms emits assignment and submission events that update attachment projections and conversation previews.
Discuss A Form Submission
- Coach opens a submitted form from inbox, profile, onboarding, or check-in review and sends it into the conversation for clarification.
- Communication stores a
FORM_SUBMISSIONattachment reference with immutable preview fields: title, submitted time, review state, and source assignment id. - Message reply context can target the form-reference message, while detailed answers stay behind Forms permissions.
- Client deep link opens the submitted form summary or clarification task in the owning Forms screen.
- If the form submission is redacted, archived, or hidden by Forms policy, Communication displays a tombstoned preview.
Offline Retry
- Mobile stores local file URI, checksum, upload metadata, pending message, attachment ids, retry count, and last error in SQLite.
- Queued uploads reuse the same client ids, source device id, and checksum so retry cannot create duplicate messages or duplicate assets.
- Expired upload URLs are refreshed through the attachment API without changing the pending message or attachment ids.
- If a local file disappears before upload, the attachment fails locally and the message remains unsent until the user removes or replaces it.
- Form assignment creation requires server validation; mobile may draft the composer card offline but sends it only after reconnect.
Domain Model
Attachments are message children with their own upload, processing, authorization, and cross-context reference rules.
Aggregates & Entities
ConversationMessage: canonical timeline item withmessage_kind = ATTACHMENT, sender, reply reference, source device, status, and sent time.MessageAttachment: child entity linking a message to a media asset, file asset, form assignment, form submission, or source-context reference.AttachmentAsset: binary object metadata, storage key, media type, file name, checksum, byte size, preview metadata, processing status, and redaction state.AttachmentUploadSession: signed upload intent with expected file metadata, expiration, completion state, retry token, and storage target.FormAttachmentReference: immutable Communication snapshot of a Forms assignment or submission plus ids needed for deep linking.AttachmentPreview: thumbnail, poster frame, page preview, file icon metadata, dimensions, duration, and preview readiness.AttachmentAccessGrant: short-lived authorization record or computed DTO for download, preview, or inline rendering.
Value Objects & Rules
AttachmentKind:IMAGE,VIDEO,DOCUMENT,FORM_ASSIGNMENT,FORM_SUBMISSION,SOURCE_REFERENCE.AttachmentProcessingStatus:PENDING_UPLOAD,UPLOADED,PROCESSING,READY,FAILED,REDACTED.AttachmentManifest: ordered client attachment ids, display names, MIME hints, byte sizes, checksums, and optional dimensions or duration.SafeFilename: normalized display name with path separators, control characters, hidden extensions, and executable suffix tricks removed.StorageObjectRef: bucket, object key, checksum, content type, size, and optional derivative keys. Raw keys are never exposed to app clients.FormLinkSnapshot: form title, purpose, assignment status, submission status, due time, submitted time, review state, and redaction flag.- One attachment message can contain multiple media/file assets, but all must belong to the same conversation and sender operation.
- Every form attachment must pass Forms visibility policy at send time and again when the user opens the deep link.
- Push and realtime previews describe attachment type and count, not signed URLs, object keys, full filenames, form answers, or sensitive form content.
| Model | Owned By | Important Methods | Emits |
|---|---|---|---|
ConversationMessage | Communication | createAttachmentMessage(), attachManifest(), markAccepted(), redact() | AttachmentMessageCreated, MessageRedacted |
MessageAttachment | Communication | reserveMedia(), linkFormAssignment(), linkFormSubmission(), updatePreview(), redact() | AttachmentReserved, FormAttachmentLinked |
AttachmentAsset | Communication | reserveUpload(), markUploaded(), startProcessing(), markReady(), markFailed() | AttachmentUploadCompleted, AttachmentReady, AttachmentFailed |
AttachmentUploadSession | Communication | create(), refresh(), complete(), expire() | AttachmentUploadReserved |
FormAttachmentReference | Communication + Forms reference | fromAssignment(), fromSubmission(), refreshStatusSnapshot(), tombstone() | FormAttachmentStatusUpdated |
Table Structure
Keep message ordering in the base Communication tables, store binary metadata in attachment tables, and reference Forms records by external ids plus immutable previews.
| Table | Purpose | Important Columns | Constraints & Indexes |
|---|---|---|---|
communication_messages | Canonical conversation message. | id, client_message_id, conversation_id, workspace_id, relationship_id, sender_user_id, message_kind, reply_to_message_id, status, source_device_id, sent_at, redacted_at | message_kind = ATTACHMENT requires at least one active attachment; unique nullable (conversation_id, sender_user_id, client_message_id). |
communication_message_attachments | Ordered attachment records for one message. | id, message_id, conversation_id, workspace_id, relationship_id, sender_user_id, client_attachment_id, attachment_kind, display_order, caption_text, status, preview_snapshot jsonb, created_at, redacted_at | Unique nullable (message_id, client_attachment_id); unique (message_id, display_order); index (conversation_id, created_at desc). |
communication_attachment_assets | Binary media/file object metadata and processing state. | id, attachment_id, storage_bucket, storage_key, thumbnail_key, preview_key, original_filename, safe_filename, mime_type, byte_size, checksum_sha256, width, height, duration_ms, processing_status, processing_error_code, scan_status, uploaded_at, ready_at, redacted_at | Unique (attachment_id); index (processing_status, uploaded_at); index (scan_status, uploaded_at); storage keys are internal only. |
communication_attachment_upload_sessions | Signed upload intents and retry state. | id, attachment_asset_id, conversation_id, sender_user_id, source_device_id, client_message_id, client_attachment_id, expected_mime_type, expected_byte_size, expected_checksum_sha256, upload_url_expires_at, completed_at, expired_at | Unique active (conversation_id, sender_user_id, source_device_id, client_attachment_id); index (upload_url_expires_at). |
communication_form_attachment_refs | Communication snapshots of Forms records linked to messages. | id, attachment_id, forms_assignment_id, forms_submission_id, form_template_id, form_version_id, form_purpose, title_snapshot, status_snapshot, due_at, submitted_at, review_state, deep_link_payload jsonb, last_synced_at, tombstoned_at | Unique (attachment_id); index (forms_assignment_id); index (forms_submission_id); Forms ids are external references. |
communication_attachment_processing_jobs | Durable processing attempts for scans, metadata extraction, thumbnails, previews, and poster frames. | id, attachment_asset_id, job_type, job_status, attempt_count, worker_version, started_at, completed_at, error_code, error_detail jsonb | Index (job_status, started_at); retain failed attempts for support diagnostics. |
communication_attachment_access_events | Optional audit and diagnostics for preview/download grant issuance. | id, attachment_id, asset_id, conversation_id, actor_user_id, access_type, issued_at, expires_at, surface, request_context jsonb | Index (actor_user_id, issued_at desc); prune after diagnostics window unless workspace policy requires retention. |
Implementation note
SQLite stores pending attachment manifests, local file URIs, upload sessions, retry receipts, thumbnail cache metadata, and form attachment snapshots. It must not store signed download URLs, raw object-storage keys, full form answer payloads, or received-file binaries beyond explicit offline cache policy.
Implementation note
Communication keeps message, attachment, and form-reference business ownership local. Shared mobile sync rules for queued send/upload operations, client operation ids, receipts, canonical id mapping, upload-session refresh, and local cache pruning follow the Offline Sync Protocol.
Lifecycle Policy
Attachment messages are coaching history. Keep the timeline stable while controlling binary access, form visibility, and redaction separately.
Message And Asset Lifecycle
- Upload-intent creation reserves the message, attachment rows, asset rows, and upload sessions in one transaction.
- Pending sessions expire after a short window and can be refreshed only by the sender while the conversation remains writable.
- Processing reaches
READYonly after storage presence, checksum, MIME sniffing, byte-size, scan, and preview rules pass. - Attachment message status becomes accepted when all required attachments are ready or failed according to product policy.
- Redaction keeps message ids, attachment ids, sender, kind, and sent time but removes previews, download grants, thumbnails, captions, and sensitive filenames from normal clients.
Forms, Retention, And Purge
- Form attachments keep a safe snapshot for conversation history, but Forms owns assignment cancellation, submission review, clarification, archival, and purge.
- If Forms hides or purges the source record, Communication tombstones the form attachment and stops deep-link access.
- Media binaries follow Communication retention by default unless workspace privacy policy sets shorter retention for attachment classes.
- Upload sessions, access events, failed processing attempts, and sync receipts can be pruned after the support diagnostics window.
- Hard purge removes or tombstones storage objects and anonymizes residual metadata according to account, relationship, and legal retention policy.
Guardrail
Do not overload message attachments as a universal file store. If a file becomes a durable business record, the owning context must copy or claim it through an explicit handoff, then Communication should store only a source reference.
API Contracts
Attachment APIs wrap storage and Forms access behind relationship-scoped Communication permissions and idempotent mobile retry contracts.
| Endpoint | Purpose | Request | Response |
|---|---|---|---|
POST /api/v1/communication/conversations/{conversationId}/attachments/upload-intents | Create or return a pending attachment message and signed upload sessions. | Client message id, source device id, optional reply target, ordered attachment manifest with client attachment ids, filenames, MIME hints, sizes, checksums, captions. | Pending message dto, attachment ids, asset ids, signed upload URLs, required headers, expirations, limits, sync receipt. |
POST /api/v1/communication/attachments/{attachmentId}/complete-upload | Confirm object upload and enqueue processing. | Upload session id, checksum confirmation, uploaded byte size, client completed timestamp. | Attachment dto with processing status, message status, conversation revision. |
POST /api/v1/communication/attachments/{attachmentId}/refresh-upload | Refresh an expired upload URL for the sender of a pending attachment. | Upload session id, client message id, client attachment id, source device id. | New signed upload URL, headers, expiration, unchanged message and attachment ids. |
GET /api/v1/communication/attachments/{attachmentId}/download | Issue a short-lived download or inline render URL. | Actor session, optional surface and requested rendition. | Signed URL, expiration, content type, safe filename, size, cache policy. |
GET /api/v1/communication/attachments/{attachmentId}/preview | Issue short-lived preview or thumbnail access when needed. | Actor session, desired rendition such as thumbnail, poster, first-page preview. | Signed preview URL, dimensions, expiration, cache policy. |
POST /api/v1/communication/conversations/{conversationId}/form-attachments | Send a form assignment or form submission reference into a conversation. | Client message id, source device id, reference type, form template/version id or assignment/submission id, optional due date, optional message caption. | Message dto with form attachment snapshot, Forms assignment/submission status, conversation revision. |
GET /api/v1/communication/conversations/{conversationId}/messages | Load message windows with attachment summaries. | Cursor, limit, include attachment previews flag. | Messages with attachment summaries, form snapshots, processing state, preview-ready flags, no signed URLs. |
POST /api/v1/communication/sync | Reconcile queued attachment send/upload operations. | Device id, ordered operations, client operation ids, client message ids, client attachment ids, expected conversation revisions. | Accepted operations, rejected operations, canonical id map, upload refresh hints, updated conversations. |
POST /api/v1/internal/communication/attachments/{assetId}/processing-result | Worker callback for scan, metadata, thumbnail, preview, and failure results. | Worker token, status, detected MIME type, dimensions, duration, derivative keys, scan result, error code. | Updated attachment dto, message status, outbox event ids. |
Implementation note
Conversation message DTOs should use an attachments array. Each item includes attachmentId, kind, displayName, caption, processingStatus, preview, and optional formReference. Signed URLs stay behind explicit preview/download endpoints.
Key Code Snippets
Implementation sketches show the attachment schema, upload-intent use case, and form-reference boundary.
Drizzle schema shape
apps/api/src/communication/db/attachments.schema.ts
ts
export const communicationMessageAttachments = pgTable("communication_message_attachments", {
id: uuid("id").primaryKey().defaultRandom(),
messageId: uuid("message_id").notNull(),
conversationId: uuid("conversation_id").notNull(),
workspaceId: uuid("workspace_id").notNull(),
relationshipId: uuid("relationship_id").notNull(),
senderUserId: uuid("sender_user_id").notNull(),
clientAttachmentId: uuid("client_attachment_id"),
attachmentKind: text("attachment_kind").$type<AttachmentKind>().notNull(),
displayOrder: integer("display_order").notNull(),
captionText: text("caption_text"),
status: text("status").$type<AttachmentStatus>().notNull().default("PENDING_UPLOAD"),
previewSnapshot: jsonb("preview_snapshot").$type<AttachmentPreviewSnapshot>(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
redactedAt: timestamp("redacted_at", { withTimezone: true }),
}, (table) => ({
clientAttachmentUnique: uniqueIndex("message_attachments_client_unique")
.on(table.messageId, table.clientAttachmentId),
displayOrderUnique: uniqueIndex("message_attachments_order_unique")
.on(table.messageId, table.displayOrder),
conversationCreatedIdx: index("message_attachments_conversation_created_idx")
.on(table.conversationId, table.createdAt),
}));
export const communicationAttachmentAssets = pgTable("communication_attachment_assets", {
id: uuid("id").primaryKey().defaultRandom(),
attachmentId: uuid("attachment_id").notNull(),
storageBucket: text("storage_bucket").notNull(),
storageKey: text("storage_key").notNull(),
thumbnailKey: text("thumbnail_key"),
previewKey: text("preview_key"),
safeFilename: text("safe_filename").notNull(),
mimeType: text("mime_type").notNull(),
byteSize: integer("byte_size").notNull(),
checksumSha256: text("checksum_sha256").notNull(),
processingStatus: text("processing_status")
.$type<AttachmentProcessingStatus>()
.notNull()
.default("PENDING_UPLOAD"),
scanStatus: text("scan_status").$type<AttachmentScanStatus>().notNull().default("PENDING"),
uploadedAt: timestamp("uploaded_at", { withTimezone: true }),
readyAt: timestamp("ready_at", { withTimezone: true }),
redactedAt: timestamp("redacted_at", { withTimezone: true }),
}, (table) => ({
attachmentUnique: uniqueIndex("attachment_assets_attachment_unique").on(table.attachmentId),
processingIdx: index("attachment_assets_processing_idx")
.on(table.processingStatus, table.uploadedAt),
}));Create upload intents
apps/api/src/communication/use-cases/create-attachment-upload-intents.ts
ts
@Injectable()
export class CreateAttachmentUploadIntentsUseCase {
constructor(
private readonly conversations: ConversationRepository,
private readonly attachments: AttachmentRepository,
private readonly storage: MediaStorageGateway,
private readonly access: CommunicationAccessPolicy,
private readonly policy: AttachmentPolicy,
private readonly tx: TransactionRunner,
) {}
async execute(input: CreateAttachmentUploadIntentsInput, actor: Actor): Promise<AttachmentUploadIntentDto> {
return this.tx.run(async () => {
const conversation = await this.conversations.lockById(input.conversationId);
await this.access.assertCanSend(actor, conversation);
this.policy.assertManifestAllowed(input.manifest);
const existing = await this.attachments.findPendingManifest({
conversationId: conversation.id,
senderUserId: actor.userId,
sourceDeviceId: input.sourceDeviceId,
clientMessageId: input.clientMessageId,
});
if (existing) return this.attachments.refreshUploadIntents(existing);
const message = conversation.recordAttachmentMessage({
senderUserId: actor.userId,
clientMessageId: input.clientMessageId,
replyToMessageId: input.replyToMessageId,
sourceDeviceId: input.sourceDeviceId,
});
const reserved = await Promise.all(input.manifest.items.map(async (item, index) => {
const attachment = MessageAttachment.reserveMedia(message, item, index);
const upload = await this.storage.createSignedUpload({
purpose: "COMMUNICATION_ATTACHMENT",
contentType: item.mimeType,
byteSize: item.byteSize,
checksumSha256: item.checksumSha256,
ownerUserId: actor.userId,
});
return { attachment, upload };
}));
await this.attachments.saveReserved(message, reserved);
await this.conversations.save(conversation);
return AttachmentUploadIntentDto.from(message, reserved);
});
}
}Send form reference
apps/api/src/communication/use-cases/send-form-attachment.ts
ts
@Injectable()
export class SendFormAttachmentUseCase {
constructor(
private readonly conversations: ConversationRepository,
private readonly attachments: AttachmentRepository,
private readonly forms: FormsReferencePort,
private readonly access: CommunicationAccessPolicy,
private readonly outbox: OutboxPort,
private readonly tx: TransactionRunner,
) {}
async execute(input: SendFormAttachmentInput, actor: Actor): Promise<MessageDto> {
return this.tx.run(async () => {
const conversation = await this.conversations.lockById(input.conversationId);
await this.access.assertCanSend(actor, conversation);
const formRef = input.referenceType === "FORM_ASSIGNMENT"
? await this.forms.createOrGetAssignmentForConversation({
actor,
relationshipId: conversation.relationshipId,
templateVersionId: input.formVersionId,
dueAt: input.dueAt,
idempotencyKey: input.clientMessageId,
})
: await this.forms.getSubmissionReference(actor, input.formsSubmissionId);
const message = conversation.recordAttachmentMessage({
senderUserId: actor.userId,
clientMessageId: input.clientMessageId,
bodyText: input.captionText,
sourceDeviceId: input.sourceDeviceId,
});
const attachment = MessageAttachment.linkForm(message, formRef);
await this.attachments.saveFormReference(message, attachment);
await this.conversations.save(conversation);
await this.outbox.publish(message.pullDomainEvents());
return MessageDto.fromDomain(message);
});
}
}Events & Background Jobs
Attachment events keep the message timeline, media processing, Forms projections, notifications, and mobile sync state consistent.
Domain And Integration Events
AttachmentUploadReserved: records pending message state, upload expiration, and retry metadata.AttachmentUploadCompleted: schedules scan, metadata extraction, thumbnail generation, preview generation, and poster-frame jobs.AttachmentReady: updates message attachment state, conversation summary, realtime event, and push notification job.AttachmentFailed: updates sender-visible failure state and support diagnostics without granting recipient access.FormAttachmentLinked: records an assignment or submission reference and updates message preview.FormAttachmentStatusChanged: consumes Forms events to refresh assignment, submission, review, or tombstone snapshots.MessageRedacted: revokes preview/download access, hides filenames and captions, and schedules object-storage redaction or purge.
Jobs
- Attachment processing worker validates file type from bytes, checksum, size, image dimensions, video duration, PDF page count, and scan result.
- Derivative worker creates thumbnails, poster frames, and first-page previews using storage keys that remain private.
- Upload cleanup job expires abandoned sessions and removes orphaned object-storage uploads.
- Forms projection worker consumes assignment/submission events and refreshes Communication snapshots.
- Notification worker sends generic previews such as "sent 2 attachments" or "sent a check-in form" without sensitive content.
- Retention worker purges expired binaries, derivatives, upload sessions, and access diagnostics according to workspace policy.
Permissions & Access Rules
Attachments combine relationship access, file safety, storage grants, and Forms visibility checks.
Actor Rules
- Coach can send, preview, download, redact, and link form assignments or submissions only for active relationships they can access in the workspace.
- Client can send allowed media/files, preview, download, and open form attachments only in their own active coach relationship conversation.
- Only the sender can refresh or complete pending upload sessions, and only before the message is accepted, failed, redacted, or expired.
- Clients cannot attach arbitrary Forms records; form submission links must be visible to that client and relationship according to Forms policy.
- Support/admin access to attachments is disabled by default and must be audited when enabled for troubleshooting, moderation, export, or legal workflows.
Content And Storage Rules
- Allow-list MIME types and extensions; reject executables, scripts, archives, hidden-extension tricks, unsupported codecs, and files over policy limits.
- Signed upload URLs are single-purpose, short-lived, size-limited, content-type constrained, and bound to the reserved asset id.
- Signed preview and download URLs are issued only for
READYassets visible to the actor and expire quickly. - Object-storage keys, upload URLs, download URLs, scan details, full form answers, and sensitive filenames must not appear in push payloads, analytics events, or client logs.
- Processing failures are exposed as typed user-safe reasons such as unsupported type, too large, scan failed, upload expired, preview failed, or processing failed.
Web, Coach Mobile, Client Mobile
All surfaces share attachment DTOs, but mobile owns most capture and retry behavior while web needs richer review and drag-and-drop ergonomics.
Coach Web
- Composer supports drag-and-drop, file picker, image/video/PDF previews, caption text, upload progress, retry, and removal before send.
- Form picker lets coach send a published template, reuse an active assignment, or discuss an existing submission from review context.
- Conversation cards render attachment grids, document rows, processing state, failure state, redaction state, and form deep links without embedding signed URLs in the message payload.
- Coach can jump from a form attachment to assignment status, submitted answers, review task, or clarification flow when permitted by Forms.
Coach Mobile
- Composer supports camera, gallery, file picker, form picker, upload progress, retry, cancellation, and offline pending state.
- SQLite tracks local file URI, manifest item, upload session, retry count, canonical ids, processing state, and safe preview cache metadata.
- Form attachment sending requires network validation, but a draft composer card can remain local until the coach reconnects.
- Push deep links reconcile missed attachment-processing or Forms status events by reloading the conversation cursor.
Client Mobile
- Client can attach approved photos, videos, and documents for coaching context from Messages and contextual entry points.
- Client can open form assignment cards into the Forms flow and return to the original message after submit or clarification.
- Offline upload queue shows queued, uploading, processing, ready, failed, and rejected states without duplicating messages after reconnect.
- Received attachments load previews/downloads on demand and respect cache eviction; signed URLs are never persisted in SQLite.
Test Scenarios & V1 Baseline Decisions
Attachment coverage must include domain rules, signed storage, processing workers, Forms integration, offline retry, permissions, and privacy.
Tests
- Domain: duplicate upload-intent calls with the same client message id and client attachment ids return the same message, attachments, assets, and refreshed upload sessions.
- Domain: attachment cannot become
READYunless checksum, detected MIME type, byte size, scan, and preview rules pass. - Domain: multi-attachment message preserves display order and does not accept duplicate client attachment ids.
- API: coach and client cannot create, complete, refresh, preview, download, or redact attachments outside their relationship conversation.
- API: form attachment send calls Forms policy and rejects archived templates, hidden submissions, ended relationships, and duplicate assignments when Forms disallows them.
- Worker: unsafe file, unsupported codec, failed thumbnail, failed scan, and missing object produce typed failure events and sender-visible states.
- Integration: Forms assignment/submission events refresh message attachment snapshots and tombstone previews when source records become unavailable.
- Mobile E2E: offline media attachment queues locally, uploads after reconnect, and reconciles to one canonical message with stable attachment order.
- Web E2E: drag-and-drop attachments show upload progress, ready previews, failed retry, and download grants without leaking signed URLs in cached message DTOs.
V1 Baseline Decisions
- Public V1 allows up to six images per message (
jpg,jpeg,png,webp,heic; 10 MB each; 4096 px max rendered dimension), one short video per message (mp4ormovwith H.264/AAC; 60 seconds; 100 MB), one PDF per message (20 MB; 25 pages), or one coach-only document (docx,xlsx,pptx, orcsv; 20 MB). Total attachment payload is capped at 100 MB per message. - Use self-hosted Docker workers with ClamAV for malware scanning, byte-level MIME sniffing for type validation, Sharp/libvips for image normalization, thumbnailing, and EXIF stripping, FFmpeg/ffprobe for audio/video metadata and poster frames, and Poppler or PDFium for PDF page previews. Transient worker failures retry three times with backoff; unsafe, unsupported, or policy-violating files fail terminally with sender-visible reasons.
- Clients can send images and short videos in chat for Public V1. Arbitrary client document/PDF chat uploads are deferred; client health documents and answer files must enter through Forms or another owning workflow with its own visibility and retention policy. Coaches can send approved PDFs and document files.
- Sending a form template through chat always creates or returns an actionable Forms assignment in Public V1. Non-actionable form template previews are out of scope because they would create a second, non-source-of-truth form surface.
- Received attachment caches store only encrypted preview metadata and generated thumbnails, evicted on logout, storage pressure, or after seven days. Full received downloads and high-sensitivity health documents require fresh signed URLs and are not persisted in SQLite; workspace policy may disable preview caching for sensitive classes.
- Generic attachment binaries follow Communication message retention during the active relationship and 30-day ended-relationship window, then purge or tombstone storage objects and anonymize residual metadata. Attachments that are really Forms, progress, nutrition, or workout evidence inherit the owning context's stricter retention; attachment access events keep detailed diagnostics for 30 days.