# Phase 40: Job Infrastructure - Research
**Researched:** 2026-04-04
**Domain:** Async job lifecycle, SSE streaming, storage namespacing, asset tracking
**Confidence:** HIGH
---
## User Constraints (from CONTEXT.md)
### Locked Decisions
All implementation choices are at Claude's discretion — discuss phase was skipped per user setting. Use ROADMAP phase goal, success criteria, and codebase conventions to guide decisions.
### Claude's Discretion
All implementation choices are at Claude's discretion.
### Deferred Ideas (OUT OF SCOPE)
None — discuss phase skipped.
---
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| INFRA-01 | System processes content generation jobs asynchronously with queued → running → done/failed lifecycle | New `content_jobs` table + in-process job runner service; pattern mirrors `heartbeat_runs` / `plugin_job_runs` already in codebase |
| INFRA-02 | System pushes job progress updates via SSE to connected clients | Existing `live-events.ts` EventEmitter + `LIVE_EVENT_TYPES` const; add new event types and a new SSE endpoint scoped to jobs |
| INFRA-03 | Generated content stored in namespaced storage without size restrictions blocking video/images | `StorageService.putFile()` already has no server-side byte limit; add `generated/` namespace + `MAX_GENERATED_ASSET_BYTES` constant bypassing the upload-route multer limit |
| INFRA-04 | All generated content tracked in database with source conversation linkage | Extend `assets` table with `source_task_id` column (nullable FK); assetService.create() gains optional `sourceTaskId` parameter |
---
## Summary
Phase 40 builds the async foundation that every subsequent content generation phase depends on. The codebase already has two well-established job-tracking patterns (`heartbeat_runs`, `plugin_job_runs`) and a working SSE streaming pattern in three routes (`voice.ts`, `puter-proxy.ts`, `plugins.ts`). The work here is additive: a new `content_jobs` DB table, a minimal in-process job runner, new live-event types for SSE progress, a `generated/` storage namespace with its own size constant, and a `source_task_id` column on `assets`.
No external queue infrastructure (Redis, BullMQ) is needed. The project is single-user and local-first. An in-process async runner (fire-and-forget `void Promise`) with EventEmitter fan-out — matching the heartbeat pattern — is the correct approach. If a job crashes the process restarts clean; orphan prevention is via `source_task_id` so consumers can audit.
**Primary recommendation:** Mirror the `heartbeat_runs` table/service pattern for the `content_jobs` table. Use the existing `publishLiveEvent` function with new `content_job.*` event types for SSE. Add `MAX_GENERATED_ASSET_BYTES` as a server-only constant and a `generated/` namespace prefix. Add `source_task_id` to `assets` via a migration.
---
## Standard Stack
### Core
| Library | Version (verified) | Purpose | Why Standard |
|---------|-------------------|---------|--------------|
| drizzle-orm | 0.38.4 (pkg) | Schema definition + query builder | Already used throughout; schema in `packages/db/src/schema/` |
| postgres (postgres.js) | project dep | DB connection | Already used via `createDb()` in `packages/db/src/client.ts` |
| express | project dep | HTTP layer for new routes | All routes are Express; follows existing pattern |
| Node.js EventEmitter | built-in | In-process pub/sub for SSE fan-out | Already used in `live-events.ts`; no extra dep |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| drizzle-kit | 0.31.9 (pkg) | Migration generation | Run `pnpm db:generate` after schema change |
| vitest | project dep | Unit + integration tests | All server tests use vitest; pattern in `server/src/__tests__/` |
| supertest | project dep | HTTP route tests | Used in `assets.test.ts`, `chat-routes.test.ts`, etc. |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| In-process EventEmitter runner | BullMQ / Redis | Redis adds infra complexity; single-user single-process — EventEmitter is correct here |
| In-process EventEmitter runner | Worker threads | Unnecessary isolation; jobs are I/O-bound (renders call child processes) |
| Custom SSE endpoint | WebSocket upgrade | SSE is simpler for one-way server → client push; WebSocket already used for live events via `live-events-ws.ts` — keep SSE for job polling per existing voice/puter patterns |
**Installation:** No new packages required. All tooling already present.
---
## Architecture Patterns
### Recommended Project Structure
```
packages/db/src/schema/
├── content_jobs.ts # NEW: content_jobs table definition
├── assets.ts # MODIFY: add source_task_id column
├── index.ts # MODIFY: export content_jobs
packages/db/src/migrations/
├── 0056_create_content_jobs.sql # NEW: generated via drizzle-kit
├── 0057_assets_source_task_id.sql # NEW: generated via drizzle-kit
packages/shared/src/
├── constants.ts # MODIFY: add content_job.* to LIVE_EVENT_TYPES
# add CONTENT_JOB_STATUSES constant
server/src/
├── services/
│ ├── content-job-store.ts # NEW: DB CRUD for content_jobs
│ ├── content-job-runner.ts # NEW: async executor + live-event publisher
│ └── index.ts # MODIFY: export new services
├── routes/
│ ├── content-jobs.ts # NEW: POST /companies/:id/content-jobs
│ │ # GET /companies/:id/content-jobs/:jobId
│ │ # GET /companies/:id/content-jobs/:jobId/events (SSE)
│ └── index.ts # MODIFY: mount content-job routes
├── app.ts # MODIFY: register routes
```
### Pattern 1: content_jobs Table (mirrors heartbeat_runs / plugin_job_runs)
**What:** A persisted lifecycle table for async content generation requests.
**When to use:** Any content generation work that may take >200ms.
```typescript
// packages/db/src/schema/content_jobs.ts
// Pattern source: packages/db/src/schema/heartbeat_runs.ts + plugin_jobs.ts
import { pgTable, uuid, text, timestamp, jsonb, index } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
// Status lifecycle: queued → running → done | failed
export const CONTENT_JOB_STATUSES = ["queued", "running", "done", "failed"] as const;
export type ContentJobStatus = (typeof CONTENT_JOB_STATUSES)[number];
export const contentJobs = pgTable(
"content_jobs",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
jobType: text("job_type").notNull(), // e.g. "diagram", "theme", "video"
status: text("status").$type().notNull().default("queued"),
input: jsonb("input").notNull().default({}), // renderer-specific params
resultAssetId: uuid("result_asset_id"), // populated on done
errorMessage: text("error_message"), // populated on failed
sourceTaskId: text("source_task_id"), // conversation task linkage (INFRA-04)
startedAt: timestamp("started_at", { withTimezone: true }),
finishedAt: timestamp("finished_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyStatusIdx: index("content_jobs_company_status_idx").on(table.companyId, table.status),
companyCreatedIdx: index("content_jobs_company_created_idx").on(table.companyId, table.createdAt),
}),
);
```
### Pattern 2: HTTP 202 + Job ID Response
**What:** POST to submit a job returns immediately with jobId.
**When to use:** All content generation submissions.
```typescript
// server/src/routes/content-jobs.ts
router.post("/companies/:companyId/content-jobs", async (req, res) => {
assertCompanyAccess(req, companyId);
const job = await contentJobStore.create(companyId, {
jobType: req.body.jobType,
input: req.body.input ?? {},
sourceTaskId: req.body.sourceTaskId ?? null,
});
// Fire and forget — never await the runner here
void contentJobRunner.dispatch(job);
res.status(202).json({ jobId: job.id, status: job.status });
});
```
### Pattern 3: SSE Job Progress (mirrors voice.ts pattern)
**What:** GET endpoint that holds connection open and pushes events until terminal state.
**When to use:** Browser polls for job progress without polling.
```typescript
// server/src/routes/content-jobs.ts
router.get("/companies/:companyId/content-jobs/:jobId/events", async (req, res) => {
assertCompanyAccess(req, companyId);
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.flushHeaders();
const sendEvent = (type: string, data: unknown) => {
res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
};
// Send current state immediately
const job = await contentJobStore.getById(jobId);
sendEvent("status", { jobId, status: job.status });
if (job.status === "done" || job.status === "failed") {
res.end();
return;
}
// Subscribe to live events for this job
const unsubscribe = subscribeCompanyLiveEvents(companyId, (event) => {
if (event.type === "content_job.status" && event.payload.jobId === jobId) {
sendEvent("status", event.payload);
if (event.payload.status === "done" || event.payload.status === "failed") {
unsubscribe();
res.end();
}
}
});
req.on("close", () => {
unsubscribe();
});
});
```
### Pattern 4: Live Event Types for Job Progress
**What:** Extend LIVE_EVENT_TYPES in shared constants.
**When to use:** Publishing job progress from the runner.
```typescript
// packages/shared/src/constants.ts — add to LIVE_EVENT_TYPES array:
"content_job.queued",
"content_job.running",
"content_job.done",
"content_job.failed",
```
### Pattern 5: Generated Asset Storage Namespace
**What:** `generated/` namespace bypasses upload-route multer limit.
**When to use:** Writing rendered output (video, SVG, PDF, PNG) from job runner.
```typescript
// server/src/attachment-types.ts — add alongside MAX_ATTACHMENT_BYTES:
export const MAX_GENERATED_ASSET_BYTES =
Number(process.env.PAPERCLIP_GENERATED_ASSET_MAX_BYTES) || 500 * 1024 * 1024; // 500MB default
// Job runner stores via:
const stored = await storage.putFile({
companyId,
namespace: "generated", // bypasses upload limit — this is not from multer
originalFilename: outputFilename,
contentType,
body: outputBuffer, // renderer output, no multer involved
});
```
**Key insight:** The upload route (`assets.ts`) enforces limits via multer `limits: { fileSize: MAX_ATTACHMENT_BYTES }`. Job runners write directly to `storage.putFile()` — multer is never involved. The `MAX_GENERATED_ASSET_BYTES` constant exists for the job runner to validate before calling `putFile`, but `putFile` itself has no byte limit.
### Pattern 6: source_task_id on assets (INFRA-04)
**What:** Nullable column added to `assets` table.
**When to use:** Every asset created by a job runner must pass `sourceTaskId`.
```typescript
// packages/db/src/schema/assets.ts — add column:
sourceTaskId: text("source_task_id"), // nullable, no FK — task IDs are string identifiers
// assetService.create() in server/src/services/assets.ts accepts it through
// the existing spread pattern: db.insert(assets).values({ ...data, companyId })
// since the column is nullable, no callers break.
```
### Pattern 7: content-job-store.ts (service layer)
**What:** CRUD service for content_jobs table.
**When to use:** Create and update jobs from routes + runner.
```typescript
// server/src/services/content-job-store.ts
// Pattern source: server/src/services/assets.ts, heartbeat.ts
export function contentJobStore(db: Db) {
return {
create: (companyId: string, data: { jobType: string; input: Record; sourceTaskId: string | null }) =>
db.insert(contentJobs).values({ companyId, ...data }).returning().then((r) => r[0]),
getById: (id: string) =>
db.select().from(contentJobs).where(eq(contentJobs.id, id)).then((r) => r[0] ?? null),
listByCompany: (companyId: string) =>
db.select().from(contentJobs)
.where(eq(contentJobs.companyId, companyId))
.orderBy(desc(contentJobs.createdAt)),
transition: (id: string, patch: Partial) =>
db.update(contentJobs).set({ ...patch, updatedAt: new Date() }).where(eq(contentJobs.id, id)),
};
}
```
### Anti-Patterns to Avoid
- **Awaiting render in HTTP handler:** Never `await renderer.run()` in the route handler. Always `void dispatch(job)` and return 202.
- **Using multer for generated asset storage:** Job runners call `storage.putFile()` directly; multer is only for user uploads.
- **Hardcoding status strings:** Always use the typed `CONTENT_JOB_STATUSES` constant from shared, not raw strings.
- **Blocking SSE on DB polling:** SSE endpoint subscribes to EventEmitter via `subscribeCompanyLiveEvents`, not a polling loop.
- **Missing `source_task_id` in job creation:** Every job submission should pass `sourceTaskId` from the incoming request (even if null for now); the column prevents future orphan accumulation.
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| In-process pub/sub for SSE | Custom EventEmitter wrapper | `publishLiveEvent` + `subscribeCompanyLiveEvents` from `live-events.ts` | Already handles multi-company scoping, id sequencing |
| Storage path generation | Custom UUID + date path builder | `StorageService.putFile()` via `service.ts` | Already handles namespace normalization, sha256, objectKey construction |
| Migration execution | Custom SQL runner | `pnpm db:generate` then `pnpm db:migrate` | Existing drizzle-kit + custom migration runner in `client.ts` |
| HTTP route mounting | Ad-hoc app.use() | Follow `app.ts` pattern: create router fn, import in app.ts | Consistent middleware application (auth, actor, etc.) |
| Asset DB record | Custom insert | `assetService(db).create()` | Already handles the full asset shape |
---
## Common Pitfalls
### Pitfall 1: Forgetting the "generated" namespace bypass logic lives in the runner, not the route
**What goes wrong:** Developer adds byte-limit check in the new content-job route handler (treating it like the assets upload route), rejecting large files before they're even rendered.
**Why it happens:** The upload route pattern uses multer limits; the developer copies that pattern.
**How to avoid:** The content-jobs route only creates the job record (tiny JSON). The runner calls `storage.putFile()` directly — no multer anywhere. The size check (`MAX_GENERATED_ASSET_BYTES`) belongs in the job runner, after rendering, before writing.
**Warning signs:** Any import of `multer` in `content-jobs.ts` or `content-job-runner.ts`.
### Pitfall 2: SSE connection leaking if client disconnects mid-job
**What goes wrong:** Client disconnects; the `unsubscribe` callback is never called; the EventEmitter listener accumulates. With many requests this can trigger Node's MaxListeners warning.
**Why it happens:** SSE streams need explicit `req.on("close")` cleanup.
**How to avoid:** Always register `req.on("close", () => { unsubscribe(); })` in every SSE handler. See `live-events-ws.ts` `cleanupByClient` pattern.
**Warning signs:** MaxListeners exceeded warning in server logs.
### Pitfall 3: Publishing live events before the DB row is committed
**What goes wrong:** Browser receives `content_job.done` event, queries `/content-jobs/:id`, gets stale data (or 404 if the row hasn't flushed).
**Why it happens:** `publishLiveEvent` is synchronous; the DB write is async.
**How to avoid:** Always `await db.update(...)` before calling `publishLiveEvent(...)` in the job runner.
**Warning signs:** Frontend shows "done" but API returns "running".
### Pitfall 4: Migration numbering collision
**What goes wrong:** Two migrations are created with the same prefix (e.g., `0056_`) and drizzle-kit fails to apply them in order.
**Why it happens:** Parallel development or rebasing creates numbering conflicts.
**How to avoid:** Check `ls packages/db/src/migrations/` before running `pnpm db:generate`. The last file is currently `0055_create_push_subscriptions.sql`. New migrations start at `0056_`.
**Warning signs:** `pnpm db:generate` creates a file with a duplicate number.
### Pitfall 5: Forgetting to export from schema/index.ts and services/index.ts
**What goes wrong:** TypeScript compiles but runtime throws "cannot find module" or imports return undefined.
**Why it happens:** The project uses explicit barrel exports; tree-shaking won't auto-discover.
**How to avoid:** After adding `content_jobs.ts` schema and `content-job-store.ts` service, immediately add exports to `packages/db/src/schema/index.ts` and `server/src/services/index.ts`.
### Pitfall 6: Adding content_job.* event types in the wrong place
**What goes wrong:** `publishLiveEvent({ type: "content_job.running" })` throws a TypeScript error because the type is not in `LIVE_EVENT_TYPES`.
**Why it happens:** `LiveEventType` is derived from `LIVE_EVENT_TYPES as const` in `packages/shared/src/constants.ts`.
**How to avoid:** Add the four new types (`content_job.queued`, `content_job.running`, `content_job.done`, `content_job.failed`) to the `LIVE_EVENT_TYPES` array in `constants.ts` before writing the runner.
---
## Code Examples
### Submitting a Job and Returning 202
```typescript
// Source: voice.ts (sync return), assets.ts (storage pattern) — combined
router.post("/companies/:companyId/content-jobs", async (req, res) => {
const companyId = req.params.companyId;
assertCompanyAccess(req, companyId);
const { jobType, input, sourceTaskId } = req.body as {
jobType: string;
input?: Record;
sourceTaskId?: string;
};
const store = contentJobStore(db);
const job = await store.create(companyId, {
jobType,
input: input ?? {},
sourceTaskId: sourceTaskId ?? null,
});
void contentJobRunner.dispatch(db, storage, job); // fire and forget
res.status(202).json({
jobId: job.id,
status: job.status,
createdAt: job.createdAt,
});
});
```
### Publishing Progress from the Runner
```typescript
// Source: live-events.ts publishLiveEvent pattern
async function runJob(db: Db, storage: StorageService, job: ContentJob) {
const store = contentJobStore(db);
// Transition to running
await store.transition(job.id, { status: "running", startedAt: new Date() });
publishLiveEvent({
companyId: job.companyId,
type: "content_job.running",
payload: { jobId: job.id },
});
try {
const result = await renderContent(job.jobType, job.input);
// Store asset
const stored = await storage.putFile({
companyId: job.companyId,
namespace: "generated",
originalFilename: result.filename,
contentType: result.contentType,
body: result.buffer,
});
const asset = await assetService(db).create(job.companyId, {
...stored,
sourceTaskId: job.sourceTaskId,
createdByAgentId: null,
createdByUserId: null,
});
// Transition to done
await store.transition(job.id, { status: "done", resultAssetId: asset.id, finishedAt: new Date() });
publishLiveEvent({
companyId: job.companyId,
type: "content_job.done",
payload: { jobId: job.id, assetId: asset.id },
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Unknown error";
await store.transition(job.id, { status: "failed", errorMessage, finishedAt: new Date() });
publishLiveEvent({
companyId: job.companyId,
type: "content_job.failed",
payload: { jobId: job.id, errorMessage },
});
}
}
```
### Migration for content_jobs table
```sql
-- packages/db/src/migrations/0056_create_content_jobs.sql
-- (generated by drizzle-kit; shown for reference)
CREATE TABLE IF NOT EXISTS "content_jobs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL REFERENCES "companies"("id"),
"job_type" text NOT NULL,
"status" text NOT NULL DEFAULT 'queued',
"input" jsonb NOT NULL DEFAULT '{}',
"result_asset_id" uuid,
"error_message" text,
"source_task_id" text,
"started_at" timestamp with time zone,
"finished_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE INDEX IF NOT EXISTS "content_jobs_company_status_idx" ON "content_jobs" ("company_id", "status");
CREATE INDEX IF NOT EXISTS "content_jobs_company_created_idx" ON "content_jobs" ("company_id", "created_at");
```
### Migration for source_task_id on assets
```sql
-- packages/db/src/migrations/0057_assets_source_task_id.sql
ALTER TABLE "assets" ADD COLUMN IF NOT EXISTS "source_task_id" text;
CREATE INDEX IF NOT EXISTS "assets_source_task_id_idx" ON "assets" ("source_task_id");
```
---
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Polling for job status | SSE push | Existing pattern in codebase | No polling loop needed on client |
| Blocking HTTP for render | HTTP 202 + async | Decision from STATE.md | Render time decoupled from response time |
| Flat asset storage | Namespaced storage (`generated/`, `assets/general`) | Existing `service.ts` pattern | No path collision between user uploads and generated output |
**Already established in project:**
- All DB schemas use Drizzle ORM with explicit migrations (not `drizzle.push`)
- Migration files are numbered sequentially starting at `0000_`; next is `0056_`
- Services follow a simple factory function pattern: `export function xService(db: Db) { return { ... } }`
- Routes follow `export function xRoutes(db, deps...) { const router = Router(); ... return router; }` pattern
- All routes are mounted in `server/src/app.ts`
---
## Environment Availability
Step 2.6: SKIPPED — Phase 40 is purely code and database changes. No external CLI tools, external services, or runtime binaries are required beyond Node.js 20, pnpm 9, and PostgreSQL already running.
---
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | vitest (project dep) |
| Config file | `server/vitest.config.ts` — `environment: "node"` |
| Quick run command | `pnpm test:run --project server -- --reporter=verbose src/__tests__/content-jobs*` |
| Full suite command | `pnpm test:run` |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| INFRA-01 | POST /content-jobs returns 202 + jobId within 200ms | unit | `pnpm --filter @paperclipai/server test:run -- src/__tests__/content-jobs-routes.test.ts` | ❌ Wave 0 |
| INFRA-01 | Job transitions queued → running → done/failed | unit | same file | ❌ Wave 0 |
| INFRA-02 | SSE endpoint delivers progress events before terminal | unit | `pnpm --filter @paperclipai/server test:run -- src/__tests__/content-jobs-sse.test.ts` | ❌ Wave 0 |
| INFRA-03 | storage.putFile with generated/ namespace stores bytes without size error | unit | `pnpm --filter @paperclipai/server test:run -- src/__tests__/content-jobs-storage.test.ts` | ❌ Wave 0 |
| INFRA-04 | Asset created by job runner includes sourceTaskId | unit | covered in content-jobs-routes.test.ts | ❌ Wave 0 |
### Sampling Rate
- **Per task commit:** `pnpm --filter @paperclipai/server test:run -- src/__tests__/content-jobs*.test.ts`
- **Per wave merge:** `pnpm test:run`
- **Phase gate:** Full suite green before `/gsd:verify-work`
### Wave 0 Gaps
- [ ] `server/src/__tests__/content-jobs-routes.test.ts` — covers INFRA-01, INFRA-04
- [ ] `server/src/__tests__/content-jobs-sse.test.ts` — covers INFRA-02
- [ ] `server/src/__tests__/content-jobs-storage.test.ts` — covers INFRA-03
*(No new test framework needed — vitest already configured for server.)*
---
## Project Constraints (from CLAUDE.md)
CLAUDE.md does not exist at the project root. The constraints below are derived from `STATE.md` key decisions which carry the same authority:
1. **Async job pattern is mandatory** — all render requests return 202 + job ID immediately; never block HTTP on render
2. **`content_jobs` table must exist before any renderer is built** — this phase is the hard dependency for all others (phases 41–45)
3. **`sourceTaskId` required on every generated asset from day one** — prevents SSD orphan accumulation
4. **`MAX_GENERATED_ASSET_BYTES` constant bypasses the 10MB upload limit for `generated/` namespace** — separate from upload route
5. **Async pattern** — `renderPipelineService` stub must exist by end of phase (even as no-op) so phase 41 can extend it
---
## Sources
### Primary (HIGH confidence)
- Codebase: `server/src/services/live-events.ts` — EventEmitter pub/sub pattern for SSE fan-out
- Codebase: `server/src/routes/voice.ts` — SSE headers pattern (`text/event-stream`, `flushHeaders`, `res.write`)
- Codebase: `packages/db/src/schema/plugin_jobs.ts` — job lifecycle table pattern (status, timestamps, logs)
- Codebase: `packages/db/src/schema/assets.ts` — asset table shape for INFRA-04 extension
- Codebase: `server/src/storage/service.ts` — `putFile` has no byte limit; limit is in multer (upload route only)
- Codebase: `packages/shared/src/constants.ts` — `LIVE_EVENT_TYPES` pattern to extend for job events
- Codebase: `server/src/attachment-types.ts` — `MAX_ATTACHMENT_BYTES` = 10MB; `MAX_GENERATED_ASSET_BYTES` to be added here
- Codebase: `packages/db/src/migrations/` — last migration is `0055_`; next is `0056_`
- Project STATE.md — locked architecture decisions
### Secondary (MEDIUM confidence)
- Pattern inference from `heartbeat_runs` / `plugin_job_runs` tables (same repo) for `content_jobs` shape
### Tertiary (LOW confidence)
None — all findings are from direct codebase inspection.
---
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — all verified from codebase
- Architecture: HIGH — directly modeled on existing patterns in same repo
- Pitfalls: HIGH — identified from direct code review of existing patterns
**Research date:** 2026-04-04
**Valid until:** 2026-05-04 (stable internal patterns)