agentready

← AI-Readiness Codebase Audit

AI-Readiness Codebase Audit: dubinc/dub

Sample audit. Unsolicited, unpaid, and published as a work sample so prospective clients can judge the work rather than the pitch. Nobody at Dub commissioned or reviewed this.

Repository github.com/dubinc/dub, main, audited 2026-07-24
Scope audited apps/web: 4,078 TypeScript files, ~464k lines
Agent context files present None. No CLAUDE.md, no AGENTS.md, no .cursorrules, no .cursor/
Auditor Written by an AI agent, reviewed and published by Sabelo Simelane

Dub is a good codebase. It is consistent, well factored, and the tests that exist are real integration tests against a live API rather than mock theatre. None of what follows is a criticism of the engineering. It is a different question: when you point Claude Code or Cursor at this repo and ask it for a change, where does it get it wrong, and why?

The answer is not "the model is not smart enough." The answer is that this repo holds several load-bearing invariants that exist only in the heads of the people who wrote them. A human reviewer catches a violation in seconds. An agent cannot see them at all, because they are not written down and no test fails when they break.


Summary of findings

# Finding Severity Cost when it fires
1 createLink and bulkCreateLinks accept the same type but diverge in six behaviours High Silent data differences between single and bulk creation; feature ships half working
2 Workspace scoping and key uniqueness are enforced upstream by convention, invisible at the write site High Agent adds a write path that skips processLink; no test catches it
3 Test suite pins tags thoroughly and pins A/B test fields on one path only High Agent breaks A/B scheduling in bulk creation and the suite stays green
4 createLink and bulkCreateLinks each resolve to two different files by the same name Medium Agent edits the OpenAPI spec object believing it is the implementation
5 487 files named route.ts, plus 112 server actions and 138 API routes as parallel mutation surfaces Medium Agent updates one surface, misses the other; validation drift
6 No agent context file at all High Every session re-derives the above from scratch, badly

Severity: High. Evidence: apps/web/lib/api/links/create-link.ts and apps/web/lib/api/links/bulk-create-links.ts.

Both functions take ProcessedLinkProps. Both are the supported way to create a link. An agent reading the signatures has every reason to believe they are the same operation at different cardinalities. They are not.

Behaviour create-link.ts bulk-create-links.ts
Uploaded image to R2 Nulls image on insert (:64), uploads and back-fills after (:175 to :196) Absent. No storage import, no proxy handling
geo JSON null Prisma.DbNull (:71), which writes SQL NULL undefined (:73), which leaves the column untouched
testStartedAt / testCompletedAt Coerced to Date (:74 to :75) Absent. Only testVariants is set (:74)
A/B completion scheduled scheduleABTestCompletion(response) (:218) Absent
Write retry Wrapped in withPrismaRetry (:54) Bare prisma.link.createMany (:48)
Tinybird record recordLink per link (:157) Via propagateBulkLinkChanges (:226), a different code path

Some of these are deliberate. The Tinybird split is clearly intentional. At least two look like drift: a link created through POST /api/links/bulk with an uploaded image and an A/B test schedule loses the image upload and never gets its completion job scheduled. Prisma.DbNull versus undefined is a real semantic difference in Prisma for JSON columns, not a style choice.

The failure scenario. A developer types:

"Add a campaignId field to links. Set it on creation and make sure it's indexed."

The agent finds createLink, adds the field cleanly, adds it to the Zod schema, runs the tests, and they pass. Bulk creation silently drops the field for every link created by CSV import, the partner-link generator, and the Tolt, PartnerStack and Tapfiliate importers. Nobody notices until a customer asks why their imported links have no campaign.

That is a developer-day to find, and it is not the model's fault. Nothing in the repo told it there was a second write path, and nothing failed when it missed it.

Fix. Not a refactor. Two paragraphs in a context file naming both paths and stating "any change to link creation must be applied to both, or you must state in the PR why it should not be," plus a table of the known-intentional divergences so the next reader does not re-litigate them.


Finding 2: the invariants that matter are enforced one layer up

Severity: High. Evidence: apps/web/lib/api/links/process-link.ts:277, apps/web/lib/api/links/bulk-create-links.ts:48 to :90, apps/web/app/api/links/bulk/route.ts:83 and :254.

bulkCreateLinks calls prisma.link.createMany({ ..., skipDuplicates: true }) (:48 to :78), then re-fetches what it just wrote by shortLink IN (...) (:81 to :90), with no projectId filter. shortLink is globally unique (prisma/schema/link.prisma:6), so if one of those short links already belonged to a different workspace on a shared domain, skipDuplicates would silently skip the insert and the re-fetch would return the other workspace's link, which transformLink then hands back to the caller.

This does not happen today, and I want to be precise about why. The bulk route calls processLink for every link first (route.ts:83), and processLink runs keyChecks (:277), which rejects a key that is already taken. The guard is real and it works.

But the guard is 500 lines away in a different file, and the code at the write site does nothing to express that it depends on it. There is no comment, no assertion, and no type that distinguishes "props that have been through processLink" from "props that have not." ProcessedLinkProps is named as if it did, and that name is the only signal, which is exactly the kind of signal an agent under-weights.

The failure scenario. A developer types:

"Add an internal endpoint so our support team can bulk-restore links from a backup."

The agent finds bulkCreateLinks, sees it takes ProcessedLinkProps[], constructs those props from the backup rows, and calls it directly. It has skipped processLink, because nothing said it could not, and the type it satisfied is structural rather than nominal. It has now built a path where skipDuplicates can return another tenant's link. Every test passes, because no test exercises a write path that bypasses processLink.

Fix. Two things, both cheap. Write the invariant down where an agent will read it: "every link write goes through processLink first; ProcessedLinkProps means it already has." And add a workspace filter to the re-fetch at bulk-create-links.ts:81 as defence in depth, a one-line change that makes the invariant self-enforcing instead of documentary.


Finding 3: the test suite pins the wrong half

Severity: High. Evidence: apps/web/tests/links/create-link.test.ts, apps/web/tests/links/bulk-create-link.test.ts.

91 test files, real integration tests against a live API. This is better test discipline than most repos this size have. The problem is not coverage volume, it is which behaviours are pinned, because that is what decides whether an agent gets a red light or a green one.

So the exact three behaviours that Finding 1 shows are divergent are the three the bulk suite does not check. The suite would go green on every failure mode described above.

For an agent this is worse than no coverage, because a passing suite is read as permission to stop. An agent that runs pnpm test, sees green, and reports "done" is behaving correctly given the signal it was handed.

Fix. Three assertions added to bulk-create-link.test.ts. Roughly forty minutes of work, and it converts Findings 1 and 2 from "hope someone remembers" into "CI says no."


Finding 4: name collisions between implementation and specification

Severity: Medium. Evidence:

Symbol Implementation Also defined at
createLink lib/api/links/create-link.ts:26 lib/openapi/links/create-link.ts:5
bulkCreateLinks lib/api/links/bulk-create-links.ts:17 lib/openapi/links/bulk-create-links.ts:10

The lib/openapi/ versions are ZodOpenApiOperationObject documentation values, not functions. Same export name, near-identical file path, different universe.

An agent's first move on any task is a symbol search. Searching createLink returns two definitions in files called create-link.ts. It has a genuine 50/50 choice with no disambiguating signal, and one of the two options is a documentation object that will accept an edit without any type error pointing at the mistake.

Fix. One line in the context file: "lib/openapi/** is generated API documentation, never behaviour. If you are changing what the code does, you are not in lib/openapi."


Finding 5: two mutation surfaces, 487 files called route.ts

Severity: Medium.

Both are live mutation surfaces. Many operations are reachable through both, which means validation, rate limiting and audit behaviour have two places to live and two places to drift.

The naming compounds it. When an agent's context window holds nine files named route.ts, path is the only thing distinguishing them, and path is exactly what gets truncated in a tool result. Anecdotally this is the single most common cause of "the agent edited the wrong file" in Next.js App Router codebases.

There is also an undocumented split at app/(ee)/, the enterprise-edition route group, which holds its own link-creation endpoints (app/(ee)/api/partners/links/route.ts and five others). Nothing in the repo explains what may or may not depend across that boundary. An agent will cheerfully import across it.

Fix. A routing map in the context file: when to use a server action versus an API route, what (ee) means and what the dependency rule across it is. This is the cheapest high-value section in the whole document, because it is knowledge every one of your engineers already has and no new reader can obtain.


Finding 6: there is no context file

Severity: High. This is the root cause of Findings 1 through 5.

24,000 stars, a commercial product, a 464k-line application, and an agent starting a session in this repo gets the README. It will re-derive the architecture from directory names every single session, and it will get Findings 1 through 5 wrong every single time, because nothing in the repo can tell it otherwise.

The economics are stark. Assume five agent-assisted changes a week that touch anything near link writes. If one in five goes wrong in the manner of Finding 1, that is roughly a developer-day a week spent debugging output that looked correct and passed the tests. Against that, the document that prevents it is a day of work, once.


What the fix actually looks like

The deliverable in a paid audit is not this report. It is this report plus the working files. Here is a genuine excerpt of the CLAUDE.md that would ship with it, written from the code above rather than from a template:

## Writing links: read this before any change to link creation

There are two link-write paths and they are NOT interchangeable:

- `lib/api/links/create-link.ts` for a single link. Handles R2 image upload, A/B
  test scheduling, and per-link Tinybird records.
- `lib/api/links/bulk-create-links.ts` for many links. Uses `createMany`, then
  `propagateBulkLinkChanges` for Tinybird. Does NOT handle image upload or A/B
  scheduling (known gap, not a deliberate design).

**Any change to link creation must be applied to both**, or you must say in the PR
why it should not be. Known-intentional divergences: Tinybird recording (per-link
versus propagated), and retry wrapping (bulk relies on `createMany` atomicity).

**Invariant: every link write goes through `processLink` first.** `ProcessedLinkProps`
is a promise that it already has. The type does not enforce this; `keyChecks`
(`process-link.ts:277`) does. Never call `createLink` or `bulkCreateLinks` with props
you assembled yourself. If you need a new write path, route it through `processLink`.

`lib/openapi/**` is generated API documentation. It defines values named `createLink`
and `bulkCreateLinks` that are NOT the implementation. If you are changing behaviour,
you are in `lib/api/`, never `lib/openapi/`.

Four paragraphs. Every failure scenario in this report is prevented by them.


What this cost

This audit took a single working session on a public repository, with no access to Dub's team, their issue tracker, or their internal context. Everything above is derived from the code as it stands on main.

A paid audit adds what I could not do here: a conversation about which divergences are deliberate, a read through your closed PRs for the conventions that get enforced in review, and the complete set of context files rather than an excerpt.


AI-Readiness Codebase Audit: $149, delivered within 24 hours. First three clients pay $99. If the report contains nothing you did not already know, you pay nothing.

Sabelo Simelane · Email: sabelo@ligabazi.co.za · PayPal: paypal.me/sabside