September 15, 2026
A cache with no way back to the source
The report
A founder listed an X (Twitter) profile — Kingpin lets you point a listing at
https://x.com/<handle> instead of a normal website. On their dashboard, the picker button
for that listing showed no usable name. Not their handle, not their profile, nothing worth
recognizing as "mine." The ask was specific: it should show either the founder's own profile
name, or the X handle (@username) — and it should never be a dead end for any listing,
generic or social.
Two sources of the same fact, one of them frozen at birth
Checkout deliberately asks for nothing but a URL — no title field on the add-listing form. So a listing's name doesn't exist yet the moment money lands; it's filled in later, two different ways depending on the listing:
- for a normal site,
lib/sitemeta.mjs'scaptureSiteMeta()fetches the destination page, during async moderation, and adopts its real<title>/og:title. - for an X or Instagram profile, fetching is close to useless — both platforms serve a
logged-out crawler a login wall, so the page's own
<title>says "X. It's what's happening," not the founder's name.lib/social.mjs'ssocialTitle()exists specifically to produce@handleinstead, andcaptureSiteMetaprefers it when the URL is recognized as a social profile.
That part works. The canonical listing row (CATEGORY#<cat>/ITEM#<id>) does end up with
Title: '@theirhandle' once moderation runs.
The dashboard's picker, though, doesn't read the canonical row. It reads a separate
ownership-index row, FOUNDER#<id>/LISTING#<cat>#<id> (founder-listings.mjs, a bounded
Query on the founder's own session — the right call, since the alternative is Querying
every category's listings and filtering, which doesn't scale and isn't even ownership-scoped).
That row exists so the dashboard doesn't have to fan out across every category to find "my
listings," and it carries its own copy of Title for exactly the reason indexes usually
duplicate a field: so the read that needs it doesn't have to join.
The bug is in when that copy gets written. linkFounderToListing()
(src/lambda/lib/founder.mjs) runs synchronously inside the checkout handler
(payments-consumer.mjs), before the listing has gone through moderation, which is where
captureSiteMeta actually runs. At that moment there is no real title yet — checkout never
asked for one. So the ownership-index row gets written with Title: '', and then nothing
ever touches it again. captureSiteMeta faithfully updates the canonical row later; it has
no idea a second, independent copy of the same fact exists elsewhere and needs the same
update.
checkout (payments-consumer.mjs)
→ writes CATEGORY#/ITEM# row, Title = hostname placeholder
→ writes FOUNDER#/LISTING# row, Title = '' ← frozen here, forever
→ enqueues moderation
moderation (moderate.mjs, async, seconds later)
→ captureSiteMeta() updates CATEGORY#/ITEM#, Title = '@handle' ← the real value lands
→ (nothing updates FOUNDER#/LISTING#) ← stays ''
This is the same shape of bug as ADR-027/entry 003's decay-vs-downvotes question, just one level more mundane: two representations of one fact, updated on different schedules, with nothing keeping them in sync. An index is a cache. A cache that's written once and never invalidated isn't a performance optimization, it's a slow-motion staleness bug with a delay before it's noticed.
The fix: write-time sync, plus a read-time self-heal
Two changes, addressing two different timeframes:
Going forward — keep the copies from drifting apart. captureSiteMeta() now asks
DynamoDB to hand back the updated canonical row (ReturnValues: 'ALL_NEW' on the UpdateItem
it already had to do) and, if a FounderId is present on it, writes the same title into the
ownership-index row right there:
// lib/sitemeta.mjs, right after the canonical Title/TitleSafe write
const founderId = updated?.Attributes?.FounderId;
if (title && founderId) {
await client.send(new UpdateCommand({
Key: { PK: `FOUNDER#${founderId}`, SK: `LISTING#${category}#${listingId}` },
UpdateExpression: 'SET Title = :t',
ConditionExpression: 'attribute_exists(PK)',
ExpressionAttributeValues: { ':t': title },
})).catch((err) => log.warn({ ... })); // best-effort — never blocks a paid listing
}
It's deliberately best-effort and swallowed on failure, for the same reason every write in this function already is: this is decoration on top of money that has already been captured. A missed sync here should never be able to fail a real listing.
It's also deliberately not guaranteed to run before FounderId exists —
linkFounderToListing and captureSiteMeta fire from two different Lambda invocations
(checkout vs. moderation) with no ordering contract between them. If moderation happens to
run first, FounderId won't be on the row yet, and this sync silently does nothing. That's
why there's a second half to the fix.
Already-broken rows — self-heal on read, instead of waiting for a rewrite that will never
come. The founder who reported this has a listing whose ownership-index Title was already
frozen at '' before this fix existed at all — the write-time fix only prevents new drift,
it can't retroactively repair a value that was already written wrong. founder-listings.mjs
now checks for that case directly:
// GET /founder/listings, per row
let title = item.Title ?? '';
if (!title) {
const live = await client.send(new GetCommand({ Key: canonicalKey })); // one point read
if (live.Item?.TitleSafe) {
title = live.Item.TitleSafe;
// repair the cached row so this founder's next load doesn't pay the read again
await client.send(new UpdateCommand({ Key: indexKey, ... Title: title })).catch(() => {});
}
}
This isn't a Scan, and it isn't the public feed/board read path — invariant 5 ("reads never
hit DynamoDB") governs the materialized, unauthenticated, high-traffic surfaces; this is a
single authenticated founder's own small, already-bounded result set, and the extra read only
happens for the specific rows that are actually blank. It fixes the founder's dashboard on
their very next load, with no backfill script and no manual data fix.
Why "Untitled" stays as the last-resort fallback
A brand-new listing that hasn't been through moderation yet genuinely has no title anywhere — neither copy exists. Showing "Untitled" for that case is honest: it says "this is real, it's just not named yet," which is different from silently mislabeling a properly-titled listing. The bug wasn't that a fallback string exists; it was that the fallback was triggering for listings that unambiguously did have a real name sitting one table read away, because the code was looking in the one place that had never been told about it.
The generalisable lesson
Before adding a second copy of a fact for a legitimate reason (an index, a cache, a denormalized field for a query pattern the primary shape can't serve), write down every place the first copy can change after the second one is created — and make each of those writers responsible for the copy too, not just the field's original writer. A denormalized field's staleness bug never announces itself at write time; it only shows up later, to whoever reads the stale copy, looking exactly like a missing feature rather than a sync gap.
And, paired with it: a self-healing read is worth adding whenever a cache can already be stale in production before the write-time fix ships — it turns "we shipped a fix, but existing users are still broken until some backfill job runs" into "existing users are fixed on their very next request," for the cost of one conditional point-read on the empty-cache path only.