Expands the slice from a single 300x250 banner to four IAB sizes (300x600, 300x250, 728x90, 160x600) driven by a designer-authored TypeSystem and a per-row strip review surface. Layout engine - TypeSystem with role-based typography (headline/subheadline/cta/legal) and piecewise size-class derivation: half_page / rectangle / leaderboard / skyscraper / mobile_banner. - resolveLayout now derives per-size font/leading from the role + artboard size, then clamps to a legibility floor and emits a constraint_signal when copy does not fit at the floor. - Four reference templates with character constraints per size. AI pipeline (Shape B) - One extract + one generate per feed row; generate returns per-size copy keyed by artboard_id plus a shared rationale block. - Constraint-signal retry: orchestrator tightens per-(artboard, field) limits and re-calls generate before giving up. - orchestrateRow returns specs[] + rationale + constraint_signals. Review UI - /review renders one strip per feed row, all four sizes side-by-side at true pixel dimensions, synced on a single GSAP master timeline. - AiReasoningDrawer shows a per-size copy table, shared rationale, and any constraint signals that fired. - /api/generate response grouped by row; /api/export accepts the same shape and writes exports/row-N/artboard_id.zip. Render worker - render-to-zip / render-many accept optional subdir + filename overrides so multi-size exports can be grouped by feed row. Docs - VERTICAL_SLICE and BUILD_SEQUENCE updated for the multi-size scope. - RESOLVED_FEED.md documents the V1 Resolved Creative Feed proposal. - SLICE_DEVIATIONS.md records where the slice diverges from V1. Tests: 56 pass (28 layout-engine + 14 api-lib + 14 render-worker). Web app: tsc clean, next build succeeds. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
20 KiB
RESOLVED_FEED.md
Status: V1 architecture proposal. Not implemented in the vertical slice. The slice persists per-size editorial decisions in
BannerSpec.ai_reasoningas a forward-pointer to this model (see SLICE_DEVIATIONS.md).
A proposal for the V1 version/override architecture. Replaces — or significantly
reshapes — the override-merge layer described in ARCHITECTURE.md Part 7. The
load-bearing claim: copy belongs in a sparse, per-product-per-size feed, not in
a patch layer applied after generation.
The Two-Feed Model
The system manages two feeds, not one.
Inbound Feed. What the client provides. One row per product. Flat columns
(headline, subheadline, hero_image_url, cta_text, click_url, …).
Authored by the client or by humans inside the agency. Never edited by the
system. Refreshes on CSV upload or live API pull.
Resolved Creative Feed. What the system manages internally. One cell per product per size per copy field. Sparse — cells exist only where variation is needed. Authored by the AI (most cells), the constraint signal (per-size deviations), or humans (review-UI edits). The render worker reads from this feed. The review UI reads from this feed. The export pipeline reads from this feed.
The user only ever interacts with the inbound feed (upload) or the review UI (per-banner). The resolved creative feed is the engine room — visible in audit views, never edited as a spreadsheet.
INBOUND FEED RESOLVED CREATIVE FEED
───────────────────────── ────────────────────────────────────────
client artifact system artifact
one row per product one cell per product per size per field
flat, dense sparse — cells only where needed
human-authored only AI + constraint + human
refresh = overwrite (with locks) refresh = orchestrated regeneration
Cell Types
A copy cell in the resolved feed has a source indicating how it came to be.
The source determines who can write to it and what happens on regeneration.
1. Inbound cell. Equal to the inbound feed value for this product + field. No size-specific variation. Read-only from the system; only changes on external feed refresh.
2. Generated baseline cell. Written by the AI on first pass against the reference artboard's constraints and the brand voice. This is the "house version" of the copy — what would render at the reference size and at any non-reference size that didn't trigger a constraint signal. One per product + field.
3. Per-size cell. Created in exactly two situations:
- The layout engine emitted a
constraint_signalfor this product at this size, and the AI rewrote shorter copy to fit. - A human edited the banner at this size in the review UI.
Read order at render time. For a given product, size, and field, the renderer reads in priority order:
per-size cell → generated baseline cell → inbound cell
If a per-size cell exists, it wins. Otherwise the generated baseline. Otherwise the inbound value (which means the AI's baseline pass was a no-op for this field — typically because the inbound value already met every constraint and the brand voice didn't require a rewrite).
Sparseness in practice. A typical 5-product, 4-size campaign has 5 inbound rows. The resolved feed adds ~15 generated baseline cells (3 fields × 5 products, give or take, since some fields may not need rewriting). Per-size cells appear only where the leaderboard couldn't fit the baseline headline, or the producer adjusted something specific. Total cells in the resolved feed for the campaign: maybe 25–40 across what would be 60 dense cells (5 × 4 × 3). Sparseness ratio matters because it's the audit-trail signal — every per-size cell represents a real editorial decision.
How Cells Are Created
NEW INBOUND ROW
→ 1 inbound cell per field
AI BASELINE PASS (Generate agent, reference-size constraints)
→ 1 generated baseline cell per field where the AI rewrote
→ No cell where the inbound value already passes validation
CONSTRAINT SIGNAL FROM LAYOUT ENGINE
Layout engine: "728x90 headline doesn't fit at 18px floor, max 32 chars"
Orchestrator re-invokes Generate with tightened limit for that size
→ 1 per-size cell at (product, 728x90, headline)
HUMAN EDIT IN REVIEW UI
Producer changes headline on the 728x90 banner for product 3
→ 1 per-size cell at (product 3, 728x90, headline)
→ Cell flagged human_authored: true (see Lock Mechanism below)
If a producer edits at the reference size (300x600), the system has a choice:
update the generated baseline cell, or create a per-size cell at the reference
size. The right answer is update the baseline cell and mark it
human_authored: true, so the edit propagates to any non-reference size that
was reading the baseline. This is the only place the cell model gets subtle;
the UI should make the propagation visible ("this edit will apply to all sizes
except 728x90, where you previously customized the headline").
The Override Problem, Reframed
The current ARCHITECTURE.md Part 7 model:
AI generates → human edits → override patch stored separately
Regenerate → new generation + patch re-applied
↑ conflicts possible at this merge step
The merge step is where complexity lives. The patch is a field_path + value;
on regeneration, the patch is re-applied to the new spec; if the field shape
changed (e.g., the AI removed a layer the override referenced), the merge
algorithm has to resolve the conflict.
The resolved-feed model:
AI generates → writes baseline + per-size cells
Human edits → writes per-size cell, sets human_authored: true
Regenerate → reads resolved feed; AI only rewrites un-locked cells
↑ no merge — edited cells were already the canonical source
There is no conflict because there is no after-the-fact patch application. The
edit is the cell value. Regeneration reads the cell; the AI is invoked only
for cells without human_authored: true.
Important honest scope limit. This collapses the override problem for copy. Non-copy overrides — visual properties on a layer, hero focal point adjustments, GSAP timing changes, layer visibility per artboard, asset variant selection — do not naturally live as cells in a copy feed. Those still require the existing override layer (or some shrunken version of it). The resolved feed eliminates the largest, highest-conflict surface of the override system; it does not eliminate it entirely.
V1 should ship with both: resolved feed for copy, smaller patch layer for
non-copy. Future cleanup can examine whether non-copy edits can be folded
into a generalized cell model (likely yes for layer-level properties keyed
by layer_id; probably no for cross-cutting decisions like animation
preset selection).
Lock Mechanism
Two independent flags on every cell. Default values matter; both default to the producer-protective setting on human edit.
human_authored — true when a human authored or edited this cell.
- Effect on regeneration: AI does not rewrite this cell.
- Default on human edit:
true. - Producer can explicitly reset to
false("let the AI try again").
external_pinned — true when this cell should resist inbound feed refresh.
- Effect on external feed refresh: inbound value does not overwrite this cell.
- Default on human edit:
true. - Producer can relax to
falseif they want the new product price from the next feed pull but want to keep their headline edit.
The combinations:
human_authored |
external_pinned |
Behavior |
|---|---|---|
| false | false | AI may rewrite; feed refresh may overwrite |
| true | false | AI does not rewrite; feed refresh may overwrite |
| false | true | AI may rewrite; feed refresh does not overwrite |
| true | true | AI does not rewrite; feed refresh does not overwrite |
The two flags are independent because they answer different questions ("who controls editorial decisions?" vs "is this synced with the source of record?"). Conflating them — as the original write-up did with a single "locked" flag — loses real producer leverage.
Revert / Delete Semantics
A per-size cell exists because something deviated from the baseline. When a producer wants to revert ("let the AI decide again, or fall back to baseline"), the cell is deleted, not edited to match the baseline.
Deletion drops back through the source chain:
delete per-size cell → reads generated baseline cell (if exists)
delete baseline cell → reads inbound cell
delete inbound cell → impossible (inbound feed is the floor)
The UI shows a single "revert to system" action per cell. Producers think in terms of the rendered banner, not the cell tree, so the action is phrased "let the AI/system decide this again." Under the hood it's a cell deletion.
Open question for V1. Should deletion also clear human_authored
and external_pinned on the underlying baseline cell if that cell exists?
Probably yes — if the producer is reverting, they're signaling they no
longer want the lock. Worth confirming with producers before locking the
behavior.
External Feed Refresh
When a new CSV is uploaded or the live feed updates, the system:
-
Diffs new inbound rows against existing inbound cells.
-
For each changed cell:
- If the resolved feed cell at this product + field is
external_pinned: trueanywhere (baseline or any per-size), do nothing. - Otherwise, update the inbound cell value.
- If a baseline cell exists for this field, mark it stale and queue regeneration. (The new inbound copy may invalidate the AI's baseline rewrite.)
- If per-size cells exist for this field and are not pinned, mark them stale and queue regeneration.
- If the resolved feed cell at this product + field is
-
Surface the stale set in the review UI so the producer chooses when to trigger regeneration.
Note on stale state. Stale cells render with their current value (no banner breaks because of a feed refresh). They're flagged in the UI as "needs regeneration." Regeneration runs only the affected cells, not the whole campaign. This is what makes "production gets faster with every campaign" real — most refreshes touch a small set of cells, not all of them.
Brief Mode
This model applies in brief mode with one structural difference: there is no inbound feed.
- No inbound cells exist.
- Generated baseline cells are written from the brief on first pass.
- Per-size cells are created from constraint signals or human edits, same as feed mode.
external_pinnedis meaningless and becomes a no-op (no inbound source to resist). UI hides the flag in brief mode.human_authoreddoes the same job it does in feed mode.
The override-preservation backstop (the smaller patch layer mentioned above) is more load-bearing in brief mode for non-copy overrides, but copy edits still write to cells.
What This Unlocks Beyond Solving the Override Problem
Auditable creative history. The resolved feed is the creative record. Every campaign leaves behind a structured document of what shipped at every size for every product, with sources attributed (AI baseline, constraint signal, human edit) and timestamps. Compliance, brand audits, and "what did we run last Black Friday?" all become queries against the feed.
Compounding speed across campaigns. Next campaign, same products:
the producer starts from the previous campaign's resolved feed as a baseline.
The brief writer marks which products are unchanged. For unchanged products,
the system reuses cells verbatim (no AI calls). For changed products, only
those rows regenerate. For campaign-level changes (new brand voice, new
character constraints), all human_authored: false cells regenerate.
This speedup is a feature to build, not a property that emerges. The resolved feed makes it possible; the campaign-rerun flow has to be designed and shipped. Worth a dedicated build phase in V1.
A real surface for editorial review. Right now, "see what the AI
decided" is a derived view assembled from logs and ai_reasoning fields.
With the resolved feed, it's a table: product × size × field, with each cell
showing source, value, timestamp, and any constraint signal that drove the
decision. Reviewing a campaign becomes a structured pass through this table
rather than clicking through 25 banner previews.
Cleaner V2 hooks. Multi-language: another dimension on the cell key
(product × size × field × locale). Multi-market: same shape. The cell
model generalizes more cleanly than the patch model does, because patches
are inherently anchored to a single spec at a single point in time.
What This Doesn't Solve
Non-copy overrides. As above. Visual, layout, animation, and asset
selection edits still need the patch layer (or a generalized cell model
keyed by layer_id + property_path, which is a V2 question).
Cross-product editorial coherence. A campaign may need its headlines to feel consistent across products in tone and structure. The cell model doesn't encode this constraint; the Generate agent's prompt does, and the producer reviews. This is fine — but worth not claiming the resolved feed solves something it doesn't.
Inbound feed schema changes. When the client adds, drops, or renames
columns, the resolved feed doesn't automatically know what to do. Adds
are easy (new field, no cells yet, AI generates on next pass). Drops are
trickier — historical cells for the dropped column should probably be
preserved, marked stale, and hidden from the active campaign view but
retained for audit. Renames are the hardest and likely require explicit
producer intent ("treat subheadline as the new subhead"). V1 should
handle adds and drops; renames are a V2 capability.
Schema Sketch
Not a final V1 schema — a sketch to make the model concrete.
-- The two cell roots.
inbound_cells (
product_id TEXT,
field TEXT, -- 'headline', 'subheadline', 'cta_text', …
value TEXT,
source TEXT, -- 'csv_upload' | 'live_feed' | 'manual'
updated_at TIMESTAMP,
PRIMARY KEY (product_id, field)
)
resolved_cells (
campaign_id TEXT,
product_id TEXT,
size_id TEXT NULL, -- NULL for generated baseline; '300x600', '728x90', … for per-size
field TEXT,
value TEXT,
cell_source TEXT, -- 'inbound' | 'generated_baseline' | 'constraint_emitted' | 'human_authored'
human_authored BOOLEAN DEFAULT FALSE,
external_pinned BOOLEAN DEFAULT FALSE,
constraint_signal JSONB NULL, -- when source is constraint_emitted
authored_at TIMESTAMP,
authored_by TEXT NULL, -- user id when human, AI prompt version when AI
stale BOOLEAN DEFAULT FALSE,
PRIMARY KEY (campaign_id, product_id, size_id, field)
)
Notes on the schema:
size_id IS NULLmarks the generated baseline (or absence). Modeling baselines as asize_id = NULLrow is cleaner than a separate table because the read query for "give me the value for (campaign, product, size, field)" is one query with a coalesce onsize_id.cell_sourceis the origin of the value;human_authoredis a behavior flag. They're related but not the same — a human-authored cell hascell_source = 'human_authored'andhuman_authored = true, but a baseline cell that a human edited becomescell_source = 'human_authored'(the previous AI baseline is overwritten) withhuman_authored = true. The flag is what drives regeneration logic; the source is what drives audit views.constraint_signalcarries the layout-engine emission for cells created by the constraint loop. This is exactly the data structure the slice's layout engine already produces.
Relationship to the Slice
The slice does not implement the resolved feed. It runs end-to-end in one session: CSV in → BannerSpec out → render. There is no persistence, no campaign reruns, no human editing.
The slice does, however, produce data that maps directly to the resolved feed model:
-
Per-row constraint signals. When the layout engine emits a
constraint_signaland the orchestrator retries Generate with a tightened limit, the resulting copy is conceptually a per-size cell withcell_source = 'constraint_emitted'. In the slice, this lives in the resolvedBannerSpecand theai_reasoningfield. -
AI baseline reasoning. The Generate agent's per-size output is the generated baseline + per-size cell set. The slice surfaces these in the AI reasoning panel.
For the slice's BannerSpec.ai_reasoning, a per_size_decisions field
captures this as a forward-pointer:
ai_reasoning: {
asset_selection: string;
copy_rationale: string;
variant_selection: string;
animation_rationale: string;
per_size_decisions?: Array<{
artboard_id: string;
role: 'headline' | 'subheadline' | 'cta' | 'legal';
reason: 'baseline' | 'constraint_emitted';
constraint_signal?: {
max_chars_at_floor: number;
derived_font_size: number;
floor_font_size: number;
};
decided_at: string;
}>;
}
This is the audit-trail data the future resolved feed would carry, captured in the slice as a flat array on the spec. Cheap to add, narratively powerful for the demo ("here are the editorial decisions the AI made per size, with reasons"), and a clean migration path to the V1 cell model.
V1 Build Path
Rough sequencing for V1, not committed:
- Schema + storage. Implement
inbound_cellsandresolved_cellsper the sketch above. Postgres + JSONB columns where appropriate. - Read path. Implement
resolveCellValue(campaign, product, size, field)as the canonical read. Render worker, review UI, export pipeline all use it. - Write path — AI. Generate agent writes baseline + per-size cells.
Constraint-signal retry writes
cell_source = 'constraint_emitted'. - Write path — human. Review UI inline edit writes
cell_source = 'human_authored'withhuman_authored = trueby default. - Refresh path. Inbound feed refresh diffs against
inbound_cells, respectsexternal_pinned, marks affected resolved cells stale. - Revert / delete. UI action for "revert to system" deletes the appropriate cell and recomputes downstream reads.
- Campaign rerun. Brief writer flow that diffs new brief against previous campaign's resolved feed, marks unchanged products, regenerates only the changed set.
Steps 1–4 are the minimum to ship copy editing in V1. Steps 5–7 are the compounding-speed payoff and can land in subsequent V1 milestones.
Open Questions for V1
- Cell deletion and lock interaction. When a per-size cell is deleted,
should the underlying baseline cell's
human_authoredflag be cleared if it was true? Probably yes; worth confirming with producers. - Multi-campaign sharing. Should the resolved feed be scoped per campaign or per client? Per campaign is simpler; per client enables cross-campaign reuse but complicates lock semantics.
- Audit retention. How long are deleted cells kept? Forever (soft
delete with
deleted_at) is the conservative answer; cost may justify a TTL eventually. - Conflict between concurrent edits. Two producers editing the same cell. The patch model already had this problem; the cell model has it too. Optimistic locking with last-write-wins + audit log is probably enough for V1.
- Generalizing to non-copy properties. Whether to extend the cell model to layer-level visual properties in V2, eliminating the residual patch layer entirely.
One-Line Summary
Copy lives in a sparse resolved feed — one cell per product per size per field, only created on need. AI writes baseline and per-size deviations; humans edit cells directly; regeneration reads from the feed, so copy edits survive without an override-merge layer. Non-copy overrides — visual, layout, animation — remain on a smaller patch layer.