file-storage-service — DOMAIN_MODEL
Companion: SERVICE_OVERVIEW · APPLICATION_LOGIC · DATA_MODEL · EVENT_SCHEMAS · SECURITY_MODEL
This document is the canonical domain model for file-storage-service. The domain layer is pure TypeScript: no NestJS, no Drizzle/Kysely, no GCS SDK, no fetch. Aggregates enforce invariants in their constructors and command methods; persistence and I/O happen at the application/infrastructure boundary via ports.
1. Strategic Diagram (text)
┌──────────────────────────────────────────────────────────┐
│ Tenant │
│ (TenantId from @ghasi/contracts-melmastoon) │
└──────────────────────────────────────────────────────────┘
│ owns
┌──────────────────────────────┼─────────────────────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────────────┐ ┌──────────────┐
│ Bucket │ references │ FileObject │ produces │ Variant │
│ (logical)│ ◄──────────── │ (med_…) │ ────────────► │ (var_…) │
└──────────┘ │ status FSM │ └──────────────┘
└──────────────────┘
│
┌────────┼─────────┐
▼ ▼ ▼
UploadSession ScanResult AccessGrant
(ups_…) (scn_…) (grt_…)
applied via
┌─────────────────────┐
│ RetentionPolicy │
│ (ret_…) │
└─────────────────────┘
The aggregate boundary is FileObject. Everything else is either a child entity (Variant, ScanResult, AccessGrant) reached only through the parent or a separate aggregate (UploadSession, Bucket, RetentionPolicy).
2. Branded IDs
import type { Branded } from '@ghasi/contracts-melmastoon';
export type FileObjectId = Branded<string, 'FileObjectId'>; // med_<ULID>
export type UploadSessionId = Branded<string, 'UploadSessionId'>; // ups_<ULID>
export type VariantId = Branded<string, 'VariantId'>; // var_<ULID>
export type ScanResultId = Branded<string, 'ScanResultId'>; // scn_<ULID>
export type RetentionPolicyId = Branded<string, 'RetentionPolicyId'>;// ret_<ULID>
export type BucketId = Branded<string, 'BucketId'>; // bkt_<ULID>
export type AccessGrantId = Branded<string, 'AccessGrantId'>; // grt_<ULID>
ID format: <prefix>_<26-char Crockford ULID>. Construction goes through the corresponding factory XxxId.from(string) which throws InvalidIdError if the format is wrong.
3. Value Objects
3.1 Sha256
export class Sha256 {
private constructor(public readonly hex: string) {}
static from(hex: string): Sha256 {
if (!/^[0-9a-f]{64}$/.test(hex)) throw new InvalidSha256Error(hex);
return new Sha256(hex);
}
equals(other: Sha256): boolean { return this.hex === other.hex; }
}
3.2 ContentType
A strict allow-list per Scope. The full matrix lives in SECURITY_MODEL §5. At the domain layer the type validates against the canonical registry:
export type Scope =
| 'property_photo'
| 'tenant_logo'
| 'theme_asset'
| 'invoice_pdf'
| 'receipt_scan'
| 'guest_id_scan'
| 'vendor_lock_report'
| 'notification_attachment'
| 'misc';
export class ContentType {
private constructor(public readonly value: string) {}
static from(value: string, scope: Scope): ContentType {
const allowed = AllowedContentTypes[scope];
if (!allowed.includes(value)) throw new ContentTypeNotAllowedError(value, scope);
return new ContentType(value);
}
}
3.3 ByteSize
export class ByteSize {
private constructor(public readonly bytes: number) {}
static from(bytes: number, scope: Scope): ByteSize {
if (!Number.isInteger(bytes) || bytes < 0) throw new InvalidByteSizeError(bytes);
const cap = ScopeByteCap[scope];
if (bytes > cap) throw new ScopeByteCapExceededError(bytes, scope, cap);
return new ByteSize(bytes);
}
}
3.4 ObjectKey
The single most important VO in the service. It enforces the tenant prefix invariant.
export class ObjectKey {
private constructor(
public readonly tenantId: TenantId,
public readonly scope: Scope,
public readonly relPath: string, // not URL-encoded
) {}
static for(tenantId: TenantId, scope: Scope, relPath: string): ObjectKey {
if (relPath.startsWith('/') || relPath.includes('..')) {
throw new InvalidObjectKeyError(relPath);
}
return new ObjectKey(tenantId, scope, relPath);
}
toGcsKey(): string {
return `tenants/${this.tenantId}/${this.scope}/${this.relPath}`;
}
}
Any code path that constructs a GCS request must consume an ObjectKey. The service has exactly one place that converts ObjectKey → GCS key string — the GCS adapter — and a unit + integration test pair that verifies the resulting key always starts with tenants/<tenantId>/.
3.5 RetentionClass and RetentionPolicy (VO form)
export type RetentionClass = 'operational' | 'regulated' | 'audit';
export class RetentionPolicyName {
private constructor(public readonly name: string) {}
static from(name: string): RetentionPolicyName {
if (!CanonicalPolicyNames.has(name)) throw new UnknownRetentionPolicyError(name);
return new RetentionPolicyName(name);
}
}
export const CanonicalPolicyNames = new Set([
'default', // 90 d archive then purge
'pii_id_scan', // 30 d (AF) – per-jurisdiction override
'tax_compliance', // 7 y (default; per-jurisdiction override)
'vendor_lock_report', // 12 m
'theme_asset', // until-superseded
'invoice_pdf', // matches tax_compliance
'short_lived_attachment', // 30 d
]);
3.6 DataClass
export type DataClass = 'public_media' | 'private' | 'archive';
// public_media → fronted by Cloud CDN, signed URL with long TTL OK
// private → never CDN-fronted, short signed URLs only, CMEK at rest
// archive → cold storage class, hard-delete only via retention sweeper
3.7 AIProvenance
Reused from @ghasi/contracts-melmastoon. Stamped on any FileObject whose altText, tags, safetyVerdict, or redactionMap was derived from an AI call. See AI_INTEGRATION.
4. Aggregate: FileObject
The principal aggregate. State machine:
┌──────────────┐
│ initiated │ ← initiate-upload returns signed URL; no bytes yet
└──────┬───────┘
│ confirm-upload (bytes verified, hash matches)
▼
┌──────────────┐
│ uploaded │
└──────┬───────┘
│ scan dispatched automatically
▼
┌──────────────┐
│ scanning │
└──┬─────┬─────┘
passed │ │ failed
▼ ▼
┌────────────┐ ┌──────────────┐
│ ready │ │ quarantined │
└────┬───────┘ └──────┬───────┘
│ delete (soft) │ purge after 30 d
▼ ▼
┌────────────┐ ┌──────────────┐
│ archived │ │ purged │
└────┬───────┘ └──────────────┘
restore │ ▲
▼ │ retention sweep / erasure cascade
┌────────────┐ │
│ ready │ │
└────────────┘ │
│
hard-delete (retention expired or erasure)
export class FileObject {
private constructor(
public readonly id: FileObjectId,
public readonly tenantId: TenantId,
public readonly objectKey: ObjectKey,
public readonly bucketId: BucketId,
public readonly scope: Scope,
public readonly dataClass: DataClass,
public readonly retentionPolicyName: RetentionPolicyName,
private _status: FileStatus,
private _contentType: ContentType,
private _byteSize: ByteSize | null, // null until confirmed
private _sha256: Sha256 | null, // null until confirmed
private _ownerActor: ActorRef,
private _ownerScopeRefs: OwnerScopeRefs, // e.g. { propertyId, photoId } | { reservationId, guestId } | { invoiceId }
private _aiProvenance: AIProvenance | null,
private _altText: I18nString | null,
private _tags: string[],
private _aliasOfFileId: FileObjectId | null, // dedupe alias
public readonly createdAt: Date,
private _updatedAt: Date,
private _version: number,
private _events: DomainEvent[] = [],
) {}
static initiate(input: {
id: FileObjectId;
tenantId: TenantId;
objectKey: ObjectKey;
bucketId: BucketId;
scope: Scope;
dataClass: DataClass;
retentionPolicyName: RetentionPolicyName;
contentType: ContentType;
ownerActor: ActorRef;
ownerScopeRefs: OwnerScopeRefs;
now: Date;
}): FileObject {
if (input.objectKey.tenantId !== input.tenantId) {
throw new CrossTenantReferenceError('objectKey.tenantId !== tenantId');
}
const f = new FileObject(
input.id, input.tenantId, input.objectKey, input.bucketId,
input.scope, input.dataClass, input.retentionPolicyName,
'initiated', input.contentType, null, null,
input.ownerActor, input.ownerScopeRefs,
null, null, [], null,
input.now, input.now, 1, [],
);
f._raise(new FileInitiatedEvent({ /* … */ }));
return f;
}
confirmUpload(input: { byteSize: ByteSize; sha256: Sha256; now: Date }): void {
this._mustBe('initiated');
this._byteSize = input.byteSize;
this._sha256 = input.sha256;
this._status = 'uploaded';
this._touch(input.now);
this._raise(new FileUploadedEvent({ /* … */ }));
}
beginScan(now: Date): void {
this._mustBe('uploaded');
this._status = 'scanning';
this._touch(now);
}
recordScanPassed(scanResultId: ScanResultId, now: Date): void {
this._mustBe('scanning');
this._status = 'ready';
this._touch(now);
this._raise(new FileScanPassedEvent({ /* … */ }));
}
recordScanFailed(scanResultId: ScanResultId, reason: string, now: Date): void {
this._mustBe('scanning');
this._status = 'quarantined';
this._touch(now);
this._raise(new FileScanFailedEvent({ /* … */ }));
}
attachVariant(variant: Variant): void {
if (variant.fileObjectId !== this.id) throw new VariantOwnershipError();
this._raise(new FileOptimizationCompletedEvent({ /* … */ }));
}
softDelete(actor: ActorRef, now: Date): void {
if (this._status === 'archived' || this._status === 'purged') return;
this._status = 'archived';
this._touch(now);
this._raise(new FileDeletedEvent({ soft: true /* … */ }));
}
restore(actor: ActorRef, now: Date): void {
this._mustBe('archived');
this._status = 'ready';
this._touch(now);
}
hardPurge(reason: 'retention_expired' | 'erasure', now: Date): void {
if (this._status === 'purged') return;
this._status = 'purged';
this._touch(now);
this._raise(reason === 'erasure'
? new FileErasureCompletedEvent({ /* … */ })
: new FileRetentionExpiredEvent({ /* … */ }));
}
attachAIProvenance(prov: AIProvenance): void { this._aiProvenance = prov; }
applyAIAltText(text: I18nString): void { this._altText = text; }
applyAITags(tags: string[]): void { this._tags = [...new Set([...this._tags, ...tags])]; }
asAliasOf(other: FileObjectId): void {
if (this._status !== 'initiated') throw new IllegalAliasStateError();
this._aliasOfFileId = other;
}
// accessors omitted for brevity
pullEvents(): DomainEvent[] { const e = this._events; this._events = []; return e; }
private _mustBe(s: FileStatus): void {
if (this._status !== s) throw new InvalidStateTransitionError(this._status, s);
}
private _touch(now: Date): void { this._updatedAt = now; this._version += 1; }
private _raise(e: DomainEvent): void { this._events.push(e); }
}
Invariants
- Tenant prefix invariant.
objectKey.tenantId === tenantIdat construction; enforced again on any operation that constructs a GCS request. - Hash on confirm. A file cannot leave
initiatedwithout a SHA-256 + bytes count. - No download before ready.
issueDownloadUrlrejects withMELMASTOON.FILE.SCAN_PENDINGfor any status other thanready. - No restore after purge.
purgedis terminal. - Quarantine is sticky. Only an explicit
override-quarantineuse case (security-reviewer signoff) can move a file out ofquarantined, and only intoarchived(notready). - One alias hop max. A
FileObjectcannot alias another alias; the application use case resolves to the canonical row before storing the alias pointer. - Per-MIME validation. Scope ↔ allowed MIME table enforced via
ContentType.from(value, scope).
5. Aggregate: UploadSession
export class UploadSession {
private constructor(
public readonly id: UploadSessionId,
public readonly tenantId: TenantId,
public readonly fileObjectId: FileObjectId,
public readonly objectKey: ObjectKey,
public readonly signedUrl: SignedUrl,
public readonly expiresAt: Date,
public readonly resumable: boolean,
public readonly chunkSizeBytes: number,
private _status: UploadSessionStatus,
private _bytesReceived: number,
public readonly createdAt: Date,
private _updatedAt: Date,
) {}
// 'open' → 'completed' | 'expired' | 'aborted'
}
Invariants:
- A session is bound to exactly one
FileObjectininitiatedstatus. expiresAt≤createdAt + 1 h(upload TTL).- Chunk size ≥ 8 MiB for resumable uploads.
6. Entity: Variant
export class Variant {
private constructor(
public readonly id: VariantId,
public readonly tenantId: TenantId,
public readonly fileObjectId: FileObjectId,
public readonly preset: VariantPreset, // 'thumb' | 'hero' | 'full' | 'avif_thumb' | 'avif_hero' | 'avif_full' | 'hls_720p' (Phase 3)
public readonly objectKey: ObjectKey,
public readonly contentType: ContentType,
public readonly bytes: number,
public readonly widthPx: number | null,
public readonly heightPx: number | null,
public readonly status: 'pending' | 'ready' | 'failed',
public readonly createdAt: Date,
) {}
}
Variants are immutable once ready. A re-optimization (e.g., on policy change) creates a new variant row and supersedes the previous one; the old GCS object is garbage-collected by the retention sweeper.
7. Entity: ScanResult
export class ScanResult {
private constructor(
public readonly id: ScanResultId,
public readonly tenantId: TenantId,
public readonly fileObjectId: FileObjectId,
public readonly scanner: 'clamav' | 'cloud_dlp',
public readonly verdict: ScanVerdict, // 'passed' | 'failed' | 'inconclusive'
public readonly threats: string[],
public readonly scannedAt: Date,
public readonly engineVersion: string,
public readonly definitionsVersion: string | null,
) {}
}
inconclusive is treated as failed for read gating; manual override may reclassify.
8. Aggregate: Bucket (logical)
export class Bucket {
private constructor(
public readonly id: BucketId,
public readonly name: string, // e.g. 'media' | 'private' | 'archive'
public readonly gcsBucket: string, // 'melmastoon-media-prod'
public readonly dataClass: DataClass,
public readonly cdnEnabled: boolean,
public readonly cmekKeyResource: string | null,
public readonly defaultRetention: RetentionPolicyName,
) {}
}
Buckets are platform-level, not per-tenant. Tenant isolation is achieved by mandatory prefix, not by bucket. The mapping is fixed at deploy time.
9. Aggregate: RetentionPolicy
export class RetentionPolicy {
private constructor(
public readonly id: RetentionPolicyId,
public readonly tenantId: TenantId | null, // null = platform default
public readonly name: RetentionPolicyName,
public readonly retentionClass: RetentionClass,
public readonly minRetentionDays: number,
public readonly maxRetentionDays: number | null,
public readonly redactionAfterDays: number | null,
public readonly hardDeleteAfterDays: number,
public readonly jurisdiction: string | null, // ISO 3166-1 alpha-2 or null = global
public readonly active: boolean,
) {}
}
A tenant-scoped policy supersedes the platform default for that tenant. Jurisdiction-specific overrides for a single name (e.g., pii_id_scan@AF=30d, pii_id_scan@FR=90d) are resolved by the application layer at upload time using tenant.jurisdiction.
10. Entity: AccessGrant
export class AccessGrant {
private constructor(
public readonly id: AccessGrantId,
public readonly tenantId: TenantId,
public readonly fileObjectId: FileObjectId,
public readonly actor: ActorRef,
public readonly purpose: 'view' | 'download' | 'attach' | 'embed',
public readonly issuedAt: Date,
public readonly expiresAt: Date,
public readonly callerIp: string | null,
public readonly callerUserAgent: string | null,
public readonly signatureFingerprint: string,
private _revokedAt: Date | null,
) {}
revoke(now: Date): void {
if (this._revokedAt) return;
this._revokedAt = now;
}
}
Audit grain: one row per signed URL issued. The signature fingerprint is the SHA-256 of the URL Signature query parameter and is the key used to revoke a leaked URL via Redis blacklist.
11. Domain Errors
| Error | Maps to |
|---|---|
InvalidIdError | MELMASTOON.GENERAL.VALIDATION_FAILED |
InvalidObjectKeyError | MELMASTOON.GENERAL.VALIDATION_FAILED |
CrossTenantReferenceError | MELMASTOON.GENERAL.CROSS_TENANT_REFERENCE |
ContentTypeNotAllowedError | MELMASTOON.FILE.CONTENT_TYPE_NOT_ALLOWED |
ScopeByteCapExceededError | MELMASTOON.FILE.OBJECT_TOO_LARGE |
InvalidStateTransitionError | MELMASTOON.FILE.INVALID_STATE_TRANSITION |
UnknownRetentionPolicyError | MELMASTOON.FILE.RETENTION_POLICY_UNKNOWN |
VariantOwnershipError | MELMASTOON.GENERAL.INTERNAL (invariant violation; page on-call) |
IllegalAliasStateError | MELMASTOON.FILE.ALIAS_NOT_ALLOWED |
QuotaExceededError | MELMASTOON.FILE.QUOTA_EXCEEDED |
ScanPendingError | MELMASTOON.FILE.SCAN_PENDING |
QuarantinedReadError | MELMASTOON.FILE.QUARANTINED |
RetentionViolatedError | MELMASTOON.FILE.RETENTION_LOCK |
All MELMASTOON.FILE.* codes are added to ERROR_CODES.md in the same PR that ships this service.
12. Domain Events (names; payloads in EVENT_SCHEMAS)
| Event | Aggregate | Trigger |
|---|---|---|
melmastoon.file.upload.initiated.v1 | FileObject | FileObject.initiate() |
melmastoon.file.upload.completed.v1 | FileObject | FileObject.confirmUpload() |
melmastoon.file.upload.failed.v1 | FileObject / UploadSession | abort or expiry |
melmastoon.file.scan.requested.v1 | FileObject | beginScan() |
melmastoon.file.scan.passed.v1 | FileObject | recordScanPassed() |
melmastoon.file.scan.failed.v1 | FileObject | recordScanFailed() |
melmastoon.file.optimization.completed.v1 | FileObject | attachVariant() after the last preset finishes |
melmastoon.file.deleted.v1 | FileObject | softDelete() |
melmastoon.file.access.denied.v1 | AccessGrant | denied issuance or denied access (cross-tenant, revoked, expired) |
melmastoon.file.retention.expired.v1 | FileObject | sweeper hard-deletes |
melmastoon.file.erasure.completed.v1 | FileObject | erasure sweep hard-deletes |
melmastoon.file.bucket.quota_warning.v1 | Bucket (per-tenant projection) | quota crossing 80 % / 95 % |
13. Test Anchors (referenced by TESTING_STRATEGY)
| Test | Where |
|---|---|
ObjectKey.toGcsKey always starts with tenants/<tenantId>/ | domain/__builders__/object-key.spec.ts |
FileObject.initiate rejects objectKey.tenantId !== tenantId | domain/file-object/file-object.spec.ts |
FSM rejects confirmUpload from any non-initiated status | same |
FSM rejects recordScanPassed on a quarantined file | same |
ContentType.from rejects application/x-msdownload for any scope | domain/file-object/content-type.spec.ts |
Quarantined FileObject.issueDownloadUrl raises QuarantinedReadError | application use case spec |
| Aliasing two-deep is rejected | application/use-cases/confirm-upload.spec.ts |