Skip to main content

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

  1. Tenant prefix invariant. objectKey.tenantId === tenantId at construction; enforced again on any operation that constructs a GCS request.
  2. Hash on confirm. A file cannot leave initiated without a SHA-256 + bytes count.
  3. No download before ready. issueDownloadUrl rejects with MELMASTOON.FILE.SCAN_PENDING for any status other than ready.
  4. No restore after purge. purged is terminal.
  5. Quarantine is sticky. Only an explicit override-quarantine use case (security-reviewer signoff) can move a file out of quarantined, and only into archived (not ready).
  6. One alias hop max. A FileObject cannot alias another alias; the application use case resolves to the canonical row before storing the alias pointer.
  7. 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 FileObject in initiated status.
  • expiresAtcreatedAt + 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

ErrorMaps to
InvalidIdErrorMELMASTOON.GENERAL.VALIDATION_FAILED
InvalidObjectKeyErrorMELMASTOON.GENERAL.VALIDATION_FAILED
CrossTenantReferenceErrorMELMASTOON.GENERAL.CROSS_TENANT_REFERENCE
ContentTypeNotAllowedErrorMELMASTOON.FILE.CONTENT_TYPE_NOT_ALLOWED
ScopeByteCapExceededErrorMELMASTOON.FILE.OBJECT_TOO_LARGE
InvalidStateTransitionErrorMELMASTOON.FILE.INVALID_STATE_TRANSITION
UnknownRetentionPolicyErrorMELMASTOON.FILE.RETENTION_POLICY_UNKNOWN
VariantOwnershipErrorMELMASTOON.GENERAL.INTERNAL (invariant violation; page on-call)
IllegalAliasStateErrorMELMASTOON.FILE.ALIAS_NOT_ALLOWED
QuotaExceededErrorMELMASTOON.FILE.QUOTA_EXCEEDED
ScanPendingErrorMELMASTOON.FILE.SCAN_PENDING
QuarantinedReadErrorMELMASTOON.FILE.QUARANTINED
RetentionViolatedErrorMELMASTOON.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)

EventAggregateTrigger
melmastoon.file.upload.initiated.v1FileObjectFileObject.initiate()
melmastoon.file.upload.completed.v1FileObjectFileObject.confirmUpload()
melmastoon.file.upload.failed.v1FileObject / UploadSessionabort or expiry
melmastoon.file.scan.requested.v1FileObjectbeginScan()
melmastoon.file.scan.passed.v1FileObjectrecordScanPassed()
melmastoon.file.scan.failed.v1FileObjectrecordScanFailed()
melmastoon.file.optimization.completed.v1FileObjectattachVariant() after the last preset finishes
melmastoon.file.deleted.v1FileObjectsoftDelete()
melmastoon.file.access.denied.v1AccessGrantdenied issuance or denied access (cross-tenant, revoked, expired)
melmastoon.file.retention.expired.v1FileObjectsweeper hard-deletes
melmastoon.file.erasure.completed.v1FileObjecterasure sweep hard-deletes
melmastoon.file.bucket.quota_warning.v1Bucket (per-tenant projection)quota crossing 80 % / 95 %

13. Test Anchors (referenced by TESTING_STRATEGY)

TestWhere
ObjectKey.toGcsKey always starts with tenants/<tenantId>/domain/__builders__/object-key.spec.ts
FileObject.initiate rejects objectKey.tenantId !== tenantIddomain/file-object/file-object.spec.ts
FSM rejects confirmUpload from any non-initiated statussame
FSM rejects recordScanPassed on a quarantined filesame
ContentType.from rejects application/x-msdownload for any scopedomain/file-object/content-type.spec.ts
Quarantined FileObject.issueDownloadUrl raises QuarantinedReadErrorapplication use case spec
Aliasing two-deep is rejectedapplication/use-cases/confirm-upload.spec.ts