{"slug": "provenance-belongs-in-the-image-table", "title": "Provenance Belongs in the Image Table", "summary": "A developer at a content studio implemented a PostgreSQL schema for AI-generated images that stores provenance directly in the image table, using a self-referencing foreign key to link edited variants to their originals. This design choice prioritizes fast access to current image metadata over audit-log history, reducing the need for joins on every render.", "body_md": "A generated image looks finished until review starts.\n\nSomeone approves the first version. Someone else crops it. A branded copy goes out. Another edit changes the prompt. A week later, the useful question is simple: which prompt, model, seed, size, parent image, and publishing settings produced the version on screen?\n\nIn a content studio, I put those answers in the PostgreSQL row that stores the image. Logs explain what happened during a run, then rotate away. Object storage keeps the bytes and forgets why they exist. The row is the only one of the three that survives edits, review, and publishing.\n\nThe table in `apps/api/src/database/init-ai-images-table.js`\n\ntreats generated and edited images as one record type. An original image gets its own row. An edit gets another row, with `original_image_id`\n\npointing back to the parent.\n\n```\nCREATE TABLE IF NOT EXISTS ai_generated_images (\n  id SERIAL PRIMARY KEY,\n  image_url TEXT NOT NULL,\n\n  -- what produced it\n  prompt TEXT NOT NULL,\n  model VARCHAR(100) DEFAULT 'fal-ai/imagen4',\n  model_version VARCHAR(100),\n  seed BIGINT,\n  width INTEGER,\n  height INTEGER,\n\n  -- how it derives from another row\n  is_edited BOOLEAN DEFAULT FALSE,\n  original_image_id INTEGER REFERENCES ai_generated_images(id),\n  edit_prompt TEXT,\n  edit_strength DECIMAL(3,2),\n\n  -- what actually shipped\n  branded_url TEXT,\n  branding_options JSONB,\n\n  metadata JSONB,\n  tags TEXT[],\n  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n);\n```\n\nThat self-reference is the design choice. It makes the image table append-only-ish: new variants are inserted as new rows instead of overwriting the earlier state. The cost is more rows and more discipline at write time. The benefit is editable history that product screens and debugging queries can follow.\n\n```\nflowchart TD\n  original[\"Original row: prompt, model, seed, dimensions\"]\n  editA[\"Edited child: edit_prompt, edit_strength, edit_steps\"]\n  editB[\"Edited child: edit_prompt, edit_guidance_scale\"]\n  brandedA[\"Branded output: branded_url, branding_options\"]\n  brandedB[\"Branded output: branded_url, branding_options\"]\n  original --> editA\n  original --> editB\n  editA --> brandedA\n  editB --> brandedB\n```\n\nAn audit table is the obvious alternative. Write the image row, append an event per generation and edit somewhere else, reconstruct history when asked.\n\nI did not do that, and the reason is where the cost lands. An audit table answers questions about history. The product asks questions about the current thing. Every screen that shows an image wants the prompt next to it. A gallery filters by model. A review queue sorts by whether a row is a branded variant. Each of those becomes a join against a log that grows faster than the images do.\n\nThe self-reference costs a recursive query when someone wants the whole branch, which is rare. The audit table costs a join on every render, which is constant. I moved the expensive case onto the rare one.\n\nA value gets its own column when the application filters, sorts, joins, or explains by it. `metadata`\n\nand `branding_options`\n\nstay `JSONB`\n\nbecause those shapes change more often than the core record, and `tags`\n\nis an array so classification can be indexed.\n\n| Field group | Question it answers |\n|---|---|\n`prompt` , `negative_prompt`\n|\nWhat text guided the run |\n`model` , `seed`\n|\nWhich settings identify it |\n`width` , `height` , `aspect_ratio` , `resolution`\n|\nWhat shape came back |\n`is_edited` , `original_image_id`\n|\nWhether it derives from another row |\n`edit_prompt` , `edit_strength` , `edit_guidance_scale` , `edit_steps`\n|\nWhich transform produced the child |\n`branded_url` , `branding_options`\n|\nWhich publishable variant was created |\n`metadata` , `tags`\n|\nExtra details and classification |\n\nSeven indexes cover the access patterns: owner and session for scoping, creation time for the default sort, saved and public flags for the two filters the gallery exposes, and model for the question that only comes up during review. Six of those are ordinary B-tree indexes on scalars. The seventh is not:\n\n```\nCREATE INDEX idx_ai_images_tags ON ai_generated_images USING GIN(tags);\n```\n\n`tags`\n\nis an array, so a B-tree cannot help: a B-tree indexes a value, and the query asks whether a value sits inside a collection. A Generalized Inverted Index (GIN) inverts that. It stores one entry per distinct tag pointing at every row carrying it, which turns containment into a lookup instead of a scan.\n\nEvery index is a tax on writes. Seven of them on a table that grows a row per edit is a real cost, and I took it because these images are written once and then browsed, filtered, revisited and argued about for weeks.\n\nOnce parent links live in the same table as the model parameters, review can ask for a whole branch without reading logs. This recursive Common Table Expression (CTE) starts from one original row and returns every descendant with the fields needed to reproduce or debug the result:\n\n```\nWITH RECURSIVE image_lineage AS (\n  -- anchor: the row you are asking about, at depth 0\n  SELECT id, original_image_id, image_url, prompt, model, seed,\n         edit_prompt, edit_strength, branded_url, created_at, 0 AS depth\n  FROM ai_generated_images\n  WHERE id = $1\n\n  UNION ALL\n\n  -- recursive arm: anything whose parent is already in the result\n  SELECT child.id, child.original_image_id, child.image_url, child.prompt,\n         child.model, child.seed, child.edit_prompt, child.edit_strength,\n         child.branded_url, child.created_at, parent.depth + 1\n  FROM ai_generated_images child\n  JOIN image_lineage parent ON child.original_image_id = parent.id\n)\nSELECT * FROM image_lineage ORDER BY depth, created_at;\n```\n\nThe two arms are doing different jobs. The anchor selects one row and calls it depth zero. The recursive arm joins the table back onto the results so far, so each pass picks up the children of everything found in the pass before it. Postgres repeats that until a pass returns nothing.\n\n`depth`\n\nis the column that makes the output readable. Without it the result is an unordered pile of rows; with it, ordering by depth then time reproduces the order the edits actually happened in. One original branches into many children, and every node carries enough state to explain why it exists.\n\nThe recursion has no depth limit, which is fine while edits are made by people. A loop would hang it, and a row cannot become its own ancestor through the normal write path, so I have not added a guard. If edits ever get generated in a batch, that assumption is the first thing I would revisit.\n\nThe insert path in `apps/api/src/database/azure-postgres.js`\n\nfollows the same contract: explicit columns for the core settings and edit parameters, JSON for the irregular details.\n\nTwo gaps, and both are visible in the schema above.\n\n`model`\n\nstores a slug like `fal-ai/imagen4`\n\n, not a version. A provider can change weights behind that name without changing the string, and the row will keep claiming a reproducibility it no longer has. Same prompt, same seed, same model value, different image. The fix is a pinned version in its own column rather than buried in `metadata`\n\n, because reproducibility is a question the product asks directly.\n\nThe foreign key carries no `ON DELETE`\n\nclause, so Postgres refuses to remove a parent that still has children. Cleanup becomes a deliberate walk down the tree instead of one statement. That is the right default here, and it is worth stating plainly: anyone who reaches for `ON DELETE CASCADE`\n\nto make a purge easier is deleting the receipts.\n\nBranding obeys the same rule. `image_url`\n\nrecords what the model returned, `branded_url`\n\nthe version intended for use, `branding_options`\n\nthe publishing configuration. A reviewer questioning the final visual can separate model output, edit choice, and branding treatment without opening a log.\n\n🎧 **Listen to the audiobook** — [Spotify](https://open.spotify.com/show/4ABVd5yDVfbX9HlV5JjT7D) · [Google Play](https://play.google.com/store/audiobooks/details/How_to_Architect_an_Enterprise_AI_System_And_Why_t?id=AQAAAECafz8_tM&hl=en) · [All platforms](https://www.craftedbydaniel.com/audiobook)\n\n🎬 [Watch the visual overviews on YouTube](https://youtube.com/playlist?list=PLRteDbGJPYDb9XNjecvHplGlgW7tIv_q6)\n\n📖 [Read the full 13-part series](https://www.craftedbydaniel.com/blog/series/how-to-architect-an-enterprise-ai-system-and-why-the-engineer-still-matters)", "url": "https://wpnews.pro/news/provenance-belongs-in-the-image-table", "canonical_source": "https://dev.to/daniel_romitelli_44e77dc6/provenance-belongs-in-the-image-table-4d30", "published_at": "2026-08-03 21:23:23+00:00", "updated_at": "2026-08-03 21:43:08.449032+00:00", "lang": "en", "topics": ["artificial-intelligence", "developer-tools"], "entities": ["PostgreSQL", "fal-ai/imagen4"], "alternates": {"html": "https://wpnews.pro/news/provenance-belongs-in-the-image-table", "markdown": "https://wpnews.pro/news/provenance-belongs-in-the-image-table.md", "text": "https://wpnews.pro/news/provenance-belongs-in-the-image-table.txt", "jsonld": "https://wpnews.pro/news/provenance-belongs-in-the-image-table.jsonld"}}