Platform changelog
What has shipped on the Zeso platform.
Platform changelog
The renewal sweep now runs itself — and knows when it hasn't run.
Everything below this entry about renewals shared a quiet flaw: the database could refuse a charge we had not warned you about, and the renewal planner could produce a correct plan — and nothing ran it on a schedule. A renewal system that runs when a human remembers is not a renewal system.
So it runs daily now. The interesting part is what we had to build to notice when it doesn't.
Every safeguard we already had asks a question about records that exist — a charge with no warning, a domain that expired while still resolving. A sweep that never runs writes no records at all, so it passes all of them, and it looks exactly like a quiet week with nothing due. The failure is not a wrong record; it is the absence of a trace.
So every run leaves a heartbeat: it signs in when it starts and signs out when it finishes, and a check compares that against what was supposed to happen. That separates five things we could not previously tell apart — the job was never scheduled, it runs and always fails, it stopped firing, it died halfway through, or it is healthy — and each one needs a different fix.
Two more pieces came with it:
- The schedule is set so a missed day cannot cost you a warning. It runs
daily and tolerates two missed days, because our tightest obligation is ICANN's five-day post-expiry window. A test fails the build if either number grows past what that window can absorb: a sweep that runs too rarely does not send late warnings, it sends none for the windows it steps over.
- Old warnings are now actually deleted when their time is up. We said we keep
them seven years and that deleting them was permitted — and nothing was doing it. The same daily run now clears the queue, only ever what is already past its retention, and safely if it happens to run twice.
We tested the failure itself rather than trusting the happy path: the simulation drops the sweep for two days, then confirms the silence is detected, cleared by the run that catches up, and — the part that matters — that the two lost days cost no warning. A missed sweep delays a warning. It does not lose one.
We published the fees ICANN requires. Doing it corrected a citation we had wrong.
Closing the last open compliance item turned up a more interesting problem than the missing page.
The obligation had been written down as ERRP §3.3 — which actually says "registrars must permit the RAE to redeem a deleted registration". That is a behaviour, and we honour it. It is not a disclosure. The disclosure is §4.1.1, and it names three fees, not one: the renewal fee, the post-expiration renewal fee (if different), and the redemption/restore fee — all three to be clearly displayed on the site, with a link to them in the registration agreement.
The wrong paragraph was the real defect. A citation that points at a permission while the obligation is a publication makes an empty schedule read as a complete one — which is precisely what had happened: an audit row printed green over nothing, for as long as the citation went unchecked.
All three fees are now published at /pricing#fees, rendered from the same constant the audit reads so the page and the check cannot drift apart, and carried by the registration response so the agreement links to them. The registry's restore fee is a pass-through at zero markup — around $41.40 on a .com, quoted live for the specific name and never auto-charged. And because it is a sale rather than our revenue, it sits inside the tax base while adding nothing to margin: the two facts that sound like a contradiction until you separate "what is taxable" from "what we earn".
We also attack the guard rather than only running it. The audit hands it six deliberately broken schedules — a dropped fee, a marked-up pass-through, a restore fee booked as our own revenue, one chargeable without asking, one published as a fixed price, and an agreement linked to a page that does not exist — and each one must be refused.
The renewal system finally ran. Running it found six more bugs.
The two entries below this one end with a sentence that had started to be uncomfortable: the schema could prove a charge had been noticed, and refused one that had not, but nothing had ever tried to charge. Every guarantee was a guarantee about a system that had not run.
So the sweep exists, and it was played out over 900 days against the real database — a simulated gateway answering every presentment, a simulated registrar answering the expiry question, and a list of invariants checked on every one of those days. Not one of them may be violated, and the run exits non-zero if any is.
It found six defects, and they were all the same defect in different clothes: a rule that is true of hosting applied to a domain registration.
- A domain's renewal was presented on its expiry date. So the very first
declined card is already past expiry, ICANN's rules then require DNS to be interrupted, and the customer's site goes dark because their card expired. That was the routine path, not an edge case.
- Domains had no recovery path at all. A failed charge left the name active
forever: no retry, no grace window, and the sweep re-planned the same charge every single day. A customer's lapsed card was never retried — the most expensive ordinary failure we have, because the customer loses the name.
- Retries recorded nothing. A retry moved a counter and wrote no payment
record, so the ladder's own effectiveness could never be measured, which is the entire reason that table exists.
- Eligibility never asked whether we had already presented for that period.
- The sweep's look-ahead was borrowed from the domain policy, which was
invisible while both products happened to need the same number of days — and silently wrong the moment either moved.
- A charge could share a day with the notice that authorises it. The database
accepts a notice and a charge in the same second, and that is not notice: it is a receipt.
The last one is the one that justifies the whole exercise, because it was found by running the thing rather than reading it — on the simulation's first successful run. So the invariants were then deliberately broken, four different ways, to check that each is actually capable of failing. Reintroducing the re-planning bug produces 58 daily refusals, which is what it should do.
Three of the fixes changed the database, not just the code. A renewal order is per period and every attempt against it shares it, because the index that guarantees "at most one successful payment" keys off the order — giving each attempt its own order would have retired that guarantee the moment retries became real. The uniqueness backstop is now per retry, not just per opening attempt, closing the same double-charge hole one rung further down where it is much harder to see. And domains gained the ladder's state, including a grace column the schema had provisioned and nothing had ever written — dead schema is the tell that a state machine was described and never built.
One asymmetry is now stated in the code rather than left implicit, because all six bugs lived in the gap: a subscription's period end is a billing date we chose, while a domain's is a deadline the registry enforces and ICANN attaches obligations to that nobody can opt out of. So a hosting lapse ends in expired — our service stops — while a domain's ladder ends in nothing at all: the expiry comes from the registrar, and the software refuses to mark a domain expired from our own billing state, because doing that while we have in fact renewed the name would take a paying customer's site down.
What is still open is the money question underneath it, and it is now the largest one in this area: do we renew a domain at the registry before collecting, or collect first and let the name lapse if the card never clears? We currently collect first, which means a card that never clears costs the customer the name — with the post-expiry notice and the 30-day redemption window as the recovery path. Most registrars do the opposite and renew first, which protects the customer's name and costs us a wholesale renewal for every auto-renewing name that never pays. That is a business decision with a number attached, not a technical one, and it is written down rather than guessed at.
Also still true, and worth repeating because it is easy to read the above as "done": nothing schedules the sweep yet. It runs when it is called. The absence of a notice looks exactly like a period with nothing due, which is why the alarm for "this never ran" is part of the same job rather than an afterthought.
The renewal system was reviewed against ICANN and against the database. Both found bugs.
Three defects, all in code that had already passed its own audit — and none visible by inspection. Each one appeared the moment something tried to violate it, which is the argument for checking a promise by attacking it rather than by reading it.
The append-only notice log made every customer undeletable. Blocking deletes on the notice log was meant to make "we told you before we charged you" provable. It did that, and it also broke the three ON DELETE CASCADE relationships pointing into that table: deleting a customer cascaded into the log, the log refused, and the delete aborted. So three declared cascades could never fire, and GDPR erasure was impossible — a schema advertising behaviour it could not deliver, which is the same class of defect as copy that over-promises.
The fix says what is actually true instead of weakening the promise. A notice log holds no direct personal data — an opaque customer id, an amount, a period, a template key — so keeping it is not a privacy problem, and erasing the customer does not require deleting it. Retention is now explicit and the append-only rule is scoped to it: you cannot make the evidence disappear while it is still evidence, and past retention the row is a liability that must be purgeable. "Append-only forever" is not a policy, it is a hoard, and it was the thing that made erasure impossible.
Erasure is now redaction of the customer record, and the database refuses a partial one: every personal field cleared, the email pseudonymised to a non-routable address, and no live domain or subscription left behind — the registrar needs a full registrant contact for every registration.
The domain notice schedule did not meet ICANN's rules. The Expired Registration Recovery Policy requires two notices before expiry (roughly a month, then a week) and one within five days after it, telling the customer how to renew, in the registration agreement's language, delivered without requiring a click. Only the first two existed. Worse, they were fixed to days 30 and 7 rather than to ERRP's acceptable ranges, so a job firing a day late would have been out of compliance while looking perfectly healthy.
Notices are now windows with a signed offset, because a model that can only express "before expiry" cannot represent a compliant domain schedule at all. A build guard fails if those ranges drift, if the post-expiry notice loses its renewal instructions, or if a window could ever land on the charge day — a notice that arrives with the charge is a receipt.
And we had promised something we cannot deliver. "Your site stays up while we sort out your card" is true of hosting and false of a domain: ERRP requires DNS resolution to be interrupted from the moment a gTLD registration expires. A customer whose name has actually lapsed cannot reach their site, however generous our grace window is.
The state that rescues the promise is one the schema did not have. auto_renew_pending means we have already renewed at the registry and are collecting afterwards, so the name never lapsed, DNS is intact, and the site genuinely does stay up. That is the ordinary commercial practice, and it is the only state in which the promise holds. So what we may tell a customer is now scoped per product, and a guard fails the build if the domain version claims the service stays up or quietly drops its DNS caveat — because the failure mode here is a sentence creeping back into site copy, not a line of code.
Two things we are not doing, written down rather than left implied. We neither charge nor disclose a redemption fee, which ERRP and the RRA require us to publish and to state at registration — a disclosure duty, not a feature, and the audit prints it as an open gap rather than failing the build, because a known gap should be visible and not block every deploy. And retention is not yet a behaviour: the schema permits the purge and the queue exists, and nothing drains it. (The notice, retry, dunning and DNS-interruption jobs do now run — see the entry above.)
Corrections, added later rather than written back into the paragraph above. The fee disclosure is closed, and closing it fixed a citation: the duty had been filed under ERRP §3.3, which is a permission, not a publication — the disclosure is §4.1.1, and it names three fees rather than one. See the entry at the top of this log. The purge gap in the second sentence still stands, and so does the point about the purge being a document rather than a behaviour.
One limitation is worth stating plainly. The enforcement keys off a flag the caller supplies, so it protects against forgetting to mark a renewal — the realistic failure at this size — and not against lying about it. Deciding whether a charge is a renewal needs to know whether the subject's period had already begun, which is application state. So it is detected by a monitoring query instead of enforced, and detection is a weaker guarantee. Saying so is part of the guarantee.
Auto-renewal, and the promises behind it
A renewed paid year contributes +$71.88 on Launch — identical to any later paid year — but needs no free year and no invitation. It needs a notice and a working card. That makes it the cheapest revenue event in the business, and it is the number that ties renewal back to the free tier: $13.98 / $71.88 = 0.1945, which is exactly the 19.45% month-13 conversion threshold the free year has to clear. They are the same quantity at two scales, so a failed renewal is not a retention problem sitting beside the acquisition budget — it is the acquisition budget failing to clear.
Renewal is therefore pushed hard, and the line between aggressive and predatory is now written down as RENEWAL_POLICY in lib/renewals.ts: auto-renew on by default; notices at 30d and 7d (plus 1d for domains) that must state the amount, the date, and what happens if you do nothing; retries at +1/+3/+7/+14 days; a 30-day grace window; no late fee; no reactivation fee; no contact required to cancel; and no silent plan change on renewal.
Reviewing the schema against those promises found they were not merely unwritten — they were unwritable. 0001_init.sql had auto_renew and current_period_end and nothing else: nowhere to record that a notice was sent, nowhere to record a failed payment, and no grace window. "You are told before you are charged" was unverifiable by construction, and in a dispute the customer's account beats our absent row.
migrations/0002_renewals.sql makes three of them impossible to violate rather than merely recorded. A renewal charge cannot be inserted without a notice already on record for that period, stamped at or before the charge — a notice created alongside the charge is refused, because that is a receipt, not a notice. A renewal charge cannot be inserted on a subscription or domain with auto_renew = 0. And renewal_notices is append-only on both UPDATE and DELETE, because a log you can edit proves nothing, and the specific abuse is back-dating a notice to before a charge that already happened.
These are triggers rather than application rules for a narrow reason: each one is a rule a growth-minded edit removes, none are visible in any screenshot, and all three fail in a way that cannot be corrected afterwards. Application code refuses them too; the trigger is the floor that holds when the application is replaced.
pnpm audit:renewals does not read the migration and agree with it. It executes both migrations against a real SQLite engine and then attacks the promises — 22 attempts that must each be refused or allowed exactly as specified, printing the database's own error message, and exiting non-zero if a charge that should be refused is allowed. The assumed recovery figures (25% of lapses are a failed card, 60% of those recovered) are labelled as estimates so that payment_attempts can replace them with our own numbers once it is collecting data.
Also settled: when a domain and a plan are bought together they renew on one date. The two default renewal sweeps would otherwise produce two debits in one month for one customer, which reads as a double charge — and is the most effective way there is to make an honest renewal feel like a trap.
The free tier split in two, and one half became invite-only
"The free tier" turned out to be two products with opposite economics behind one word, and treating them the same would have been wrong in both directions.
The free App year costs us $28.97 to serve ($10.97 domain, $18.00 hosting) against $14.99 collected — year one is −$13.98, and it only pays for itself above 19.45% conversion. That one is now invited: not sold on the pricing page, not self-serve, reached by invitation, and disclosed in the pricing FAQ in plain words. Gated rather than hidden — the offer is a fact anyone can read about, just not a tier anyone can open.
The single free page on static hosting is the opposite: funded by the $4.02/yr domain renewal margin against $3.00/yr of static serving, so it earns +$1.02/yr while the domain stays. That one is included — provisioned with every domain registration, exactly as the pipeline documentation already described, with no invitation and no change in behaviour.
Collapsing both into one "hidden free tier" would have forced the free page behind an invitation and contradicted shipped behaviour. Plan.availability therefore has three states rather than a boolean, and the guard fails if a plan is reachable by no route, or by the wrong one.
Why gate the App year at all. Every figure above scales with SERVING_COST_USD, which is still an estimate. At $3/mo instead of $1.50/mo the break-even conversion moves from roughly 19% to roughly 48% — a different strategy. Publishing an unbounded offer priced against a number we have not measured is not aggression, it is an unhedged position. Gating converts it into figures: 500 redemptions across four channels, −$6,990 worst case and +$7,385 over the cohort at target conversion, printed by pnpm audit:invites alongside the same numbers at 1.5×, 2× and 3× the serving estimate.
It also buys what a public tier cannot: every invitation carries the channel that issued it, so "which campaign paid for itself" becomes a query instead of a guess.
Three rules are enforced in code rather than agreed in a document. An invitation never requires a card — that is a prepaid trial, not a gift. It never discounts the domain, because the domain is the paid gate and discounting it pays twice for one acquisition. And the price it converts into must be on the public pricing page, so the invitation page shows exactly what year two costs before the claim rather than on the first invoice. A fourth falls out of the split: an invitation may never grant something that is already free to everyone, because that is theatre.
The included page is a churn bet, not a conversion bet. It has no conversion event to amortise against, so it pays only while at least 42.7% of registrations keep the domain registered here. Past 57.3% churn it becomes a net cost that scales with registrations — and unlike the invited program, it has no cap at all. Nothing currently reconciles a domain leaving against a live free page.
Also corrected: two places on the site claimed the domain's first-year markup funds the free year. It does not. The markup is $4.02 against $28.97 of cost, and the gap is acquisition spend we carry deliberately. Saying otherwise would have hidden the exact number the program has to be managed against.
Our .com price was measured against the market
We priced our .com at $14.99 and had never checked whether that was competitive. It is not: against a dated snapshot of twelve registrars we rank 11th of 13 on first-year price and 9th of 13 on five-year cost of ownership. Cloudflare sells the same domain at cost, $10.44, flat; Porkbun at $11.08, flat.
The more useful finding is why it cannot be fixed. Our margin policy refuses to sell below cost + $2.50, so the cheapest price we are ever permitted to publish is $13.99 — already $3.55 above Cloudflare's shelf price. No discount available inside our own rules reaches an at-cost seller, and matching one would mean selling at a loss to lose anyway. So the domain is a component of the bundle, not a stand-alone product, and the acquisition spend stays where the year-one arithmetic is positive.
That measurement also killed a claim we had started making. A flat renewal is table stakes, not a differentiator: seven of the twelve registrars surveyed already hold year two at year one. The promotional-then-hike pattern belongs to hosts that bundle a domain as a loss leader — which is why the hosting renewal table is honest, and why a domain renewal table would not have been.
The site now states this in the unflattering direction, with the figures generated from the same snapshot the build audits. assertPositioningClaimsHold() fails the build if the market stops undercutting us, if our floor ever falls below an at-cost rival, or if the hosting comparison loses its cheapest competitor — so the copy cannot outlive the facts behind it.
Also fixed: the flagship category (.com, .net, .org) was missing from the Domains page grid. The cheap promotional TLDs were rendering and .com, the one everybody price-checks, was not.
Pricing restructured around renewal honesty
Domain markups are now set per category rather than by one global rate, and .com is verified against Verisign's published wholesale price — $10.97 from 2026-11-01. The previous snapshot carried $9.15, which means the margin audit was guarding a cost we had stopped paying; an audit cannot catch a wrong input. Every verified cost now records its source and date inline.
A second build-time invariant, assertNoBaitAndSwitch(), proves our renewal price only moves when the registry's cost moves — and holds exactly where the registry holds. .com now renews at $14.99 against a first year of $14.99: a 0% uplift. The pricing page carries the published entry-tier rates for the major hosts, dated and attributed, with the cheapest competitor left in on purpose — these are hosting companies bundling a domain as a loss leader, and their renewal jumps run to 17×. Registrars are a different market with a different answer; see the entry above.
The Domains page now shows a five-year total on every TLD, because first-year prices are the wrong column for comparison.
Domain pricing derived from cost
Retail prices are now generated from a wholesale snapshot with a per-category markup and a $2.50/year margin floor. A margin audit runs over the catalogue and fails the build if any TLD would sell below the floor. This caught a renewal price that was losing $20.01 per year.
Free-domain rules made explicit
A domain is free for year one only on qualifying annual hosting plans. The rule lives in one function, is enforced server-side, and cannot be requested by the client.
Provisioning pipeline
Registration now triggers a resumable, idempotent eight-step pipeline covering zones, delegation, certificates, sites and email. A stalled run is picked up by the sweep rather than requiring a human.
Onboarding checklist
A customer-facing checklist, separate from the internal pipeline, so internal retries never make the progress bar flicker.