Skip to content

API evolution

A published API has old callers even when we own the only client: a browser tab running last release’s bundle, or the server after a rollback. This page answers what counts as breaking, how a breaking change ships, how old surfaces are flagged and removed, and how an internal method is replaced.

Change the wire additively; break through expand/contract

Section titled “Change the wire additively; break through expand/contract”

Impact: HIGH release N must be safe for a client of release N−1

  • Adding a response field, an optional request field, an endpoint, a growable enum member or an error is not breaking. Removing or renaming a field, changing its type or encoding, adding a required request field, tightening a constraint, or changing a path, method or success status is breaking.
  • A breaking change ships as expand → migrate → contract, never in one release. The new field gets a new name; a replacement endpoint goes beside the old one. The expand PR opens the removal issue, the same rhythm as database migrations.
  • A stored event payload (an outbox row) is a third surface under the same rules: new fields are optional or defaulted, unknown fields are ignored, and a breaking change is a new event_tag whose old subscriber stays until a count query finds no unprocessed row of the old tag. /api/v2 is for a whole redesign, not a field.

❌ Incorrect — the same name changes type in one release; old tabs break:

// R12
createdAt: Schema.DateTimeUtcFromString, // was epoch ms

✅ Correct — expand, migrate, contract:

R12 PublicLabelingRule gains createdAtUtc: Schema.DateTimeUtcFromString
createdAt stays, flagged deprecated; toPublicRule writes both
the web client switches to createdAtUtc in the same release
R13 createdAt removed from the contract and from toPublicRule

Source: notes/04-architecture-and-api-design/api-evolution.md · Decision 1, amended

Read tolerantly: new enum members and errors aren’t breaking

Section titled “Read tolerantly: new enum members and errors aren’t breaking”

Impact: HIGH new business rules don’t each need expand/contract

  • A response enum that can grow is decoded with ForwardCompatibleNullable, from packages/contracts/src/forwardCompatible.ts: an unknown member becomes null instead of failing the whole response. Whether an enum can grow is decided when the field is added, and review asks. The same holds for growable enum fields in stored event payloads.
  • A closed enum that later needs a new member is breaking and goes through expand/contract.
  • Every client handles an unknown error generically. HttpApiClient turns an undeclared status or tag into HttpClientError, which the client maps to one generic message; an RPC client catches the per-call defect. The error _tag is the compatibility identity.

❌ Incorrect — a strict enum; one new status fails the whole response on an old client:

status: RuleStatus,

✅ Correct — forward-compatible decoding of a growable enum:

packages/contracts/src/labeling.ts
status: ForwardCompatibleNullable(RuleStatus), // "archived" on an old client → null → "Unknown"

Source: notes/04-architecture-and-api-design/api-evolution.md · Decision 2, amended

Flag old surfaces in OpenAPI and remove them by audience

Section titled “Flag old surfaces in OpenAPI and remove them by audience”

Impact: MEDIUM a rollback and an old tab keep working

  • An operation gets OpenApi.annotations({ deprecated: true, description: … }). A field gets its deprecation in the schema description, since there is no field-level annotation. The text always names the replacement and the removal issue.
  • While our own client is the only caller, the old surface is removed in the release after the one where the client stopped using it. The removal PR merges only once git tag --contains <client-change-sha> lists a tag, and names that tag.
  • A removal PR is ! only when someone outside the PR has to act. A ! bumps no app version; it puts the line under Breaking in the release notes (see release and versioning).

❌ Incorrect — a bare flag, removed in the same release the client moved:

createdAt: Schema.Number.annotate({ description: "Deprecated" }),
// …and deleted in the same PR that switched the client

✅ Correct — the flag names the way out; removal waits a release:

createdAt: Schema.Number.annotate({ description: "Deprecated: use createdAtUtc. Removal: #123" }),
HttpApiEndpoint.put("updateRule", "/rules/:id", { … }) // the old operation
.annotateMerge(OpenApi.annotations({ deprecated: true, description: "Use PATCH /rules/:id. Removal: #123" }))

Source: notes/04-architecture-and-api-design/api-evolution.md · Decision 3, amended

Replace an internal method in the same PR as its callers

Section titled “Replace an internal method in the same PR as its callers”

Impact: MEDIUM deprecated methods otherwise linger for months

  • When a new primary adds a replacement method (see interface design), the same PR moves every caller, test fakes included, and deletes the old method. The compiler lists every caller.
  • Only when that PR would touch more than 3 slices does the old method stay, as @deprecated naming the replacement, delegating to the new one where the inputs allow, with a removal issue. It is deleted in the PR that moves its last caller.
  • Adding a member to a method’s error union is not a breaking change here. It is the compile break the error rules want, fixed in the same PR.

❌ Incorrect — coexistence by default, with a bare tag:

export type LabelingRulesShape = {
/** @deprecated */
readonly create: (label: RuleLabel, prompt: string) => …
readonly createRule: (input: CreateLabelingRuleInput) => …
}

✅ Correct — one PR: add createRule, move every caller and fake, delete create:

export type LabelingRulesShape = {
readonly createRule: (input: CreateLabelingRuleInput) =>
Effect.Effect<LabelingRule, RuleLabelTaken | PersistenceError, CurrentPrincipal | CurrentOrg>
}

Source: notes/04-architecture-and-api-design/api-evolution.md · Decision 4, amended

No stability tiers; hide unfinished endpoints

Section titled “No stability tiers; hide unfinished endpoints”

Impact: LOW tiers don’t graduate, and graduating breaks URLs

  • Everything under /api/v1 follows these rules from its first release. There is no /experimental/ path, no x-stability label and no unstable/ module.
  • An endpoint whose shape is still moving is excluded from the docs with OpenApi.annotations({ exclude: true }).
  • Accepted cost: there is no external beta. exclude hides an endpoint from docs, not from callers; a caller who guesses the URL is still served.

❌ Incorrect — a tier in the URL; graduating it is a breaking change:

HttpApiEndpoint.get("suggestRules", "/experimental/rules/suggestions", { … })

✅ Correct — the final path, hidden until it settles:

HttpApiEndpoint.get("suggestRules", "/rules/suggestions", { … })
.annotateMerge(OpenApi.annotations({ exclude: true }))

Source: notes/04-architecture-and-api-design/api-evolution.md · Decision 5

  • /api/v2 — trigger: more than 5 endpoints need expand/contract in one change.
  • ForwardCompatibleArray, ForwardCompatibleOptional, ForwardCompatibleUnion — trigger: the first contract that needs one.
  • The external removal gate (30 days of zero calls, Deprecation and Sunset headers, no SDK still generating the call) — trigger: a second audience exists.
  • Per-route access logs (route, status, caller kind) — trigger: a second audience is written down as planned, so 30 days of logs exist when the gate first applies.
  • @deprecated coexistence for one replacement — trigger: the replacement touches more than 3 slices.
  • An internal HttpApi for unfinished or client-only operations — trigger: a second audience exists.