Level 00 The Substrate Days 1–5 · complete · gate 11/11
← Build log

BELLWETHER · Deep dive · Independent project, not affiliated with Netflix

Something real to operate on

Level 0 is a small, production-shaped ads platform: four services that take an ad request, decide what to serve, record what was delivered, and can be broken on purpose. It exists so that everything built on top of it — the context layer, the dev agents, the ops agents — has real traffic, real metrics and real incidents instead of mock data.

4
Services
6
Decision rules
5
Failure modes
141
Hermetic tests
11/11
Level 0 gate

Contents

What this document covers

Read top to bottom for the full picture, or jump. Sections 3 through 6 are the system itself; 7 through 9 are the reasoning; 10 is the honest comparison to the real thing.

01 · Backstory

Why build an ads platform to demonstrate AI engineering?

BELLWETHER targets one job: Netflix's Staff AI Engineer role on the Ads Platform team, requisition AJRT30201. That posting asks for five things — a centralized context layer, dev lifecycle agents, ops agents, multi-agent orchestration, and evaluation frameworks — and its underlying tension is stated plainly: ship faster with AI without accumulating slop, regressions, and accountability gaps.

The obvious way to build a portfolio for that role is to demo agents. The problem is that almost every agent demo has the same hole in it: the agent operates on nothing. An "incident triage agent" reads a hand-written log file. An "RCA agent" is handed a fixture describing the answer it is supposed to find. Those demos prove the prompt works. They prove nothing about whether the agent could function against a real system.

So Level 0 inverts the usual order. Before any AI is written, build the thing the AI will operate on:

  • Ops agents need real logs and metrics that were emitted by services actually handling requests, and real incidents with real causes.
  • Dev agents need a real codebase with real conventions, real tests, and real architectural decisions to be consistent with.
  • The context layer needs a real corpus: code, decision records, runbooks, API specs — documents that exist because the project needs them, not because the retriever needs something to retrieve.
  • Every eval needs a ground truth that was not authored alongside the answer.

Ads was chosen as the domain because it is the domain of the job. Using the posting's own vocabulary — ad insertion, brand safety, frequency capping, pacing — in the code, the metrics and the logs means a reviewer reads the repo in their own language. It also happens to be a genuinely good domain for this exercise: ad decisioning is latency-sensitive, rule-dense, config-driven and money-adjacent, which produces exactly the kind of failure modes that make ops agents interesting.

Where Level 0 sits in the 30 days

Level 0 is Days 1–5 of a six-level, 30-day build. It is the only level that contains no AI at all. Everything after it consumes it.

DayDeliverableThe thing that made it non-trivial
1Repo, architecture docs, running doc, ADR-0001Deciding to write decisions down from hour one, with the condition that would reverse each one.
2campaign-service — CRUD, Postgres, OpenAPITests passed locally and failed CI: they were secretly reaching a real database.
3ad-decision-service — the serving pathTwo services in one test process collided on a Prometheus metric name.
4event-service + Grafana dashboardsMaking ingestion idempotent without a check that races.
5traffic-simulator + failure injection + quality gateInjecting failures that are real enough to be diagnosable — and finding three genuine defects in the process.

02 · Domain primer

Ad serving, from zero

None of the design decisions parse without the vocabulary. This is the whole domain model in one page.

A viewer is watching something. The player reaches a point where an ad can run — an ad break. The system is given a fraction of a second to answer one question: which ad, if any, should play here? That question is an ad request, and it carries two things:

  • Member context — who is watching. In BELLWETHER: a member id, a country, a device type. In production this is much richer, and much more privacy-governed.
  • The slot — the opportunity itself. How many seconds are available, what the content's maturity rating is, and what the surrounding content is about (its categories).

On the other side sit campaigns. A campaign is an advertiser's booking: a budget, a flight window (start and end dates), targeting rules, brand-safety exclusions, a frequency cap, and one or more creatives — the actual video assets, each with a duration.

The job of the decision path is to narrow the campaign set to the ones that are allowed to serve into this specific slot, then pick one. Four constraints do most of that narrowing, and all four appear verbatim in the job posting:

  • Targeting — does this campaign want this viewer? Country, device, content rating. An empty targeting list means "unrestricted", not "matches nothing".
  • Brand safety — does the advertiser refuse to appear next to this kind of content? A family snack brand excluding true-crime is the canonical case. This is the advertiser protecting itself, and getting it wrong is a real-world incident.
  • Frequency capping — has this viewer already seen this campaign too many times today? This is the constraint viewers actually feel. Capping is a quality-of-experience feature as much as a contractual one.
  • Budget pacing — is this campaign allowed to spend right now? A campaign with a daily budget should spread it across the day rather than exhausting it by 9am and going dark through prime time.

If a campaign survives all of that and has a creative that fits the slot's duration, it is eligible. If one is chosen, that is a fill; the resulting play is an impression. If nothing survives, it is a no-fill, and the share of requests that fill is the fill rate — the single most-watched number on the serving path.

Two conventions worth explaining

Money is stored in micros — millionths of a currency unit — as integers. Money is never a floating-point number in an ad system, because floats accumulate rounding error and ad systems perform billions of tiny arithmetic operations against budgets. 2_000 micros is $0.002, a plausible price for one impression.

Every rejection has a name. In BELLWETHER a filter never returns a bare false. It returns a reason: targeting_mismatch, brand_safety_excluded, frequency_capped, pacing_throttled. That single decision shapes the whole rest of the project, and section 9 explains why.

03 · Architecture

Four services, one request path

The decomposition follows the natural seams of an ads platform rather than technical layers. Each service owns one question, and each owns its own state.

Signal flow — four services, two stores, one metrics path

traffic-simulator :8004
Seeded load + five injectable failure modes
POST /ad-requestPOST /eventsPATCH /campaigns
ad-decision-service :8002
The serving path — six-rule chain
campaign-service :8001
System of record — campaigns, creatives
event-service :8003
Durable delivery record + rollups
freq: / spend: countersSQL
Redis :6380
Per-day decision state, 48h TTL
Postgres :5433
campaigns · creatives · ad_events
GET /metrics — scraped every 5s
Prometheus :9090
All four services registered as targets
Grafana :3000
Two dashboards, provisioned from files
  • traffic-simulator ad-decision-service POST /ad-request — the serving call
  • ad-decision-service campaign-service GET /campaigns?status=active — over HTTP, never its tables
  • ad-decision-service Redis read caps and spend, then record the impression
  • traffic-simulator event-service POST /events — impression and click reports
  • traffic-simulator campaign-service PATCH /campaigns — this is how a failure is injected

The request path, in order

One trip through the system, from opportunity to recorded fact.

One trip — from opportunity to recorded fact

  1. 01traffic-simulator ad-decision-service
    POST /ad-request { member, slot }
    A member context and a slot: who is watching, and how many seconds are available.
  2. 02ad-decision-service campaign-service
    GET /campaigns?status=active
    The candidate set, read through the public API so campaign-service stays the only writer of its schema.
  3. 03ad-decision-service Redis
    impression counts + today’s spend
    The only state the decision path reads. Day-scoped keys that expire themselves.
  4. 04ad-decision-service the six-rule chain Active → in flight → targeting → brand safety → frequency cap → pacing → creative fit. Each candidate drops at the first rule it fails, carrying that rule’s name out with it.
  5. 05ad-decision-service Redis
    record impression (freq + spend)
    Only on a fill. This is what makes the next request for the same member see a higher count.
  6. 06ad-decision-service traffic-simulator
    200 AdDecision + full candidate trace
    A fill or an explained no-fill — plus which campaign lost, and to which rule.
  7. 07traffic-simulator event-service
    POST /events (impression)
    Carries the decision’s request_id, so every impression traces back to the decision that produced it.
  8. 08traffic-simulator event-service
    POST /events (click)
    Sometimes. A click costs the advertiser nothing here — only impressions carry a price.
  9. 09event-service dedupe, then aggregate on read The caller’s event_id is the primary key, so a retry collides with itself instead of double-counting.

The two state stores, and why there are two

Postgres holds configuration and history: campaigns, budgets, creatives, and every delivered impression. Low write rate, long lived, relational, auditable.

Redis holds the serving path's working set: how many times this member saw this campaign today, and how much this campaign has spent today. Written on every single impression, scoped to one member, and worthless once the day rolls over. That is a counter with an expiry, not a row with a history — so the keys are day-scoped and expire themselves after 48 hours.

These two now deliberately disagree, and the disagreement is meaningful: Redis spend is what the pacer believed at decision time; the Postgres event table is what was actually delivered. The gap between them is reporting loss, and it is a panel on the dashboard.

04 · The services

What each one owns

campaign-service · :8001 · Postgres

The system of record

CRUD over campaigns and creatives. Owns budgets, flight windows, targeting, brand-safety exclusions and frequency caps. Two tables, campaigns and creatives, with a cascade delete.

The interesting bit: it is the only writer of its schema. Nothing else touches those tables — which is precisely what lets the project keep deferring database migrations.

ad-decision-service · :8002 · Redis

The serving path

One endpoint that matters: POST /ad-request. Runs the six-rule chain over the active campaign set and returns a fill or an explained no-fill, with a full trace of which campaign lost to which rule.

The interesting bit: decisioning.py performs no I/O at all. It takes a store and a clock as arguments, so every rule is unit-testable and the whole suite runs with no Redis and no campaign-service.

event-service · :8003 · Postgres

The durable record

Ingests impressions and clicks into an append-only table, then aggregates delivery — impressions, clicks, CTR, spend — on read with a single GROUP BY.

The interesting bit: the caller supplies the event_id and it is the primary key. A retried delivery report collides with itself instead of double-counting. Idempotency is the constraint, not a check.

traffic-simulator · :8004 · stateless

The load and the incidents

Drives seeded, reproducible traffic through the whole stack, and injects five failure modes switchable at runtime over an HTTP control plane.

The interesting bit: it breaks things by changing real configuration through public APIs. No service contains a branch that knows it is being tested.

The data model

Three tables in total across the platform. Small on purpose — every field earns its place by being read by a rule.

Three tables — every field is read by a rule

campaigns
  • iduuid PK
  • advertiserstring
  • statusdraft|active|paused
  • budget_microsint
  • daily_budget_microsint
  • frequency_cap_per_dayint
  • targetingjson
  • brand_safety_exclusionsjson
  • starts_at / ends_atdatetime
creatives
  • iduuid PK
  • campaign_iduuid FK
  • namestring
  • duration_secondsint
  • asset_urlstring
ad_events
  • event_iduuid PK
  • event_typeimpression|click
  • request_iduuid
  • campaign_iduuid
  • creative_iduuid
  • member_idstring
  • price_microsint
  • occurred_atdatetime

campaigns ―< creatives  ·  one campaign carries many creatives, cascade delete
ad_events.request_id the decision that produced it  ·  event_id is the caller’s idempotency key

Note what the event table stores: request_id. Every impression can be traced back to the exact decision that produced it, including the trace of which campaigns lost and why. That link is the reason a Level 3 root-cause agent will be able to answer "why did fill rate drop at 14:20" against evidence rather than by guessing.

05 · The core

The decision path, rule by rule

This is the heart of Level 0. Every candidate campaign walks the same chain and is dropped at the first rule it fails, carrying the name of that rule out with it. The order is not arbitrary — cheap, purely local checks run before anything that requires state, so the expensive work is done on the smallest possible candidate set.

Below: three campaigns entering the chain for one slot, and where each one dies.

StepRuleReason emitted on failureLeft
01 Is the campaign live?Status must be active. A paused or draft flight never reaches the auction floor. not_active 3 / 3
02 Is it in flight?Now must fall between starts_at and ends_at. Pure date arithmetic, no I/O. outside_flight_window 3 / 3
03 Does targeting match?Country, device and content rating. An empty list means unrestricted, not unmatchable. targeting_mismatch 2 / 3
04 Is it brand safe here?No slot content category may appear in the advertiser's exclusion list. Case-insensitive. brand_safety_excluded 2 / 3
05 Is the viewer under the cap?First rule that reads state: today's impression count for this member and campaign, from Redis. frequency_capped 1 / 3
06 Is it within its pacing allowance?Spend so far must fit the budget released linearly by this point in the day. pacing_throttled 1 / 3
07 Does a creative fit the slot?At least one asset no longer than the break. The longest fitting creative wins the slot. no_creative 1 / 3
Selection among survivorsThe slot goes to the campaign with the most daily budget left to spend, ties broken by id for determinism. eligible → fill 1 winner

Why "most budget remaining" wins, rather than "first match"

If the winner were simply the first eligible campaign, whichever campaign happened to sort first would drain its daily budget by mid-morning and then go dark, while under-delivering campaigns stayed under-delivered. Choosing the campaign with the most daily budget left is a crude but real delivery-balancing heuristic: it keeps campaigns tracking toward full delivery of what the advertiser paid for.

In a production system this slot is where an auction lives, and the winner is chosen on expected value — bid, predicted click-through, pacing multipliers, and so on. Level 0 has no auction and no bidding at all. That is the single largest simplification in the whole substrate, and section 10 is explicit about it.

Why pacing has a floor

Pacing releases budget linearly across the day: by noon, half the daily budget is available. Taken literally, that means at 00:00:01 the allowance is effectively zero and nothing can ever serve, because the first impression would exceed the allowance. So the allowance is floored at the price of a single impression — enough for the day to open. It is a one-line detail that is invisible until the day rolls over and the platform mysteriously stops filling.

What comes back

The response is not a boolean and not just an ad. It is the full audit trail of the decision.

POST /ad-request → 200 · no-fill with trace
{
  "request_id": "6f1c…", "slot_id": "slot-241",
  "filled": false,
  "no_fill_reason": "frequency_capped",
  "candidates_considered": 3,
  "trace": [
    { "campaign_name": "Wide-reach snack launch",  "reason": "frequency_capped" },
    { "campaign_name": "Premium sedan, CTV only",  "reason": "targeting_mismatch" },
    { "campaign_name": "Sports drink, mobile",     "reason": "brand_safety_excluded" }
  ],
  "decision_latency_ms": 1.84
}

Three campaigns, three different named reasons, one response. A human debugging a fill-rate drop does not have to reproduce anything — the answer is in the payload. And because those same names are Prometheus label values and structured log fields, the same answer is available in aggregate, over time, on a dashboard.

06 · Failure injection

Breaking it on purpose, for real

The traffic-simulator drives about two ad requests per second through the full stack, reporting impressions and clicks back as delivery events. The population is seeded, so a run is reproducible and an incident can be replayed — which matters more than statistical realism, because ops agents will be scored against runs that must repeat.

It can also break the platform, on demand, over HTTP. The load-bearing rule: failures change real configuration through public APIs. Nothing is mocked, and no service contains a branch that knows it is under test.

ScenarioWhat it actually doesWhat you see
steadyRestores seed targeting, budgets and caps — this is also the rollbackFill rate ~43%, flat latency
error_burstSends genuinely malformed ad requests (30% of them)Error-ratio panel lifts; typed 422s attributable to the caller
traffic_surgeRaises the request rate ten-foldThroughput and p95 latency climb together — load, not a fault
bad_config_deployPATCHes every campaign's targeting to a country with no trafficFill rate 43% → 2%; targeting_mismatch takes over the loss breakdown
budget_runawayInflates one campaign's daily budget and cappacing_throttled vanishes; one campaign takes every slot it can

The reason this matters is downstream. A Level 3 RCA agent evaluated against a mocked failure can only ever discover the mock — the evaluation would measure the fixture, not the agent. Here, when fill rate collapses, the decision path has behaved perfectly; the cause is a wrong value sitting in a real table, reachable by exactly the query a human would run.

Making the change real forces something else that turned out to be the better design: recovery has to be real too. Returning to steady is not a flag that turns the incident off — it restores the seed configuration. Reverting is a rollback, which is precisely the remediation a guided-resolution agent should be recommending.

The quality gate

Level 0's exit criterion, from the design spec, was "all services healthy under simulator load; failure injection works — 100%". That sentence became a script that exits zero or names the check that failed.

python platform/level0_gate.py
  [PASS] campaign-service healthy               HTTP 200
  [PASS] ad-decision-service healthy            HTTP 200
  [PASS] event-service healthy                  HTTP 200
  [PASS] traffic-simulator healthy              HTTP 200
  [PASS] prometheus healthy                     HTTP 200
  [PASS] prometheus scrapes every service       4 targets up
  [PASS] steady serves ads                      15 fills across 56 requests
  [PASS] error_burst produces rejections        16 requests rejected
  [PASS] traffic_surge raises the rate          2 -> 20 requests/sec
  [PASS] bad_config_deploy collapses fill rate  fill rate 2% across 58 requests
  [PASS] budget_runaway changes configuration   1 campaign(s) mutated

  LEVEL 0 GATE: 11/11 (100%)

The gate's decision logic is pure and unit-tested; the live run only supplies numbers. That split is what makes it trustworthy in both directions — it cannot pass by accident with the substrate switched off, and it cannot fail because a test double drifted away from reality. Two deliberate details: "not enough traffic" is never a pass (a fill-rate collapse needs at least ten requests of evidence), and the gate leaves the platform healthy when it exits.

On its first run the gate failed itself: the traffic-surge check read its baseline from the response of the call that switched the scenario, which already reflected the new rate — so it compared 20 requests/sec against 20 requests/sec and never moved. Exactly what a gate is for.

07 · Decisions

Every architectural decision, and the condition that reverses it

Five decision records cover Level 0. Each follows the same shape — context, decision, alternatives, consequences — plus one field that is unusual and is arguably the most valuable practice in the project: the trigger. Every decision names, in advance, the specific observable condition that would make it wrong.

That converts "we'll revisit this later" into a falsifiable statement. Two of the five triggers have now fired on schedule.

ADR-0001 · Accepted

Docker Compose over Kubernetes

Context
Four services, one developer, one laptop, and a recorded demo that has to work every time.
Decision
Docker Compose. One command to a running stack, with published host ports and health checks.
Rejected
Kubernetes (kind/k3s). It would add real operational overhead — manifests, ingress, local registry — while proving nothing about AI foundation engineering, which is the thing being demonstrated.
Consequence
No horizontal scaling, no rolling deploys, no service mesh. All acceptable at this scale. Choosing infrastructure proportionate to the problem is itself the signal.
TriggerNeeding multi-replica behaviour or a deploy strategy that Compose cannot express.
ADR-0002 · Accepted

create_all over Alembic migrations, for now

Context
The schema has one writer, no data anyone depends on, and it will churn daily through Day 5.
Decision
Let SQLAlchemy create tables at startup. No migration tool yet.
Rejected
Alembic from day one. Migrations written against a schema that changes every day are noise, not safety — and noise that has to be maintained.
Consequence
Changing a column type means recreating the table. Perfectly fine while nothing depends on the data; unacceptable the moment something does.
TriggerThe first second service that reads these tables. Deliberately unfired — ad-decision-service reads campaigns over HTTP precisely so this stayed true. But Day 5 found the other half of the cost: budgets live in a 32-bit column, and widening it to BIGINT is exactly the change that now needs migrations.
ADR-0003 · Accepted · trigger fired

Redis holds the decision state, Postgres holds the truth

Context
Frequency counters and daily spend are written on every served impression, scoped per member, and worthless after midnight. That access pattern is a counter with a TTL, not a row with a history.
Decision
Per-day counters live in Redis under self-expiring, day-scoped keys (freq:{member}:{campaign}:{date}, 48h TTL). Campaign configuration stays in Postgres, read over campaign-service's HTTP API rather than its tables.
Rejected
Postgres tables for counters (a row per impression plus an aggregate query on every ad request, and a nightly cleanup of data nobody wants). In-process counters (wrong the moment there is a second replica).
Consequence
Decision state is lost if Redis is flushed — worst case a member sees one extra ad. Acceptable for counters; not acceptable for money, so spend here is explicitly an estimate, not billing.
Trigger — fired on Day 4event-service made impressions durable. The decision narrowed rather than reversing: Postgres now holds the auditable record of what was served, Redis keeps only the hot serving-path counters. Naming the trigger a day early is what made the answer obvious on the day.
ADR-0004 · Accepted

Idempotent on the primary key, aggregated on read

Context
Delivery reports get retried whenever a network hiccups. An impression counted twice is a campaign billed twice.
Decision
The caller supplies the event_id and it is the primary key. The insert either wins or raises a constraint violation, and the second outcome is a 200 duplicate acknowledgement — not an error, because from the caller's point of view the event is recorded. Delivery numbers are then a single GROUP BY, with no rollup table.
Rejected
Check-then-insert: it races — two concurrent reports both read "absent" and both insert. Server-generated ids: they make idempotency impossible by construction, since every retry is a new id. A rollup table: a second copy of the truth that drifts the first time anyone backfills.
Consequence
The events table only grows, and /delivery scans it — fast because the table is small, not because the query is clever. An honest constraint rather than a hidden one.
Trigger/delivery p95 crossing ~200 ms under sustained load, or the events table outgrowing a day of retention. Then a rollup table earns the copy-of-the-truth it costs.
ADR-0005 · Accepted

Failures are injected by changing real configuration, not by mocking

Context
Level 3's ops agents will be scored on finding the right root cause. If the failure is a mock, the only discoverable cause is the mock.
Decision
Every failure mode changes real state through the same public APIs any other client would use. No CHAOS=true flag, no debug endpoint, no synthetic metrics.
Rejected
A chaos env var read by each service: puts test code on the serving path and makes the "root cause" a branch in our own source. Writing synthetic series into Prometheus: dashboards would show an incident that never happened — precisely the mock-data theatre the project exists to avoid. Mutating the database directly: would create states the API forbids, which no real incident could reach.
Consequence
Injecting a failure genuinely breaks the running platform, so the simulator owns recovery, and the seed campaign set becomes product rather than fixture. Injection is bounded by what the API allows — mostly a feature, since it keeps every staged incident reachable in production.
TriggerThe first failure class we genuinely need that has no configuration cause — a network partition, a half-dead replica. That is when a fault-injection proxy earns a place alongside this, not instead of it.

08 · Tool selection

Why each tool, and what was turned down

Every choice here was made against one constraint that outranks the others: the AI layer being built on top has to be able to read, reason about and modify this code. That biases hard toward explicit types, machine-readable contracts and boring, well-documented tools.

ChoiceWhy this oneWhat was turned down, and why
Python 3.11+ The language the whole AI ecosystem lives in. Levels 1–4 need LangGraph, ChromaDB, sentence-transformers and the Anthropic SDK — all Python-first. One language across substrate and AI layer means the context layer parses everything with one AST. Java/Kotlin, which is closer to Netflix's actual backend stack, would have split the repo in two and made the AST knowledge graph a two-parser problem for no demonstrative gain.
FastAPI Generates OpenAPI from type hints for free. That spec is not decoration — it becomes ingestible context on Day 6 and the contract a code-gen agent writes against on Day 11. Dependency injection gives clean test seams. Flask (no typed contracts, no schema generation), Django (an ORM-and-admin framework for four endpoints).
Pydantic v2 Validation at the boundary, in the type system. Its models are the API contract, so an invalid ad request is rejected before a single rule runs. The le= bound on budgets is documentation, validation and OpenAPI in one line. Hand-rolled validation, which drifts from the docs within a week.
Postgres Relational data with real constraints. Level 0 leans on it twice for correctness: cascade deletes, and the primary-key collision that makes event ingestion idempotent without a race. SQLite — used in tests, deliberately, but it does not enforce integer widths, which is why the 32-bit budget ceiling could only be caught against real Postgres.
Redis The access pattern picked it: atomic INCRBY plus a TTL is exactly "a counter that expires". Key expiry means no cleanup job ever has to be written. Postgres counters (wrong shape, needs a nightly delete), in-memory counters (wrong the moment there are two replicas). Netflix's equivalent layer is EVCache, memcached-based; Redis is the closest thing with the same role.
Prometheus + Grafana Pull-based scraping and a label model that fits the domain exactly: one counter, ad_candidates_filtered_total, labelled by reason, is a complete fill-rate diagnosis. Dashboards are checked-in JSON, provisioned from files, validated by a test. Netflix runs Atlas internally, not Prometheus. Atlas is not meaningfully self-hostable for a project like this, and the practice — dimensional metrics, dashboards as code — transfers unchanged.
Docker Compose One command from clone to running stack; the Level 5 quality gate is a ten-minute fresh-clone target. Kubernetes — see ADR-0001.
uv Fast, lockfile-based, and one tool for environments, dependencies and running scripts. A frozen lockfile makes Docker builds reproducible. pip + venv (slow, no lockfile by default), Poetry (slower resolution, more ceremony).
ruff + mypy --strict The strictest reasonable static floor, because AI-generated code is coming. --strict across substrate, tests and platform means a code-gen agent's output has an objective bar to clear before a human ever reads it. Ruff replaces flake8, isort, black and more in one fast binary. Loose typing. Gradual typing would have made the Level 2 "does the generated code pass lint and types" metric close to meaningless.
pytest, hermetic The entire suite passes with Docker stopped. In-memory SQLite, an in-memory decision store, protocol stubs for HTTP. 141 tests in about 5 seconds. Integration-first testing. It is slower, flakier, and — proven on Day 2 — silently passes for the wrong reason.
httpx One client library, sync and async, with a pluggable transport — so the campaign-service client is tested against a mock transport instead of a socket. requests (no async path, no transport seam).
Mermaid for diagrams Diagrams as code: versioned, diffable, reviewable, and reusable in videos. A diagram that lives in a PNG is wrong within a week and nobody notices. Drawn diagrams in any GUI tool.

Two smaller choices with outsized effects

Non-default host ports. Postgres publishes on 5433 and Redis on 6380, because 5432 and 6379 are usually already taken on a working machine. Ten seconds of thought that prevents a whole class of "works on my machine" confusion — and the kind of detail a reviewer notices.

One shared observability module. Prometheus keeps a single registry per process, so two services each defining http_requests_total collide the moment both are imported — which the test suite does. The metric is defined once in substrate/shared/observability.py, and the service label is what separates them. This was found by a test failure on Day 3, not by design.

09 · Design thinking

Nine ideas that shaped everything else

These recur across all four services. Most of them exist because of what has to be built on top.

Named reasons, never booleans

A filter that returns false tells you nothing at 2am. pacing_throttled is simultaneously a field in the API response, a Prometheus label, and a key in the JSON log line. One naming decision produced the response trace, the diagnostic dashboard panel, and the evidence an RCA agent will cite — three artifacts from one idea.

Pure core, injected edges

The decision chain takes a store and a clock as arguments and performs no I/O. Aggregation is a query builder. Failure scenarios are data, not branches. Everything that touches the network or the clock lives at the edge behind a protocol. This is what makes a hermetic test suite possible rather than aspirational.

Tests that cannot touch infrastructure

On Day 2 the suite passed locally and failed CI: the test client was entering a context manager that ran the app lifespan, which created tables against a real Postgres. The tests only passed because Docker happened to be running. Now every service's suite runs with the stack stopped, and that is checked before every push.

Let the constraint enforce it

Idempotent ingestion is not a check, it is a primary key. Two concurrent reports of the same impression cannot both survive an insert. Any rule expressible as a database constraint should be one — application-level checks race, constraints do not.

Write down what would change your mind

Every decision record names, in advance, the observable condition that would reverse it. This turns architectural debt from a vague feeling into a tripwire. When Day 4's trigger fired on schedule, the answer — narrow the decision rather than reverse it — was obvious in minutes because the question had been framed a day earlier.

Typed errors, never a bare 500

Every non-2xx response uses one envelope. An unreachable campaign-service is a 503 naming the upstream, not a stack trace. Day 5 found a genuine violation — a Postgres range error escaping as a 500 — and it was treated as a real defect, because failure modes are a design surface and machine-readable failure is the whole point when an agent is the caller.

Real failures, or the eval measures nothing

The simulator breaks the platform by changing real configuration. When fill rate collapses, the decision path has behaved perfectly against a wrong value in a real table. Anything less and Level 3's evaluation is scoring an agent's ability to find a fixture.

Observability as a design surface

Metrics and log fields were chosen at the same time as the rules, not bolted on afterwards. That is why one stacked panel — candidates lost, by rule — answers "why did fill rate drop" directly, instead of requiring three dashboards and a hunch.

Build the evidence for the next level

Almost every Level 0 choice was made for a consumer that does not exist yet. The decision trace exists for the RCA agent. The runbook exists for the context layer. The OpenAPI specs exist for the code-gen agent. Strict typing exists so generated code has a bar to clear. Level 0 is infrastructure for the argument, not just for the app.

10 · Fidelity

How close is this to the real Netflix system?

Sourcing note — read this before the table

I have no visibility into Netflix's internal architecture, and nothing here should be read as describing it. This comparison is against two things only: publicly reported facts about Netflix's ads business, and standard practice in connected-TV ad tech, which is well documented across the industry.

What is public and well reported: Netflix launched an ad-supported tier in November 2022; Microsoft (Xandr) was its initial ad-serving and sales partner; Netflix subsequently built and rolled out its own ad platform, branded the Netflix Ads Suite, from 2025. Netflix's general engineering stack is also publicly documented through its tech blog and open-source projects — JVM-heavy services, Cassandra, EVCache, Kafka, Flink, Titus, Spinnaker, and Atlas for telemetry.

Everything in the "production reality" column below is either one of those public facts or a statement about how CTV ad systems generally work. Where I am inferring, the row says so.

ConcernWhat Level 0 doesProduction realityFidelity
Domain vocabulary Ad request, slot, campaign, creative, flight, targeting, brand safety, frequency cap, pacing, fill rate, no-fill, micros The same words, used the same way. This is industry-standard terminology, not invented.
High
Service decomposition Campaign management / decisioning / event collection split into separate services with separate stores The same three responsibilities are separated in essentially every ad platform, though each is many services rather than one.
High shape
Filter-then-select Eligibility filtering, then a selection step over survivors Candidate selection → filtering → ranking is the standard shape. The kind of pipeline is right.
High shape
Frequency capping Per member, per campaign, per day, in a low-latency KV store with TTL Same idea and same class of store. Production adds cross-device identity, multiple cap windows (hour/day/week/campaign/advertiser), household-level capping, and distributed-consistency concerns this has none of.
Medium
Budget pacing Even pacing: budget released linearly across the day Even pacing is a real, named strategy. Production runs closed-loop controllers with delivery forecasting, catch-up logic, and ASAP/front-loaded modes.
Medium
Brand safety Advertiser excludes content categories; case-insensitive set intersection Right concept. Production involves classification pipelines, third-party verification vendors, per-title review, and contractual guarantees.
Medium
Observability Dimensional metrics, structured JSON logs, dashboards as provisioned code The practice is standard and transfers directly. The tools differ — Netflix uses Atlas rather than Prometheus/Grafana.
Practice matches
Event pipeline Synchronous HTTP POST into Postgres; aggregate on read Real platforms stream: a log/Kafka-style bus, stream processing, columnar warehouse, and separate real-time and batch paths. The idempotency idea is right; the transport and scale are not.
Low
Ad selection economics Flat price per impression; winner is whoever has the most daily budget left An auction. Bids, expected-value ranking, price computation, guaranteed vs biddable inventory, deal IDs. This is the single largest simplification in the substrate.
Low
Latency & scale ~2 requests/sec, one replica, single region, sub-millisecond rule evaluation Enormous request volume, strict end-to-end latency budgets, multi-region, aggressive caching of the candidate set.
Low
Delivery mechanics Returns an asset URL and considers the job done Server-side ad insertion, manifest manipulation, transcoding to many renditions, CDN delivery, player beacons, VAST/VMAP.
Absent
Identity, consent, privacy A member id string. Nothing else. Identity resolution, consent frameworks, regional privacy regimes, data clean rooms, audience segments and match rates.
Absent
Measurement & billing Spend is an explicit estimate at a flat price; the docs say so repeatedly Verified impressions, viewability, invalid-traffic detection, third-party measurement, reconciliation, actual invoicing.
Absent

The honest summary

Level 0 is structurally faithful and operationally miniature. The concepts, their names, their ordering and the shape of the services would all be recognisable to someone who works on CTV ad serving. The economics, the scale, the delivery mechanics and the entire privacy surface are either absent or reduced to a placeholder.

That trade is deliberate and it is the correct one for this project, because the substrate is not the deliverable. It exists to generate real traffic, real metrics, real logs and real incidents for an AI platform to operate on. Adding an auction would make the ads platform more impressive and the AI demonstration no better. Every hour spent on ad-tech sophistication is an hour not spent on the thing the job posting actually asks about.

The claim being made is precise: this person can build a production-shaped distributed system in the ads domain, and reason clearly about what they simplified and why. Not: this person rebuilt Netflix's ad server.

11 · Scope

What Level 0 deliberately does not do

Naming omissions explicitly is part of the argument. An omission you can name is a scoping decision; an omission you cannot is a gap.

  • No auction or bidding. No bids, no second-price mechanics, no floor prices, no deal IDs, no guaranteed-versus-biddable inventory.
  • No machine-learned ranking. No CTR prediction, no relevance model, no experimentation framework. The selection rule is one line and is explained as a heuristic.
  • No identity, consent or privacy layer. A member id is an opaque string; there are no segments, no consent signals, no regional regimes.
  • No ad delivery. No transcoding, no CDN, no server-side insertion, no manifest manipulation, no VAST, no player beacons.
  • No streaming data platform. Events arrive over HTTP and land in Postgres. There is no Kafka, no stream processor, no warehouse, no batch/real-time split.
  • No forecasting or inventory management. No avails prediction, no reach-and-frequency planning, no overbooking protection.
  • No multi-tenancy or authentication. Every API is open on localhost. Deliberate for a demo stack; disqualifying for anything else.
  • No horizontal scale story. One replica per service. Notably, the frequency counters would survive replication (they are in Redis), while the in-process fallback store would not — and that is documented rather than hidden.

Each of these would be a reasonable thing to build. None of them would make the AI foundation on top of it any more convincing, which is the only test that mattered.

12 · Evidence

The numbers, and what running it found

The project's stated thesis is "AI velocity with provable quality", so Level 0 publishes its own numbers before any AI exists to be measured.

11/11
Level 0 gate
141
Tests, Docker stopped
~5s
Full suite runtime
43%
Baseline fill rate
2%
Fill rate under bad config
0
mypy --strict errors

Three real defects, found by pointing real load at it

This is the strongest evidence that the substrate is not theatre. On its first day of running, the simulator found three genuine defects in the platform it was pointed at.

  1. Budgets overflow a 32-bit column. Micros were stored in a SQLAlchemy Integer, which is a Postgres INTEGER — capping a daily budget at 2,147,483,647 micros, about $2,147. The budget-runaway scenario tried to exceed it and Postgres raised a range error. The ceiling is now stated in the API contract as a validation bound; the real fix, widening the column to BIGINT, is exactly the change that ADR-0002 is waiting on migrations for.
  2. That overflow escaped as a bare 500 — a direct violation of the project's own coding standard. Now a typed 422, with a handler ensuring no column's range error can ever surface untyped again.
  3. The seed guard was all-or-nothing. One leftover campaign from a manual test suppressed the entire seed campaign set, and the simulator drove traffic against a single narrowly-targeted flight at a 2% fill rate, with 828 candidates rejected for targeting_mismatch. Seeding is now idempotent per campaign, by name.

And a fourth, in the gate itself: it failed on its first run because it read its baseline from the response of the call that changed the state it was measuring.

None of these would have been found by a test suite. All four came from running the real thing under real load and looking at the numbers — which is the entire argument for building a substrate.

13 · Reference

Glossary

TermMeaning in this system
ad requestOne opportunity to serve an ad: a member context plus a slot.
slotThe ad break being filled — duration, content rating, content categories.
campaignAn advertiser's booking: budget, flight window, targeting, exclusions, cap, creatives.
creativeOne ad asset belonging to a campaign, with a duration that must fit the slot.
flight windowThe period between starts_at and ends_at during which a campaign may serve.
targetingCountry, device type and content rating the campaign will accept. Empty means unrestricted.
brand safetyContent categories the advertiser refuses to appear beside.
frequency capMaximum impressions of one campaign to one member per day.
pacingSpreading a daily budget evenly across the day so it is not exhausted early.
fill / no-fillWhether an ad request produced an ad. Fill rate is the share that did.
impressionOne ad actually played. The only event that costs the advertiser anything here.
CTRClick-through rate: clicks divided by impressions.
microsMillionths of a currency unit, stored as integers. Money is never a float.
traceThe per-candidate audit trail on every decision: which campaign lost, to which rule.
scenarioAn injectable failure mode, applied by changing real configuration.
triggerThe pre-declared condition that would reverse an architectural decision.

14 · Downstream

What Level 0 feeds

Every artifact built in Days 1–5 becomes an input to something later. This is the payoff for choices that looked like over-engineering at the time.

LevelWhat it consumes from Level 0
1 · Context layer
Days 6–10
The corpus itself: four services of typed Python, five decision records, five devlogs, the Level 0 runbook, four OpenAPI specs. All written because the project needed them — which is why the retrieval evaluation means something.
2 · Dev agents
Days 11–15
A real codebase with enforced conventions. Generated code must pass ruff and mypy --strict; generated tests must survive mutation testing; the PR reviewer is grounded in the ADRs and the standards doc.
3 · Ops agents
Days 16–20
The whole reason the substrate exists: structured logs with named rejection reasons, dimensional metrics, and five injectable incidents with real causes. Triage, correlation and root-cause analysis against evidence, not fixtures.
4 · Orchestration
Days 21–25
A system small enough for parallel agents to change coherently, with a test suite fast and hermetic enough to gate every one of their proposals.
5 · Platform & launch
Days 26–30
The one-command stack behind the ten-minute fresh-clone target, and the Level 0 gate as the first published row on the eval scoreboard.

Level 0 ends with four services running, real traffic flowing, dashboards live, five incidents stageable on demand, and a gate that says 11 out of 11. From here the substrate stops being the thing under construction and becomes the thing the AI knows about.