From 00b9b1b6972aada77ca0151b8140aa48cbaa46ca Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Wed, 9 Sep 2026 16:39:26 +0200 Subject: [PATCH 1/5] docs: add ADR 0035, move SDK application crates into a product repository Co-Authored-By: Claude Opus 4.8 (1M context) --- .../adr/0035-product-repository.md | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 docs/architecture/adr/0035-product-repository.md diff --git a/docs/architecture/adr/0035-product-repository.md b/docs/architecture/adr/0035-product-repository.md new file mode 100644 index 000000000..dbb911997 --- /dev/null +++ b/docs/architecture/adr/0035-product-repository.md @@ -0,0 +1,259 @@ +--- +adr: "0035" +status: Proposed +date: 2026-09-09 +tags: [clients, mobile, sdk, server] +--- + +# 0035 - Move SDK application crates into a product repository + + + +## Context and problem statement + +The `sdk-internal` repository lives on its own and reaches the clients as a published npm package +(and a Maven package for android, a Swift package for iOS). Every breaking change in the SDK has to +travel through that publish step before any consumer can adopt the change that follows it. That +turns delivery into a single serialized pipeline: many teams write to the SDK, everything funnels +through one published version, and many teams consume it on the other side. One team's unfinished +downstream fix holds up everyone waiting for a later SDK change. + + + SDK + NPM + Clients + + + + + + + + + + + + + + + + + + + + + + + +Put another way: every breaking change in the SDK adds to a queue of fixes that then have to merge +on the clients side in the same order they merged in the SDK. If Team A merges a breaking change +into the SDK, every other team waits for Team A's corresponding client fix before it can pull in any +SDK change that landed after the break. + +We have tried to manage this without changing the structure. The breaking-change detection workflow +builds the latest `clients` `main` against SDK PRs so a breaking change can arrive with its fix PR +ready. It has not held the line: breaking changes have gone up, not down, even with the workflow in +place. Doing nothing is not a stable position, because the pressure grows as more features and more +teams move into the SDK. + +A 12-month measurement (2025-09-01 to 2026-08-31, read from merged PRs and git history) shows the +shape of the problem: + +- **68 forced downstream breaking events**: clients 18, android 28, iOS 22. The earlier six months + had 25; the recent six months had 43, about 1.7x, with August the peak at 16. +- **About 20% of merges to `sdk-internal` main are breaking**: 149 of 754. Distinct authors per + month grew from about 14 to about 29 over the year. More contributors means more breaking changes. +- **Concurrency is the norm.** Every one of the 149 breaking merges had another breaking merge + within 14 days; 143 of 149 within 3 days. A consumer picking up a new version does not adopt one + break, it adopts a bundle of unrelated breaks at once. +- **Nothing gates the publish.** npm and Maven publish on every merge to main. The breaking-change + check runs on PRs, comments, and adds a `breaking-change` label, but never fails the job (a + known-breaking PR, #906, passed green). So a breaking change ships to consumers whether or not its + downstream fix exists. +- **Code review is not the bottleneck.** On clients, review latency is negligible and total blocked + time is small (median 2.4 hours). Mobile is a different shape: android and iOS adopt on a rolling + branch, with median branch windows of about 80 and 165 hours, but that window is dominated by + adaptation effort and the branch sitting open between re-bumps, not by waiting for a reviewer, and + per-break approval time cannot be cleanly separated from it. The cost across all three repos is + coordination and adaptation, not code review. + - Keep in mind that this is absolute time and the real cost is person-hours across the blocked + teams. On repositories like `clients` that can easily mean an order of magnitude more + person-hours. +- **The churn is in the application layer.** Breaks across the dataset are dominated by SDK-owned + surface changes (struct and field reshapes, renames, unions, new APIs), and the clients coupling + points are mostly vault, tools, admin-console, and importer code. + +The reason to act is not the size of the problem today, it is the trend. The main driver is upstream +in the SDK, and it grows with the number of contributing teams, so the problem gets worse as +adoption grows. + +The server relationship is a separate matter. Server changes reach the SDK through generated API +bindings, but server-owned enum-variant breaks are rare in the data (clients 0, android 1, iOS 1). +And because the app and the server ship independently, they always have to stay compatible with each +other over the wire, which no source layout changes. That is separate work, not part of this +decision. + +## Considered options + +- **Do nothing, improve iteratively:** keep the repos separate and reduce breaks at the edges (SDK + owns construction so added fields do not break callers, new fields optional by default, fewer + fine-grained types for clients to construct or match, a client-side facade). +- **Ban breaking changes (additive-only):** never change the exported surface in place; add + alongside, deprecate, reap. +- **Coordinated merge gate:** keep the repos separate, make the existing breaking-change check + enforcing, and merge a breaking SDK change together with its client and mobile fixes as one gated + set (a distributed atomic merge across repos). +- **Monorepo:** move the whole SDK into the `clients` repository, the way `jslib` was folded in + before. +- **Product repository:** split the SDK crates. Move the application-logic crates into the product + repository so they compile together with the clients that consume them, and keep the core + infrastructure crates as a separately published, versioned library that every product depends on. + +### Do nothing, improve iteratively + +Reducing breaks at the interface is worth doing regardless, and it composes with any other choice. +On its own it does not remove the bottleneck, and it does not stop breaking changes from happening. +It is a set of smaller wins, not an answer to the serialized pipeline. + +### Ban breaking changes (additive-only) + +If nothing ever breaks, the bottleneck disappears. The cost is the overhead of carrying and later +cleaning up backwards-compatible surface, and with Rust that overhead can grow sharply with each +change. Some of the residue (nullability relaxations, semantic restructures) does not go additive +cleanly and still has to be coordinated as a real break. + +### Coordinated merge gate + +This is the strongest alternative that keeps the repos separate. It reaches the same "a break cannot +merge without its fixes" guarantee as the monorepo, and it covers mobile without moving any +repository. The breaking-change detection it would rely on already exists (advisory today), so +making the check enforcing is a small change, but the cross-repo merge coordination is new work we +would have to build. It also does not remove the bottleneck. A cross-repo atomic merge still has to +wait for the SDK to build on main, publish to the package registry, the clients PR to update its SDK +version, and CI to re-run on that change. And it leaves the open question of what happens to other +merges in the window between an SDK merge and its downstream fix: block them, and for how long. The +coordination logic (ordering, timing, separate CIs) is fiddly to build and maintain. + +### Monorepo + +Folding the whole SDK into `clients` gives the atomic-commit guarantee: a change to the SDK lands in +the same commit as the consumer updates it requires, and main is never broken because a change that +broke a consumer would break CI. It removes the concurrent-bundle problem by construction. The +drawback is that it binds the shared core into one product's repository. Bitwarden is moving toward +being a multi-product company, and a single repository that owns both the password-manager +application code and the crypto and infrastructure core makes it harder, not easier, for a second +product to depend on that core. It solves this problem while working against the direction the +product is heading. + +### Product repository + +Split the crate graph along the layering that already exists. The application-logic crates (for +example `bitwarden-vault`, `bitwarden-send`, `bitwarden-generators`, `bitwarden-exporters`) move +into the product repository and compile together with the clients that consume them, so their churn +no longer crosses a repository boundary. The core infrastructure crates (for example +`bitwarden-core`, `bitwarden-crypto`, `bitwarden-encoding`, `bitwarden-state`) stay a separately +published, versioned library that every product depends on. It keeps the monorepo's guarantee for +the layer that actually churns, and keeps the core shareable across products. The cost is the same +as the monorepo (a large one-time migration and a bigger repository) plus the work of drawing the +crate boundary. + +## Decision outcome + +Chosen option: **Product repository**, because it removes the bottleneck for the layer that causes +it while keeping the shared core available to future products. + +The repository this creates is best understood not as "clients plus the SDK" but as **the Password +Manager product**: the client apps together with the application-logic crates they consume. Once +they build from the same source, there is no published package version between them to keep in sync. +A change to the application code and the client code that uses it becomes one change that only +merges if it builds, the same way changing a class and its callers in one commit is one change. For +the application surface, where most of the measured breaking events come from, the cross-repo +breaking change stops being a separate thing to coordinate, and the concurrent-bundle problem goes +away by construction. + +Against the coordinated merge gate, the difference is that this removes the serialized pipeline +rather than automating around it. The merge gate reaches for the same "cannot merge unfixed" +guarantee, but it keeps the publish-and-bump round trip and leaves the unanswered "what do we do in +the window" question. Compiling together answers both by construction. + +Against the plain monorepo, the difference is the core. Keeping the infrastructure crates as a +shared, versioned library lets Secrets Manager and future products depend on the same core, and it +sets up extracting products out of the `clients` repository later. The monorepo would fold the core +into one product and make that harder. + +This rests on one assumption: **products do not need to share application logic at the SDK level, +only the core.** If that turns out false for some piece of application logic, there are two ways +out: promote that piece down into the shared core and accept that it evolves across the published +boundary like the rest of core, or model the relationship as product and sub-product, where a +sub-product depends on a product. Neither looks likely, and both keep the design intact if the +assumption breaks, so it is a manageable bet. + +A public SDK does not change this. To be useful it has to expose product operations like vault and +send access, which is application logic, so like the client apps it is a consumer of the product, +not part of the shared core. Its feasibility is unaffected by this decision. + +The upfront migration is real work, but it is one-time work, and we have done a version of it before +when `jslib` moved into `clients`. What it buys is an environment where we can write changes as +often as we want to the application layer without carrying a compatibility burden or waiting on a +cross-repo pipeline, weighed against a coordination cost that otherwise grows with every new team +contributing to the SDK. + +### Positive consequences + +- Most of the measured breaking events stop being cross-repo events at all. A change to the + application surface and the client code that uses it is one commit that only merges if it builds, + so main stays green by construction and there is no published version to bundle. +- The core stays a shared, versioned library, so Secrets Manager and future products can depend on + it, and the change sets up extracting products out of the `clients` repository later. +- Tooling and people get the full product context in one place. AI review, for example, sees the SDK + change and the client code that calls it in the same diff, instead of an opaque package-version + bump with a new API. +- One-time migration cost, with a precedent (`jslib`), against a coordination cost that otherwise + grows with the number of contributing teams. + +### Negative consequences + +- A large upfront migration, plus the work of drawing the crate boundary between core and + application. +- A bigger repository: git operations and CI get slower unless CI is scoped to build only the + projects affected by a change. +- It does not remove the adaptation work. Changing a shared type still means updating every place + that uses it; co-locating turns that into one commit instead of a cross-repo sequence, but the + adaptation itself is no smaller. +- Core-layer breaks still cross a published-package boundary between the core library and the + product repository. This is smaller than today because the core churns less than the application + layer, but it is real. The plan addresses it with explicit versioning of the core. +- It does not touch compatibility between deployed apps and the server. They ship independently, so + they always have to stay compatible with each other, and that is separate work regardless of how + the source is organized. + +### Plan + +- Draw the crate boundary in `sdk-internal`: classify each crate as core infrastructure (shared) or + application logic (product-specific). The obvious cases are legible from the crate names; the + in-between crates (`bitwarden-auth`, `bitwarden-sync`, and the crypto-management crates like + `bitwarden-user-crypto-management`) need a deliberate call. +- Publish the core crates as their own versioned library, on **explicit versions** rather than + auto-publishing on every merge, so products adopt a core change on their own schedule. +- Set up required infrastructure to build the SDK in `clients`. Try to scope CI so a change builds + and tests only the components it affects, to keep the larger repository workable. +- Move the application-logic crates into the product repository so they compile together with the + clients. +- Treat extracting a second product (for example Secrets Manager) out of the `clients` repository as + a later step this structure enables, not part of this migration. + +## Open questions + +- **Mobile.** Do we bring android and iOS into the product repository as part of this decision, or + leave them as standalone repositories for now and move only the SDK application crates and clients + first? The mobile repos carry the larger share of the measured breaking events, but they also add + Swift and Kotlin build graphs and Xcode/Gradle CI to the repository, which is the heaviest part of + the migration. + +## Follow-ups + +- **The server circular dependency.** `sdk-internal` and `server` currently have a circular + relationship. Moving the application crates into the product repository would make `server` depend + on `clients`, which is the wrong direction. A true product repository, where the server and client + components live in the same repository and both depend on a shared Rust core there, would resolve + this. Whether that is compatible with the backend MSA direction has not been explored. From a0aa85b279a9683241fd9fc7dcc53bf2dcc768b4 Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Tue, 22 Sep 2026 16:40:55 +0200 Subject: [PATCH 2/5] docs: revise ADR 0035 to move the SDK into the clients monorepo Co-Authored-By: Claude Opus 4.8 (1M context) --- ...tory.md => 0035-move-sdk-into-monorepo.md} | 159 ++++++++---------- 1 file changed, 72 insertions(+), 87 deletions(-) rename docs/architecture/adr/{0035-product-repository.md => 0035-move-sdk-into-monorepo.md} (58%) diff --git a/docs/architecture/adr/0035-product-repository.md b/docs/architecture/adr/0035-move-sdk-into-monorepo.md similarity index 58% rename from docs/architecture/adr/0035-product-repository.md rename to docs/architecture/adr/0035-move-sdk-into-monorepo.md index dbb911997..5cf7a26c4 100644 --- a/docs/architecture/adr/0035-product-repository.md +++ b/docs/architecture/adr/0035-move-sdk-into-monorepo.md @@ -1,11 +1,11 @@ --- adr: "0035" status: Proposed -date: 2026-09-09 +date: 2026-09-22 tags: [clients, mobile, sdk, server] --- -# 0035 - Move SDK application crates into a product repository +# 0035 - Move the SDK into the clients monorepo @@ -103,11 +103,10 @@ decision. - **Coordinated merge gate:** keep the repos separate, make the existing breaking-change check enforcing, and merge a breaking SDK change together with its client and mobile fixes as one gated set (a distributed atomic merge across repos). +- **Product repository:** split the SDK crates. Move the application-logic crates into a product + repository with the clients, and keep the shared, lower-level crates in a separate repository. - **Monorepo:** move the whole SDK into the `clients` repository, the way `jslib` was folded in - before. -- **Product repository:** split the SDK crates. Move the application-logic crates into the product - repository so they compile together with the clients that consume them, and keep the core - infrastructure crates as a separately published, versioned library that every product depends on. + before, so the SDK and the clients build together. ### Do nothing, improve iteratively @@ -134,126 +133,112 @@ version, and CI to re-run on that change. And it leaves the open question of wha merges in the window between an SDK merge and its downstream fix: block them, and for how long. The coordination logic (ordering, timing, separate CIs) is fiddly to build and maintain. -### Monorepo +### Product repository -Folding the whole SDK into `clients` gives the atomic-commit guarantee: a change to the SDK lands in -the same commit as the consumer updates it requires, and main is never broken because a change that -broke a consumer would break CI. It removes the concurrent-bundle problem by construction. The -drawback is that it binds the shared core into one product's repository. Bitwarden is moving toward -being a multi-product company, and a single repository that owns both the password-manager -application code and the crypto and infrastructure core makes it harder, not easier, for a second -product to depend on that core. It solves this problem while working against the direction the -product is heading. +Split the crate graph instead of folding all of it in. The application-logic crates (for example +`bitwarden-vault`, `bitwarden-send`, `bitwarden-generators`, `bitwarden-exporters`) move into a +product repository and compile together with the clients that consume them, while the shared, +lower-level crates (for example `bitwarden-crypto`, `bitwarden-core`, `bitwarden-encoding`, +`bitwarden-state`) stay in a separate repository. This keeps the atomic-commit guarantee for the +application layer, where most of the breaks come from, but leaves the clients consuming the shared +crates across a version boundary. The trade-off is that it keeps that boundary, a residual of the +overhead this decision is trying to remove, in order to pre-structure for a split we may never need. +It remains an option we can revisit later if that need becomes real. -### Product repository +### Monorepo -Split the crate graph along the layering that already exists. The application-logic crates (for -example `bitwarden-vault`, `bitwarden-send`, `bitwarden-generators`, `bitwarden-exporters`) move -into the product repository and compile together with the clients that consume them, so their churn -no longer crosses a repository boundary. The core infrastructure crates (for example -`bitwarden-core`, `bitwarden-crypto`, `bitwarden-encoding`, `bitwarden-state`) stay a separately -published, versioned library that every product depends on. It keeps the monorepo's guarantee for -the layer that actually churns, and keeps the core shareable across products. The cost is the same -as the monorepo (a large one-time migration and a bigger repository) plus the work of drawing the -crate boundary. +Fold the whole SDK into the `clients` repository, the way `jslib` was folded in before. A change to +the SDK lands in the same commit as the consumer updates it requires, and main is never broken, +because a change that broke a consumer would break CI. The cross-repo breaking change stops being a +separate thing to coordinate, the same way changing a class and its callers in one commit is one +change, and the concurrent-bundle problem goes away by construction. The crates are still published +as versioned packages that other consumers, such as the server, depend on; only the in-repo clients +drop the version boundary. The cost is a large one-time migration and a bigger repository. ## Decision outcome -Chosen option: **Product repository**, because it removes the bottleneck for the layer that causes -it while keeping the shared core available to future products. +Chosen option: **Monorepo**. Fold the whole SDK into the `clients` repository. -The repository this creates is best understood not as "clients plus the SDK" but as **the Password -Manager product**: the client apps together with the application-logic crates they consume. Once -they build from the same source, there is no published package version between them to keep in sync. -A change to the application code and the client code that uses it becomes one change that only -merges if it builds, the same way changing a class and its callers in one commit is one change. For -the application surface, where most of the measured breaking events come from, the cross-repo -breaking change stops being a separate thing to coordinate, and the concurrent-bundle problem goes -away by construction. +Once the SDK and the clients build from the same source, there is no published package version +between them to keep in sync. A change to the SDK and the client code that uses it becomes one +change that only merges if it builds. The cross-repo breaking change stops being a separate thing to +coordinate, and the concurrent-bundle problem goes away by construction. + +Against the product repository, the difference is where the SDK source lives and how much of the +version boundary the clients still cross. Both layouts publish the SDK crates as versioned packages, +so external consumers like the server depend on them the same way. The product repository splits the +source across two repositories, which leaves the clients consuming the shared crates across a +version boundary, a residual of the same bottleneck, to pre-structure for a split we may never need. +The monorepo removes that boundary for the clients as well, and it does not make a later split any +harder: extracting a second repository stays a normal refactor if that need ever becomes real. The +product repository is recorded above as an option we can revisit then. Against the coordinated merge gate, the difference is that this removes the serialized pipeline rather than automating around it. The merge gate reaches for the same "cannot merge unfixed" guarantee, but it keeps the publish-and-bump round trip and leaves the unanswered "what do we do in the window" question. Compiling together answers both by construction. -Against the plain monorepo, the difference is the core. Keeping the infrastructure crates as a -shared, versioned library lets Secrets Manager and future products depend on the same core, and it -sets up extracting products out of the `clients` repository later. The monorepo would fold the core -into one product and make that harder. - -This rests on one assumption: **products do not need to share application logic at the SDK level, -only the core.** If that turns out false for some piece of application logic, there are two ways -out: promote that piece down into the shared core and accept that it evolves across the published -boundary like the rest of core, or model the relationship as product and sub-product, where a -sub-product depends on a product. Neither looks likely, and both keep the design intact if the -assumption breaks, so it is a manageable bet. - A public SDK does not change this. To be useful it has to expose product operations like vault and send access, which is application logic, so like the client apps it is a consumer of the product, -not part of the shared core. Its feasibility is unaffected by this decision. +not a shared crate. Its feasibility is unaffected by this decision. The upfront migration is real work, but it is one-time work, and we have done a version of it before when `jslib` moved into `clients`. What it buys is an environment where we can write changes as -often as we want to the application layer without carrying a compatibility burden or waiting on a -cross-repo pipeline, weighed against a coordination cost that otherwise grows with every new team -contributing to the SDK. +often as we want without carrying a compatibility burden or waiting on a cross-repo pipeline, +weighed against a coordination cost that otherwise grows with every new team contributing to the +SDK. ### Positive consequences -- Most of the measured breaking events stop being cross-repo events at all. A change to the - application surface and the client code that uses it is one commit that only merges if it builds, - so main stays green by construction and there is no published version to bundle. -- The core stays a shared, versioned library, so Secrets Manager and future products can depend on - it, and the change sets up extracting products out of the `clients` repository later. -- Tooling and people get the full product context in one place. AI review, for example, sees the SDK - change and the client code that calls it in the same diff, instead of an opaque package-version - bump with a new API. +- Most of the measured breaking events stop being cross-repo events at all. A change to the SDK and + the client code that uses it is one commit that only merges if it builds, so main stays green by + construction and there is no published version to bundle. +- Components that are meant to work together share one build graph and one place to reason about + them. AI review, for example, sees the SDK change and the client code that calls it in the same + diff, instead of an opaque package-version bump with a new API. +- The SDK crates are still published as packages, so the server and any future product keep + depending on them regardless of this layout. - One-time migration cost, with a precedent (`jslib`), against a coordination cost that otherwise grows with the number of contributing teams. ### Negative consequences -- A large upfront migration, plus the work of drawing the crate boundary between core and - application. +- A large upfront migration. - A bigger repository: git operations and CI get slower unless CI is scoped to build only the - projects affected by a change. + projects affected by a change. Folding a Rust build graph into the `clients` Nx/TypeScript + monorepo, and mobile's Swift and Kotlin builds if they follow, is real build-system work. - It does not remove the adaptation work. Changing a shared type still means updating every place that uses it; co-locating turns that into one commit instead of a cross-repo sequence, but the adaptation itself is no smaller. -- Core-layer breaks still cross a published-package boundary between the core library and the - product repository. This is smaller than today because the core churns less than the application - layer, but it is real. The plan addresses it with explicit versioning of the core. - It does not touch compatibility between deployed apps and the server. They ship independently, so they always have to stay compatible with each other, and that is separate work regardless of how the source is organized. ### Plan -- Draw the crate boundary in `sdk-internal`: classify each crate as core infrastructure (shared) or - application logic (product-specific). The obvious cases are legible from the crate names; the - in-between crates (`bitwarden-auth`, `bitwarden-sync`, and the crypto-management crates like - `bitwarden-user-crypto-management`) need a deliberate call. -- Publish the core crates as their own versioned library, on **explicit versions** rather than - auto-publishing on every merge, so products adopt a core change on their own schedule. -- Set up required infrastructure to build the SDK in `clients`. Try to scope CI so a change builds - and tests only the components it affects, to keep the larger repository workable. -- Move the application-logic crates into the product repository so they compile together with the - clients. -- Treat extracting a second product (for example Secrets Manager) out of the `clients` repository as - a later step this structure enables, not part of this migration. +- Fold `sdk-internal` into the `clients` repository, following the `jslib` precedent, so the SDK and + the clients build together. +- Scope CI so a change builds and tests only the projects it affects, to keep the larger repository + workable. +- Keep crates buildable as packages that out-of-repo consumers, such as the server, can depend on. + Clients consume the crates in-repo with no version boundary; external consumers take them as + published, versioned packages. +- Draw package boundaries where product scope matters, for example splitting policies into a + suite-wide package the server and other products can depend on, and PM-specific pieces that stay + with the application. ## Open questions -- **Mobile.** Do we bring android and iOS into the product repository as part of this decision, or - leave them as standalone repositories for now and move only the SDK application crates and clients - first? The mobile repos carry the larger share of the measured breaking events, but they also add - Swift and Kotlin build graphs and Xcode/Gradle CI to the repository, which is the heaviest part of - the migration. +- **Mobile.** Do we bring android and iOS into the monorepo as part of this decision, or leave them + as standalone repositories for now and fold in only the SDK and clients first? The mobile repos + carry the larger share of the measured breaking events, but they also add Swift and Kotlin build + graphs and Xcode/Gradle CI to the repository, which is the heaviest part of the migration. ## Follow-ups -- **The server circular dependency.** `sdk-internal` and `server` currently have a circular - relationship. Moving the application crates into the product repository would make `server` depend - on `clients`, which is the wrong direction. A true product repository, where the server and client - components live in the same repository and both depend on a shared Rust core there, would resolve - this. Whether that is compatible with the backend MSA direction has not been explored. +- **The server's dependency on the SDK.** `server` currently depends on `sdk-internal` for + cryptographic and policy code. Folding the SDK into `clients` should not turn that into a + dependency on the `clients` repository. Read as packages, the server depends on the crypto and + policy packages wherever they live, published from the monorepo, not on the repository. Whether + the server later joins the monorepo or keeps consuming those packages is open, as is compatibility + with the backend MSA direction. From 10c33651a3c34fc7f60162c72580d0a01d5bbc9f Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Tue, 22 Sep 2026 16:50:49 +0200 Subject: [PATCH 3/5] docs: qualify sdk-internal#906 reference in ADR 0035 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/architecture/adr/0035-move-sdk-into-monorepo.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/architecture/adr/0035-move-sdk-into-monorepo.md b/docs/architecture/adr/0035-move-sdk-into-monorepo.md index 5cf7a26c4..88b61692e 100644 --- a/docs/architecture/adr/0035-move-sdk-into-monorepo.md +++ b/docs/architecture/adr/0035-move-sdk-into-monorepo.md @@ -68,8 +68,8 @@ shape of the problem: break, it adopts a bundle of unrelated breaks at once. - **Nothing gates the publish.** npm and Maven publish on every merge to main. The breaking-change check runs on PRs, comments, and adds a `breaking-change` label, but never fails the job (a - known-breaking PR, #906, passed green). So a breaking change ships to consumers whether or not its - downstream fix exists. + known-breaking PR, [sdk-internal#906](https://github.com/bitwarden/sdk-internal/pull/906), passed + green). So a breaking change ships to consumers whether or not its downstream fix exists. - **Code review is not the bottleneck.** On clients, review latency is negligible and total blocked time is small (median 2.4 hours). Mobile is a different shape: android and iOS adopt on a rolling branch, with median branch windows of about 80 and 165 hours, but that window is dominated by From a3d10254be176731fc13a03aacd38baa1d891aa5 Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Tue, 6 Oct 2026 12:48:31 +0200 Subject: [PATCH 4/5] docs: renumber ADR to 0047, add versioned SDK option, decide mobile scope Add versioned SDK releases as a considered option, decide that android and iOS stay out of the monorepo with commitments to improve the mobile boundary, scope the positive consequence to the 18 clients events, and tighten wording. Co-Authored-By: Claude Opus 5.5 (1M context) --- ...repo.md => 0047-move-sdk-into-monorepo.md} | 99 ++++++++++++++----- 1 file changed, 73 insertions(+), 26 deletions(-) rename docs/architecture/adr/{0035-move-sdk-into-monorepo.md => 0047-move-sdk-into-monorepo.md} (71%) diff --git a/docs/architecture/adr/0035-move-sdk-into-monorepo.md b/docs/architecture/adr/0047-move-sdk-into-monorepo.md similarity index 71% rename from docs/architecture/adr/0035-move-sdk-into-monorepo.md rename to docs/architecture/adr/0047-move-sdk-into-monorepo.md index 88b61692e..aa2228640 100644 --- a/docs/architecture/adr/0035-move-sdk-into-monorepo.md +++ b/docs/architecture/adr/0047-move-sdk-into-monorepo.md @@ -1,11 +1,11 @@ --- -adr: "0035" +adr: "0047" status: Proposed date: 2026-09-22 tags: [clients, mobile, sdk, server] --- -# 0035 - Move the SDK into the clients monorepo +# 0047 - Move the SDK into the clients monorepo @@ -52,7 +52,7 @@ SDK change that landed after the break. We have tried to manage this without changing the structure. The breaking-change detection workflow builds the latest `clients` `main` against SDK PRs so a breaking change can arrive with its fix PR -ready. It has not held the line: breaking changes have gone up, not down, even with the workflow in +ready. It has not been enough: breaking changes have gone up, not down, even with the workflow in place. Doing nothing is not a stable position, because the pressure grows as more features and more teams move into the SDK. @@ -71,7 +71,7 @@ shape of the problem: known-breaking PR, [sdk-internal#906](https://github.com/bitwarden/sdk-internal/pull/906), passed green). So a breaking change ships to consumers whether or not its downstream fix exists. - **Code review is not the bottleneck.** On clients, review latency is negligible and total blocked - time is small (median 2.4 hours). Mobile is a different shape: android and iOS adopt on a rolling + time is small (median 2.4 hours). Mobile looks different: android and iOS adopt on a rolling branch, with median branch windows of about 80 and 165 hours, but that window is dominated by adaptation effort and the branch sitting open between re-bumps, not by waiting for a reviewer, and per-break approval time cannot be cleanly separated from it. The cost across all three repos is @@ -103,6 +103,9 @@ decision. - **Coordinated merge gate:** keep the repos separate, make the existing breaking-change check enforcing, and merge a breaking SDK change together with its client and mobile fixes as one gated set (a distributed atomic merge across repos). +- **Versioned SDK releases:** keep the repos separate, but release the SDK as its own deliverable + every two weeks (semver, a changelog of breaking changes) instead of publishing on every merge to + main, and let consumers upgrade when it suits them. - **Product repository:** split the SDK crates. Move the application-logic crates into a product repository with the clients, and keep the shared, lower-level crates in a separate repository. - **Monorepo:** move the whole SDK into the `clients` repository, the way `jslib` was folded in @@ -112,14 +115,13 @@ decision. Reducing breaks at the interface is worth doing regardless, and it composes with any other choice. On its own it does not remove the bottleneck, and it does not stop breaking changes from happening. -It is a set of smaller wins, not an answer to the serialized pipeline. ### Ban breaking changes (additive-only) If nothing ever breaks, the bottleneck disappears. The cost is the overhead of carrying and later cleaning up backwards-compatible surface, and with Rust that overhead can grow sharply with each -change. Some of the residue (nullability relaxations, semantic restructures) does not go additive -cleanly and still has to be coordinated as a real break. +change. Some changes (nullability relaxations, semantic restructures) cannot be made additive and +still have to be coordinated as a real break. ### Coordinated merge gate @@ -133,6 +135,26 @@ version, and CI to re-run on that change. And it leaves the open question of wha merges in the window between an SDK merge and its downstream fix: block them, and for how long. The coordination logic (ordering, timing, separate CIs) is fiddly to build and maintain. +### Versioned SDK releases + +Treat the SDK as its own product with a release schedule, the way we already treat client versions. +Consumers get a known set of changes every two weeks and upgrade on their own schedule. + +This is a valid option, but it does not fit how we work today. The bottleneck is between the SDK and +the clients. Many of the developers who work in the clients now also work in the SDK, because they +are moving features from TypeScript into Rust. A release cadence puts a version gap in the middle of +their own work, so a developer has to wait for a release before they can use their own SDK change in +the client. It also does not remove the bundling. Breaking merges land close together (143 of 149 +within 3 days), so each release would carry a bundle of unrelated breaks, and adopting it would take +PRs with effort from several teams. + +Mobile is in a different position. Mobile is broken more often than the clients (50 of the 68 +measured events), but the cost there is adaptation, not waiting. Mobile developers do not contribute +to the SDK at the same scale, so they rarely need to cross the gap to ship their own work, and they +already adopt published versions when it suits them, sometimes days or weeks later. In practice +mobile already consumes the SDK this way. Their problem is learning what changed, not the gap +itself. + ### Product repository Split the crate graph instead of folding all of it in. The application-logic crates (for example @@ -141,9 +163,9 @@ product repository and compile together with the clients that consume them, whil lower-level crates (for example `bitwarden-crypto`, `bitwarden-core`, `bitwarden-encoding`, `bitwarden-state`) stay in a separate repository. This keeps the atomic-commit guarantee for the application layer, where most of the breaks come from, but leaves the clients consuming the shared -crates across a version boundary. The trade-off is that it keeps that boundary, a residual of the -overhead this decision is trying to remove, in order to pre-structure for a split we may never need. -It remains an option we can revisit later if that need becomes real. +crates across a version boundary. The trade-off is that it keeps that boundary, part of the overhead +this decision is trying to remove, to prepare for a split we may never need. It remains an option we +can revisit later if that need becomes real. ### Monorepo @@ -164,19 +186,39 @@ between them to keep in sync. A change to the SDK and the client code that uses change that only merges if it builds. The cross-repo breaking change stops being a separate thing to coordinate, and the concurrent-bundle problem goes away by construction. +The SDK stays its own project inside `clients`, with its own crates, ownership, and published +packages for consumers outside the repository. + +The monorepo covers the SDK and the clients. Android and iOS stay in their own repositories and keep +consuming the SDK as published packages. Code that the same people change together belongs in one +repository, and a versioned boundary fits where different teams own each side. The SDK and the +clients are changed together by the same product teams, often as part of the same feature. The +mobile apps are built by the mobile team, which consumes the SDK on its own schedule. + +Moving mobile into the monorepo would also cause problems. CI would block an SDK change until the +mobile apps build, so every developer changing the SDK would have to learn the mobile toolchains, +Swift, and Kotlin before they can merge. That friction pushes people to rush fixes in code they do +not know, and the mobile team prefers to review changes to its apps in its own repositories. It +would also not catch runtime or behavior changes, which the mobile team names as a large part of its +problem. + Against the product repository, the difference is where the SDK source lives and how much of the version boundary the clients still cross. Both layouts publish the SDK crates as versioned packages, so external consumers like the server depend on them the same way. The product repository splits the source across two repositories, which leaves the clients consuming the shared crates across a -version boundary, a residual of the same bottleneck, to pre-structure for a split we may never need. -The monorepo removes that boundary for the clients as well, and it does not make a later split any +version boundary, part of the same bottleneck, to prepare for a split we may never need. The +monorepo removes that boundary for the clients as well, and it does not make a later split any harder: extracting a second repository stays a normal refactor if that need ever becomes real. The product repository is recorded above as an option we can revisit then. Against the coordinated merge gate, the difference is that this removes the serialized pipeline -rather than automating around it. The merge gate reaches for the same "cannot merge unfixed" -guarantee, but it keeps the publish-and-bump round trip and leaves the unanswered "what do we do in -the window" question. Compiling together answers both by construction. +rather than automating around it. The merge gate aims for the same "cannot merge unfixed" guarantee, +but it keeps the publish-and-bump round trip and leaves the unanswered "what do we do in the window" +question. Building from the same source solves both. + +Against versioned SDK releases, the difference is who sits on each side of the boundary. A release +cadence fits a boundary between two teams, which is the situation for mobile. Between the SDK and +the clients it would put a two-week gap in the middle of a single team's work. A public SDK does not change this. To be useful it has to expose product operations like vault and send access, which is application logic, so like the client apps it is a consumer of the product, @@ -190,9 +232,10 @@ SDK. ### Positive consequences -- Most of the measured breaking events stop being cross-repo events at all. A change to the SDK and - the client code that uses it is one commit that only merges if it builds, so main stays green by - construction and there is no published version to bundle. +- Breaking changes between the SDK and the clients stop being cross-repo events (18 of the 68 + measured events). A change to the SDK and the client code that uses it is one commit that only + merges if it builds, so main stays green by construction and there is no published version to + bundle. - Components that are meant to work together share one build graph and one place to reason about them. AI review, for example, sees the SDK change and the client code that calls it in the same diff, instead of an opaque package-version bump with a new API. @@ -206,13 +249,17 @@ SDK. - A large upfront migration. - A bigger repository: git operations and CI get slower unless CI is scoped to build only the projects affected by a change. Folding a Rust build graph into the `clients` Nx/TypeScript - monorepo, and mobile's Swift and Kotlin builds if they follow, is real build-system work. + monorepo is real build-system work. - It does not remove the adaptation work. Changing a shared type still means updating every place that uses it; co-locating turns that into one commit instead of a cross-repo sequence, but the adaptation itself is no smaller. - It does not touch compatibility between deployed apps and the server. They ship independently, so they always have to stay compatible with each other, and that is separate work regardless of how the source is organized. +- It does not solve mobile's problems. Android and iOS still take SDK breaks across a + published-package boundary (50 of the 68 measured events), and the runtime and behavior changes + that hurt mobile the most cannot be detected with today's tools. With the SDK inside `clients`, it + can also become easier to forget that mobile depends on it. ### Plan @@ -226,13 +273,13 @@ SDK. - Draw package boundaries where product scope matters, for example splitting policies into a suite-wide package the server and other products can depend on, and PM-specific pieces that stay with the application. - -## Open questions - -- **Mobile.** Do we bring android and iOS into the monorepo as part of this decision, or leave them - as standalone repositories for now and fold in only the SDK and clients first? The mobile repos - carry the larger share of the measured breaking events, but they also add Swift and Kotlin build - graphs and Xcode/Gradle CI to the repository, which is the heaviest part of the migration. +- Keep publishing the Maven and Swift packages that android and iOS consume. +- Improve the boundary to mobile: enforce breaking-change notes on SDK changes, keep the android + breaking-change detection, and add iOS to it. +- Look into automatically generated mobile fixes together with the mobile team. These will not be a + merge requirement: requiring a working or merged mobile PR before an SDK change can merge would + bring back the cross-repo blocker this decision removes. +- Work closer with the mobile team, and address problems at the boundary as we run into them. ## Follow-ups From f88c7d3fd735aaa227b5b4430161e454f9f42940 Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Tue, 6 Oct 2026 12:59:05 +0200 Subject: [PATCH 5/5] docs: remove SVG diagram and event counts from ADR 0047 Event counts misrepresent impact, which is better measured in person-hours. The inline SVG does not follow the Mermaid diagram standard. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../adr/0047-move-sdk-into-monorepo.md | 51 ++++--------------- 1 file changed, 11 insertions(+), 40 deletions(-) diff --git a/docs/architecture/adr/0047-move-sdk-into-monorepo.md b/docs/architecture/adr/0047-move-sdk-into-monorepo.md index aa2228640..b5c060c11 100644 --- a/docs/architecture/adr/0047-move-sdk-into-monorepo.md +++ b/docs/architecture/adr/0047-move-sdk-into-monorepo.md @@ -18,33 +18,6 @@ turns delivery into a single serialized pipeline: many teams write to the SDK, e through one published version, and many teams consume it on the other side. One team's unfinished downstream fix holds up everyone waiting for a later SDK change. - - SDK - NPM - Clients - - - - - - - - - - - - - - - - - - - - - - - Put another way: every breaking change in the SDK adds to a queue of fixes that then have to merge on the clients side in the same order they merged in the SDK. If Team A merges a breaking change into the SDK, every other team waits for Team A's corresponding client fix before it can pull in any @@ -148,12 +121,11 @@ the client. It also does not remove the bundling. Breaking merges land close tog within 3 days), so each release would carry a bundle of unrelated breaks, and adopting it would take PRs with effort from several teams. -Mobile is in a different position. Mobile is broken more often than the clients (50 of the 68 -measured events), but the cost there is adaptation, not waiting. Mobile developers do not contribute -to the SDK at the same scale, so they rarely need to cross the gap to ship their own work, and they -already adopt published versions when it suits them, sometimes days or weeks later. In practice -mobile already consumes the SDK this way. Their problem is learning what changed, not the gap -itself. +Mobile is in a different position. Mobile is also broken by SDK changes, but the cost there is +adaptation, not waiting. Mobile developers do not contribute to the SDK at the same scale, so they +rarely need to cross the gap to ship their own work, and they already adopt published versions when +it suits them, sometimes days or weeks later. In practice mobile already consumes the SDK this way. +Their problem is learning what changed, not the gap itself. ### Product repository @@ -232,10 +204,9 @@ SDK. ### Positive consequences -- Breaking changes between the SDK and the clients stop being cross-repo events (18 of the 68 - measured events). A change to the SDK and the client code that uses it is one commit that only - merges if it builds, so main stays green by construction and there is no published version to - bundle. +- Breaking changes between the SDK and the clients stop being cross-repo events. A change to the SDK + and the client code that uses it is one commit that only merges if it builds, so main stays green + by construction and there is no published version to bundle. - Components that are meant to work together share one build graph and one place to reason about them. AI review, for example, sees the SDK change and the client code that calls it in the same diff, instead of an opaque package-version bump with a new API. @@ -257,9 +228,9 @@ SDK. they always have to stay compatible with each other, and that is separate work regardless of how the source is organized. - It does not solve mobile's problems. Android and iOS still take SDK breaks across a - published-package boundary (50 of the 68 measured events), and the runtime and behavior changes - that hurt mobile the most cannot be detected with today's tools. With the SDK inside `clients`, it - can also become easier to forget that mobile depends on it. + published-package boundary, and the runtime and behavior changes that hurt mobile the most cannot + be detected with today's tools. With the SDK inside `clients`, it can also become easier to forget + that mobile depends on it. ### Plan