ADR-009: Digital Product Fulfillment System Architecture
Date: 2025-10-18 Status: ACCEPTED Stakeholders: John McMahon (CTO/CEO), ValkyrAI Core Team Relates To: PR #35 (feat(core): rc wip), ValkyrAI v0.6.0 BETA
Context
ValkyrAI required a complete end-to-end digital product delivery system to enable:
- Uploading digital assets (e-books, PDFs, software)
- Creating downloadable products tied to file storage
- Automated fulfillment workflows on order completion
- Row-level access control via ACL enforcement
- Token-based secure downloads with usage tracking
The challenge: integrate cleanly with existing Product, SalesOrder, Invoice, FileRecord, Workflow, and ACL models without duplicating auto-generated fields or violating THORAPI's "minimal duplication" golden rule.
Design Goals
- Modularity: Minimal new models; composition via UUID references
- THORAPI Compliance: No re-creation of auto-generated fields (id, createdDate, owner_id, etc.)
- E2E Cohesion: Link files → products → orders → fulfillment → ACL → downloads
- Security: Token validation, download limits, revocation support
- Workflow Integration: ValkyrAI ExecModules for async fulfillment automation
Solution: Four-Model Extension Architecture
1. DigitalAsset (New)
Purpose: Immutable record linking FileRecord to Product for delivery metadata.
ProductId (UUID ref) ──→ Product (enum type="download")
FileId (UUID ref) ────→ FileRecord (checksumSha256, virusScanStatus=CLEAN)
deliveryMethod ── ───→ enum: direct_download, email_delivery, portal_access, streaming, api_key
accessModel ────────→ enum: perpetual, subscription, trial, license_key, one_time
maxDownloads ───────→ int (-1 = unlimited)
expiresAfterDays ───→ int (-1 = never)
notifyCustomerOnExpiry → boolean
Rationale: Separates fulfillment metadata from Product (keeps Product generic). Single responsibility: define how a file is deliverable.
2. DownloadAccess (New)
Purpose: Row-level ACL permission grant for Principal to download DigitalAsset.
DigitalAssetId (UUID ref) ──→ DigitalAsset
PrincipalId (UUID ref) ─────→ Principal (customer/user)
SalesOrderLineItemId (UUID ref) ─→ LineItem (audit: which purchase unlocked this?)
downloadToken (UUID, x-thorapi-secureField) ─ secure, regenerable
downloadCount ──────────── int (incremented on each download)
maxDownloadsRemaining ───── int (-1 = unlimited, enforced on download)
grantedAt ──────────────── timestamp (auto-filled on creation)
expiresAt ──────────────── timestamp | null (null = never expires)
lastDownloadedAt ───────── timestamp | null
revokedAt ──────────────── timestamp | null (soft-delete)
revokedReason ──────────── string | null (e.g., "refund", "chargeback", "policy_violation")
Rationale: Represents the fulfillment outcome—customer's earned right to download. Backed by Spring ACL (ValkyrAclService grants READ permission). Token is cryptographically secure and regenerable per request.
3. OrderFulfillmentTask (New)
Purpose: Tracks fulfillment action state on order/line item; drives workflow execution.
SalesOrderId (UUID ref) ────→ SalesOrder (which order to fulfill?)
FulfillmentType (enum) ─────→ digital_delivery, physical_shipment, service_activation, etc.
Status (enum) ────── ────────→ pending, in_progress, completed, failed, canceled
WorkflowId (UUID ref, nullable) → Workflow (which ValkyrAI workflow executes this?)
AssignedTo (UUID, nullable) ─→ Principal (for manual fulfillment routing)
Attempts ──────────────────→ int (retry counter)
LastError ─────────────────→ string | null (error from failed attempt)
CompletedAt ───────────────→ timestamp | null (when successfully finished)
Metadata ──────────────────→ object (flexible: shipping address, tracking ID, email status, etc.)
Rationale: Separates fulfillment lifecycle from order state. Enables:
- Multi-fulfillment-type support (digital vs. physical in same order)
- Workflow orchestration (async vs. sync)
- Audit trail (attempts, errors, completion time)
- Manual intervention flow (assignedTo)
4. ProductDeliveryConfig (New)
Purpose: Per-product rules for fulfillment automation and delivery strategy.
ProductId (UUID ref) ──────────→ Product (this config applies to which product?)
DeliveryType (enum) ───────────→ instant_digital, scheduled_digital, manual_review, physical_shipment, hybrid
AutoFulfill (boolean, default=true) → auto-trigger workflow on payment?
FulfillmentWorkflowId (UUID ref, nullable) ─→ Workflow (if null, use default for deliveryType)
NotificationTemplate (string, nullable) ─→ ContentData slug for email/SMS (rendered with download link)
MaxConcurrentFulfillments (int, default=10) → throttle parallel task execution
RetryPolicy (object) ────────→ flexible: maxAttempts, backoffMultiplier, etc.
Rationale: Centralizes product-level fulfillment rules. Enables:
- Different workflows per product (custom logic, integrations)
- Email template selection (per-product branding)
- Throttling & retry strategy per product type
- Easy future extensions (e.g., payment gateway routing)
Integration Points
Product ↔ DigitalAsset
- Product.type enum already includes "download" ✅
- DigitalAsset.productId creates 1:1 link
- Product can have zero or one DigitalAsset
FileRecord ↔ DigitalAsset
- DigitalAsset.fileId references FileRecord
- FileRecord already provides: checksumSha256, virusScanStatus, storageKey, mimeType
- DigitalAsset assumes FileRecord.virusScanStatus = "CLEAN" before grant
SalesOrder ↔ OrderFulfillmentTask
- One OrderFulfillmentTask per fulfillable LineItem
- Triggered when SalesOrder.status moves to "pending" (payment confirmed)
- Created by fulfillment service:
fulfillmentService.createFulfillmentTask(order, lineItem, "digital_delivery")
OrderFulfillmentTask ↔ DownloadAccess
- On OrderFulfillmentTask.complete() with status="completed":
- Loop through LineItems with Product.type="download"
- Create DownloadAccess for each (Principal=order.customerId)
- Grant ACL READ permission via ValkyrAclService.grantPermission()
- Implemented in
DigitalFulfillmentModule.execute()
DownloadAccess ↔ Spring ACL
- Each DownloadAccess is an ACL-protected object
- Customer (Principal) has READ permission on DownloadAccess
- Enforced at REST endpoint level via
@Secured("ROLE_CUSTOMER")+ ACL check - Download endpoint validates: token match + expiry + limit + revocation status
ProductDeliveryConfig ↔ Workflow
- Links Product to Workflow for fulfillment automation
- If auto-fulfil enabled: OrderFulfillmentTask created in-progress, workflow triggered immediately
- Workflow contains tasks: DigitalFulfillmentModule → SendDownloadEmailModule → etc.
REST Endpoint Architecture
Non-CRUD Endpoints (Hand-Written)
POST /Product/{productId}/createDigitalAsset
- Body:
{ fileId, deliveryMethod, accessModel, maxDownloads, expiresAfterDays } - Returns: DigitalAsset (201 Created)
- Validates: file is CLEAN, product exists, principal has ADMIN role
- Service call:
fulfillmentService.createDigitalAsset(...)
POST /Product/{productId}/generateDownloadLink
- Body:
{ validityMinutes: int (optional, default 60) } - Returns:
{"downloadLink": "https://.../DownloadAccess/{id}/file?token=<token>", "expiresAt": ..., "accessId": ...} - Creates ephemeral DownloadAccess record with expiry
- For use in email/UI distribution
GET /DownloadAccess/{accessId}/file?token=<token>
- Binary download endpoint
- Validates: token matches, expiry not reached, limit not exceeded, not revoked
- Side effects: increments downloadCount, updates lastDownloadedAt, decrements maxDownloadsRemaining
- Returns: 200 with octet-stream OR 403 if access denied
- Service call:
fulfillmentService.downloadFile(accessId, token, auth)
POST /OrderFulfillmentTask/{taskId}/complete
- Body:
{ status: "completed|failed|canceled", metadata: object } - Returns:
{ task: OrderFulfillmentTask, downloadAccess: DownloadAccess|null } - If digital_delivery + completed: creates DownloadAccess, grants ACL
- Service call:
fulfillmentService.completeFulfillmentTask(...)
GET /OrderFulfillmentTask/byLineItem/{lineItemId}
- Convenience query: fetch fulfillment task for a specific line item
- Returns: OrderFulfillmentTask
Codegen Strategy (THORAPI)
Step 1: OpenAPI Schema Addition
✅ Four new schemas appended to api.hbs.yaml (lines 7525+):
- DigitalAsset, DownloadAccess, OrderFulfillmentTask, ProductDeliveryConfig
- Non-CRUD endpoints in
paths/section
Step 2: ThorAPI Code Generation
- Run:
mvn clean installor./vai - Generated outputs:
- Models: DigitalAssetRepository, DownloadAccessRepository, OrderFulfillmentTaskRepository, ProductDeliveryConfigRepository (Spring Data)
- Services: Auto-generated CRUD services + controllers
- TypeScript Clients: RTK Query hooks (useGetDownloadAccessesQuery, useCompleteOrderFulfillmentTaskMutation, etc.)
Step 3: Hand-Written Business Logic
DigitalFulfillmentService.java: core fulfillment orchestration (not auto-generated)DigitalFulfillmentModule.java: VModule for workflow executionSendDownloadEmailModule.java: email delivery workflow step- Custom endpoints called by auto-generated controllers or custom routes
Workflow Module Implementation
DigitalFulfillmentModule (ExecModule)
Input:
{
"orderFulfillmentTaskId": "uuid",
"status": "completed|failed|canceled",
"metadata": { "fulfilledBy": "system", ... }
}
Output:
{
"success": true,
"message": "DownloadAccess granted successfully",
"downloadAccessId": "uuid"
}
Logic:
- Fetch OrderFulfillmentTask by ID
- Call
fulfillmentService.completeFulfillmentTask(taskId, status, metadata) - If digital_delivery + completed: returns DownloadAccess object
- Emit success + downloadAccessId for downstream modules
Registration: In workflow factory (e.g., OrderFulfillmentWorkflowFactory.java):
ExecModule DIGITAL_FULFILLMENT = module(
UUID.randomUUID(),
DigitalFulfillmentModule.class,
ExecModule.ModuleTypeEnum.WRITER,
40f, // order in palette
"Digital Fulfillment",
"Grant download access for completed digital product orders"
);
SendDownloadEmailModule (Future)
Input:
{
"downloadAccessId": "uuid",
"customerEmail": "user@example.com",
"templateSlug": "digital-delivery-email"
}
Logic:
- Fetch DownloadAccess + DigitalAsset + Product metadata
- Render email from ContentData template with download link
- Send via mail service
- Log event
ACL & Security Model
Object-Level Permissions
- DownloadAccess is ACL-protected
- When granted:
ValkyrAclService.grantPermission(accessId, DownloadAccess.class, customerId, BasePermission.READ) - Check before download:
if (!aclService.hasPermission(accessId, DownloadAccess.class, principal, BasePermission.READ)) throw AccessDeniedException
Field-Level Encryption
DownloadAccess.downloadTokenmarked withx-thorapi-secureField: true- Automatically encrypted at rest, decrypted in memory via AspectJ
Authentication & Authorization
- REST endpoints annotated:
@Secured("ROLE_CUSTOMER")(downloads),@Secured("ROLE_ADMIN")(create assets, configs) - Customer can only download if they have ACL READ on that DownloadAccess
- Admin can create, revoke, query all accesses
Audit Trail
- OrderFulfillmentTask.metadata stores context (fulfilling user, timestamp, etc.)
- DownloadAccess.lastDownloadedAt records each access
- AclEntry audit logs who granted/revoked permissions
Data Flow Diagram
┌─────────────────────────────────────────────────────────────────┐
│ DIGITAL E-BOOK FULFILLMENT │
└───────────────────────────────────────────────────────────── ────┘
1. FILE UPLOAD & ASSET CREATION
┌──────────────┐ ┌──────────────┐ ┌─────────────┐
│ FileRecord │────────→│DigitalAsset │────────→│ Product │
│ (checksums, │ │(metadata) │ │(type=dl) │
│ virusScan) │ │ │ │ │
└──────────────┘ └──────────────┘ └─────────────┘
2. PRODUCT CONFIGURATION
┌──────────────────────┐
│ProductDeliveryConfig │
│(autoFulfill, wf, etc)│
└────────────┬─────────┘
│
v
┌──────────────────────┐
│ Workflow Engine │
│(ValkyrAI) │
└──────────────────────┘
3. ORDER PLACEMENT & FULFILLMENT
┌──────────────┐ ┌──────────────────┐ ┌────────────┐
│ SalesOrder │────────→│OrderFulfillment │────────→│DownloadAccess
│(customer) │ │Task(pending) │ │(token) │
└──────────────┘ └──────────────────┘ └────┬─────── ┘
│ │
v v
┌──────────────────┐ ┌──────────────┐
│Workflow Execute: │ │ ACL Perm │
│DigitalFulfill │ │ (READ grant)│
│Module │ └──────────────┘
└──────────────────┘
4. DOWNLOAD
Customer: `GET /DownloadAccess/{id}/file?token=<token>`
↓
Backend: Validate token + expiry + limit + revocation status
↓
Backend: Increment downloadCount, update lastDownloadedAt
↓
Backend: Return file (200) or deny (403)
Error Handling & Edge Cases
Scenario: File Not Clean After Scan
createDigitalAsset()throwsDigitalFulfillmentException: "File has not passed virus scan or is infected: INFECTED"- Response: 400 Bad Request with error message
Scenario: Download Limit Exceeded
downloadFile()checks:if (maxDownloadsRemaining <= 0) throw...- Response: 403 Forbidden with "Download limit exceeded"
- Access remains accessible (limit not auto-revoked, manual action required)
Scenario: Access Expired
downloadFile()checks:if (Instant.now().isAfter(expiresAt)) throw...- Response: 403 Forbidden with "Access expired"
Scenario: Access Revoked (Refund)
- Admin calls:
fulfillmentService.revokeDownloadAccess(accessId, "refund") - Sets:
revokedAt = now(), revokedReason = "refund" - Subsequent downloads: 403 "Access revoked: refund"
Scenario: Token Mismatch
- Attacker tries:
GET /DownloadAccess/{id}/file?token=WRONG_TOKEN - Response: 400 "Invalid download token"
Scenario: Multiple Concurrent Downloads
- Same DownloadAccess token can be used concurrently (token not single-use by default)
- Each download independently decrements limit, records timestamp
- Race condition safe: database transaction ensures accurate counters
Testing Strategy
Unit Tests (Service Layer)
DigitalFulfillmentServiceTest.testCreateDigitalAsset(): validate file clean, product existsDigitalFulfillmentServiceTest.testDownloadLimitEnforcement(): counter decrement, revocationDigitalFulfillmentServiceTest.testGrantDownloadAccess(): ACL grant correctness
Integration Tests (E2E)
DigitalEbookFulfillmentE2ETest: 10-step flow from upload → download- Step 1: Upload file → FileRecord
- Step 2: Create product (type=download)
- Step 3: Create DigitalAsset
- Step 4: ProductDeliveryConfig
- Step 5: Place order (line item with e-book)
- Step 6: Payment confirmed → OrderFulfillmentTask created
- Step 7: Complete task → DownloadAccess granted
- Step 8: Generate download link
- Step 9: Customer downloads (token validation, count incremented)
- Step 10: Verify limit enforcement & revocation
Workflow Tests
- Mock ValkyrAI workflow execution
- Verify DigitalFulfillmentModule.execute() creates DownloadAccess
Future Enhancements
Phase 2: Advanced Features
- Scheduled Delivery: ProductDeliveryConfig.deliveryType="scheduled_digital" → deliver at specific time
- License Key Generation: Auto-generate per-download unique license keys (for software)
- Bandwidth Throttling: Limit concurrent downloads per customer
- Regional Restrictions: GeoIP-based access denial
- Download Analytics: Dashboard showing sales → fulfillment → download metrics
Phase 3: Payment Integration
- Link to Stripe/Paddle for subscription renewal triggers
- Auto-revoke on chargeback detection
- Tiered access (free tier: 1 download/month, premium: unlimited)
Phase 4: Multi-file Bundles
- Product containing multiple DigitalAssets
- Selective download (user picks which files to access)
Deployment Checklist
- Append schemas to
api.hbs.yaml✅ - Append endpoints to
api.hbs.yaml✅ - Run
mvn clean installto trigger codegen - Create DigitalFulfillmentService.java ✅
- Create DigitalFulfillmentModule.java ✅
- Create integration tests ✅
- Register module in workflow factory
- Create initial ProductDeliveryConfigs for existing digital products
- Configure email template in ContentData (digital-product-delivery-email)
- Deploy to staging, run E2E tests
- Deploy to production, monitor fulfillment success rate
References
- ValkyrAI Instructions:
.github/copilot-instructions.md(THORAPI rules, ExecModule pattern, ACL enforcement) - OpenAPI Spec:
valkyrai/src/main/resources/openapi/api.hbs.yaml(schemas + endpoints) - Related Models: Product, FileRecord, SalesOrder, LineItem, Principal, Workflow, AclEntry
- PR: #35 (feat(core): rc wip)
Sign-Off
- Proposed By: GitHub Copilot (Agent)
- Reviewed By: (Pending)
- Approved By: (Pending)
- Implementation Start: 2025-10-18
- Target Completion: 2025-10-25