We almost shipped a content system that could publish a thousand posts without ever learning which one earned a dollar.
We had a working prototype already: a folder of markdown files in our landing-page repo, git-tracked, rendered by a static site. The next obvious move was to turn the crank faster. Point an AI at our work log, have it draft posts, push them to the folder, ship. Outcomes? Later. There’s always a later.
We were treating that prototype as the smallest version of the learning system we wanted, which is why “Outcomes? Later” sounded reasonable in the moment. We were ready to go faster before asking what the system could learn from a signal it had not stored.
That’s the seductive version of a content system: three verbs, ingest, review, publish. Add analytics later. It demos beautifully and ships in a week. We stopped because one question changed the whole schema: what is this system actually for?
Publishing is cheap. The system exists to learn which posts are worth publishing, so that next month’s thousand are better than this month’s. The whole reason to put an AI behind it is that an AI can read a feedback signal and optimize against it. A feedback signal you didn’t store does not exist.
So when we sat down to write the first migration, the outcomes table went in the same file as the beats table. Same CREATE TABLE block, same deploy. Phase two was off the table.
The folder was a dead end before we added a single table
Quick detour, because it explains why there’s a database at all.
A folder of markdown is a perfectly good source of truth for a human blog. It is a terrible one for an agent pipeline. An AI can’t efficiently search it, can’t model “this draft passed four of seven reviewers and failed the fact check,” and can’t enforce a schema on what a post even is. This system has to track state across multiple review passes and retrieve past work semantically, and the folder gives it none of that.
So the source of truth became Postgres, and the markdown repo got demoted to a render target the publisher writes into. Once we made that move, the question stopped being “should we track outcomes” and became “which tables go in the first migration.” Schema is a statement of what your system believes is real. If outcomes aren’t in the schema, the system does not believe revenue is real.
What “ingest, review, publish, later” costs
Here’s the failure mode in concrete terms. You ship the three-verb MVP. It works. Posts go out. A few months later you’ve got 1000 published pieces and you want to know which topics to write more of.
You go to answer that and discover you can’t. There’s no row anywhere that says “this post was distributed to these channels, drove these clicks, and produced these signups worth this many dollars.” The data was generated. People clicked, some converted. But the events streamed past and nothing caught them, because the table that would have caught them was on the “later” list. You can’t backfill an attribution chain that was never recorded. The signal is gone for every post you already shipped.
That is publishing into a void, and it is a deeper problem than a missing dashboard. The training corpus for the thing that is supposed to get smarter has no reward signal in it at all. You’ve built a learner with no way to tell it whether it won.
Two tables in the first migration
A beats table holds content state: slug, title, body, pipeline status, the JSON blob of reviewer verdicts. Standard stuff. Then, in the same file:
-- Each time a beat is posted to a channelCREATE TABLE distributions ( id UUID PRIMARY KEY, beat_id UUID REFERENCES beats(id), channel TEXT CHECK (channel IN ('blog','linkedin','x','newsletter','hn','reddit','other')), url TEXT, posted_at TIMESTAMP, utm_source TEXT, utm_medium TEXT, utm_campaign TEXT, reach INTEGER, clicks INTEGER, signups INTEGER, last_synced_at TIMESTAMP);
-- Conversions attributed back to a beat and a distributionCREATE TABLE outcomes ( id UUID PRIMARY KEY, type TEXT CHECK (type IN ('email_signup','lead','demo_request','sale','inquiry','other')), value_usd NUMERIC, occurred_at TIMESTAMP, attributed_beat_id UUID REFERENCES beats(id), attributed_distribution_id UUID REFERENCES distributions(id), utm_source TEXT, utm_medium TEXT, utm_campaign TEXT, raw_payload JSONB -- keep the unparsed event so you can fix attribution later);distributions is one row per beat per channel. It carries the UTM tags and the reach, clicks and signups the attribution job fills in. outcomes is the typed conversion: an email signup, a lead, a sale, with a value_usd and the full UTM chain that ties it back to a specific post on a specific channel.
Two details decide whether a schema ages well or fights you in six months.
First, the attribution chain on outcomes references both attributed_beat_id and attributed_distribution_id. A sale that came from the post on the newsletter and a sale that came from the same post on X are different facts. Collapse them and you’ve thrown away the channel-mix signal, which is half of what we’re trying to learn.
Second, raw_payload JSONB. Attribution is wrong constantly. UTMs get stripped, providers send malformed events, someone converts after clicking three things. If you only store your parsed interpretation of an event, you can never re-derive it when your attribution logic improves. With the raw payload stored, a future version of the attribution agent can replay history and get a better answer. You keep the evidence so the recording can be corrected.
Then a view stitches it together:
CREATE VIEW beat_performance ASSELECT b.id, b.slug, b.pillar, b.tier, SUM(d.clicks) AS total_clicks, SUM(d.signups) AS total_signups, COUNT(DISTINCT o.id) FILTER ( WHERE o.type IN ('lead','demo_request','sale') ) AS revenue_events, SUM(o.value_usd) AS total_revenue_usdFROM beats bLEFT JOIN distributions d ON d.beat_id = b.idLEFT JOIN outcomes o ON o.attributed_beat_id = b.idWHERE b.status = 'published'GROUP BY b.id;That FILTER clause is the line we care about most in the whole schema. It counts only the outcomes that are actually revenue, per beat, with the pillar sitting right there for the rollup. It answers “which kind of post should we write more of,” and it can only return a real number because the events feeding it were captured from the first post forward.
Where we drew the wave-one line
The obvious objection is that a NUMERIC revenue column is over-engineering and we should just ship the posts. We made that argument to ourselves.
It fails on the word “minimum” in MVP. Minimum viable means the smallest thing that does the job. For a human-startup blog the job is “publish,” so the MVP skips analytics, and that’s correct. This system’s job is to learn what earns. The smallest version that does that job has to include the feedback loop, because without it you have a different system that happens to share a publish button.
So when we scoped the first shippable cut, the first wave deployed beats, tags, distributions, and outcomes. It runs exactly two reviewer personas instead of the planned seven, because review quality can be improved incrementally. It posts to exactly one channel, the newsletter, because the subscribe form already existed and the other channels needed OAuth we didn’t want to block on. It has a two-screen approval UI and nothing else, because we review on our phones and the only human gate that matters is yes or no on a draft.
Look at what got cut and what didn’t. Reviewers: cut to two. Channels: cut to one. UI: cut to two screens. Outcome tracking and attribution wiring: kept. The things that make the system better got deferred. The thing that makes it a learning system at all stayed in wave one, because a wave one without it is publishing into a void with extra steps.
What you can add later and what you can’t
You can add reviewers three through seven next month. You cannot add the revenue history for the thousand posts you already shipped blind.
So before launching any content or marketing system, check whether it records what each piece earns from the first post onward. Reviewers, channels and screens can wait. Ask any team or vendor building one where conversions are stored, and whether the raw events are kept so attribution can be corrected.