Posts could ship with no record of which one earned anything. I had a working prototype already: a folder of markdown files in my landing-page repo, git-tracked, rendered by a static site. The next obvious move was to turn the crank faster. Point an AI at my work log, have it draft posts, push them to the folder, ship. Outcomes? Later. There’s always a later.

I was treating that publishing prototype as if it were the smallest version of the learning system I wanted. That is why “Outcomes? Later” sounded reasonable in the moment. I was ready to turn the crank 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, it ships in a week, and it lets you publish a thousand posts without ever learning which one earned a dollar.

I almost built it. I stopped because one question changed the whole schema: what is this system actually for?

It is not for publishing. 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 entire reason to put an AI behind it is that an AI can read a feedback signal and optimize against it. And a feedback signal you didn’t store does not exist.

So when I sat down to write the first migration, the outcomes table went in the same file as the beats table. Not phase two. Same CREATE TABLE block, same deploy.

The folder was a dead end before I added a single table

Quick detour, because it sets up 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. Those aren’t nice-to-haves you bolt on. For a system whose whole job is to track state across multiple review passes and retrieve past work semantically, they are the substrate.

So the source of truth became Postgres, and the markdown repo got demoted to a render target the publisher writes into. Once you’ve made that move, the question stops being “should I track outcomes” and becomes “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, your system does not believe revenue is real.

What “ingest, review, publish, later” actually 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 your system and you caught none of 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, permanently, 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 built a learner with no way to tell it whether it won.

The two tables that change what the system is

Here’s the core of the 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 channel
CREATE 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 distribution
CREATE 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/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 are the difference between a schema that ages well and one that fights you in six months.

The attribution chain on outcomes references both attributed_beat_id and attributed_distribution_id. You want to answer “which post earned this” and “which channel for that post earned this” separately. 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 you’re trying to learn.

The other is 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. Storing the raw payload means a future version of the attribution agent can replay history and get a better answer. You’re not just recording outcomes, you’re keeping the evidence so the recording can be corrected.

Then a view stitches it together:

CREATE VIEW beat_performance AS
SELECT
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_usd
FROM beats b
LEFT JOIN distributions d ON d.beat_id = b.id
LEFT JOIN outcomes o ON o.attributed_beat_id = b.id
WHERE b.status = 'published'
GROUP BY b.id;

That FILTER clause is the line I care about most in the entire schema. It counts only the outcomes that are actually revenue, per beat, with the pillar sitting right there for the rollup. It is the answer to “which kind of post should I write more of,” and it can only return a real number because the events that feed it were captured from the first post forward.

Why this is the MVP, not a feature

The obvious objection is that this is a one-person blog, a NUMERIC revenue column is over-engineering, and I should just ship the posts. I’ve made that argument to myself.

It’s wrong, and the reason is the word “minimum” in MVP. Minimum viable product 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. But this system’s job isn’t to publish. Its job is to learn what earns. The smallest version that does that job has to include the feedback loop, because without the loop it isn’t a smaller version of the system, it’s a different system that happens to share a publish button.

So when I scoped the first shippable cut, I drew the line in a specific place. The first wave deploys beats, tags, distributions, and outcomes. It runs exactly two reviewer personas instead of the planned seven, because review quality is something you can improve incrementally. It posts to exactly one channel, the newsletter, because the subscribe form already existed and the other channels needed OAuth I didn’t want to block on. It has a two-screen approval UI and nothing else, because I review on my phone and the only human gate that matters is yes/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: not cut. Attribution wiring: not cut. 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.

You can add reviewers three through seven next month. You cannot add the revenue history for the thousand posts you already shipped blind. The schema is the one decision later can’t undo.