# RB-24 — `libs/shared/upload` moves into `infrastructure/`/`domain/`/`application/`; the depcruise carve-out is deleted Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` ADR-C-002 · `99-backlog.md` RB-24, "Merges" table row for RB-25/26/27 ## What was wrong `libs/shared/src/upload/` held five files outside the folder-per-layer convention every other context follows. `upload.adapter.ts` injects `ApiClient` and opens a raw `XMLHttpRequest` — a genuine network adapter — yet sat outside `infrastructure/`. `upload.machine.ts` was the only Elm-style machine (of 9 in the repo) outside a `domain/` folder. The exception was hard-coded into the enforcement itself: `.dependency-cruiser.base.js`'s `apiclient-infrastructure-only` rule read `from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' }` — carved around the violation instead of the violation being fixed, which is why the baseline scan reported 0 violations despite this. ## What changed | From `libs/shared/src/upload/` | To | | -------------------------------- | ----------------------------------------------------- | | `upload.adapter.ts` | `libs/shared/src/infrastructure/upload.adapter.ts` | | `upload.machine.ts` + `.spec.ts` | `libs/shared/src/domain/upload.machine.ts` (+ spec) | | `upload-controller.ts` | `libs/shared/src/application/upload-controller.ts` | | `upload-shell.service.ts` | `libs/shared/src/application/upload-shell.service.ts` | All five moves used `git mv`. `libs/shared/src/upload/` no longer exists. **Import updates.** 24 consumer files import from `@shared/upload/*` (found with `grep -rln "shared/upload" apps libs --include=*.ts`, filtered to exclude the unrelated `@shared/ui/upload/*` component folder, which was not touched). All 24 files' import paths were rewritten to the new locations (30 import statements total, some files import more than one symbol). No export was renamed, no file was split, no logic changed in any of these 24 files beyond the import path string. **Within the five moved files**, three had relative imports (`./upload.adapter`, `./upload.machine`) that now crossed layers and were rewritten to `@shared/*` aliases: `upload.adapter.ts`'s import of `DocumentCategory` from `./upload.machine` → `@shared/domain/upload.machine`; `upload-controller.ts`'s imports of `UploadAdapter` and `upload.machine` symbols → `@shared/infrastructure/...` / `@shared/domain/...`; `upload-shell.service.ts` likewise. `upload.machine.spec.ts` needed no import change — it and `upload.machine.ts` moved into the same `domain/` folder together, so its `./upload.machine` import stayed correct; `git diff --find-renames` confirms this file as a 0-line-changed pure rename. **The carve-out.** `.dependency-cruiser.base.js`'s `apiclient-infrastructure-only` rule: `from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' }` → `from: { pathNot: '/infrastructure/' }`, comment updated to drop the now-false "(+ shared/upload)" parenthetical. One further consequence: `docs/reference/architecture/dependencies.md`'s "Atomic-layer rules" paragraph stated the same carve-out in prose ("the generated `ApiClient` is a value only inside `infrastructure/` (+ `libs/shared/src/upload`)") — corrected in the same diff, since leaving it would document a rule that no longer exists. ## A second, real violation the move exposed — fixed, not just reported Deleting the carve-out did not by itself make `dep:check` pass. A **separate, pre-existing** rule — `ui-not-infrastructure` (`ui/`+`layout/` may not import `infrastructure/` as a value) — had never fired against `upload.adapter.ts`, because before this move the file's path did not contain `/infrastructure/` at all. Three UI components were injecting `UploadAdapter` directly: `apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts`, `apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts`, and `apps/ssp/src/app/brief/ui/org-template.page.ts`. Once `upload.adapter.ts` physically moved into `infrastructure/`, `dep:check` correctly flagged all three: ``` error ui-not-infrastructure: .../registratie-wizard.component.ts → libs/shared/src/infrastructure/upload.adapter.ts error ui-not-infrastructure: .../herregistratie-wizard.component.ts → libs/shared/src/infrastructure/upload.adapter.ts error ui-not-infrastructure: .../org-template.page.ts → libs/shared/src/infrastructure/upload.adapter.ts ``` This is judged in-scope to fix, not a second unrelated finding to merely report, for three reasons. First, the ticket's own DoD is explicit: "if `dep:check` fails after the deletion, the move is incomplete, so fix the move rather than restoring the clause." Second, all three call sites used `UploadAdapter` for exactly one thing — `.contentUrl(documentId)`, a thin wrapper around the adapter's own already-exported, injection-free pure function `uploadContentUrl(documentId)` (its doc comment: "Pure (no injection) so a store can build a letterhead-logo `src` without pulling `ApiClient` into its dependency graph" — written for precisely this case). `apps/ssp/src/app/brief/application/brief.store.ts` already used that pure function directly; the three UI files had independently reinvented `inject(UploadAdapter)` + `.contentUrl()` instead. Third, the fix is mechanical and stays inside the ADR's own established idiom — no new architecture, no touch to any RB-25/26/27 target: - `libs/shared/src/application/upload-controller.ts` — the object `createUploadController` returns gained one more method, `previewUrlFor(documentId)`, built on the existing pure `uploadContentUrl`. Both wizard components already hold a `createUploadController` instance (`uploadCtl`) for their other upload effects; their `previewUrlFor` field now delegates to `uploadCtl.previewUrlFor` instead of injecting `UploadAdapter` itself. - `apps/ssp/src/app/brief/application/org-template.store.ts` (already injects `UploadAdapter` legitimately — it's application layer) gained one more computed-style field, `previewUrlFor`, on the same pure `uploadContentUrl`. `org-template.page.ts` now reads `this.store.previewUrlFor` instead of injecting `UploadAdapter`. No behaviour changed: `uploadContentUrl(id)` and `uploadAdapter.contentUrl(id)` return the identical string (the method is a one-line pass-through to the function), and the `demo-*` short-circuit in the two wizards moved into `upload-controller.ts`'s new method verbatim. ## Verification - **`upload.machine.spec.ts` passes unchanged.** `git diff --find-renames=30%` shows it as a 0-insertion/0-deletion pure rename — no content changed, including its own imports (both files moved into `domain/` together, so its `./upload.machine` relative import needed no edit). No spec content changed anywhere in this ticket. - `npm run dep:check`: **passes for both apps** with the carve-out clause removed — `✔ no dependency violations found (344 modules, 1200 dependencies cruised)` (ssp), `✔ no dependency violations found (226 modules, 588 dependencies cruised)` (behandelportal). - `npm run lint`: clean. - `npm test`: **43+6+24+4 = 77 test files, 274+37+138+23 = 472 tests, all passing** (ssp / behandelportal / shared / beheer). - `npm run build`: both apps build (pre-existing, unrelated warnings about `/cibg-huisstijl/css/huisstijl.min.css` and `/letter.css` not being found at build time — present before this ticket, vendored assets resolved at serve/deploy time, not a regression from this move). - **Coverage, `libs/shared/src/domain/`** (`npm run test:coverage` narrowed to `shared`): the folder now includes `upload.machine.ts` at 98.82% statements / 91.8% branches / 100% functions / 98.36% lines (84/85, 56/61, 28/28, 60/61) — the "well-specced machine" ADR-C-002 predicted landing in a folder the baseline reported at "0% spec reach across 3 files" (`capability.ts`, `feature-flag.ts`, `role.ts`, which this ticket does not touch and which remain unspecced — that gap is pre-existing and out of this ticket's scope). ## Non-TypeScript references to the old path — findings Checked `.storybook-ssp/`, `.storybook-behandelportal/`, `angular.json`, no vitest config file exists separately (Angular's builder owns test config), both `.dependency-cruiser.*.js` files, and `libs/shared/docs/*.mdx`. - **Storybook config, angular.json, dependency-cruiser app configs**: no reference to `shared/upload` or `libs/shared/src/upload` in any of these. Nothing to change. - **`.dependency-cruiser.base.js`**: the one real reference — the carve-out clause itself, deleted (see above). - **`docs/reference/architecture/dependencies.md`**: one prose reference to the same carve-out, corrected in this diff (see above) since it directly describes the rule this ticket edits. - **`libs/shared/docs/*.mdx`**: no `.mdx` file references `libs/shared/src/upload` or `@shared/upload`. `atomic-design.mdx` and `machines.mdx` mention `upload.machine.ts` and `shared/ui/upload/...` by filename/short-path only, never the full old directory path — both remain accurate (the filename didn't change; `ui/upload/` is the untouched sibling folder). - **`apps/ssp/src/locale/messages.xlf`, `messages.en.xlf`, `apps/behandelportal/src/locale/messages.en.xlf`**: each carries a handful of `src/app/shared/upload/upload.machine.ts` /`upload.adapter.ts` annotations — auto-generated by Angular's `$localize` extractor, informational only (they tell a translator where a string originated; they are not read by the build or by `i18nMissingTranslation`). Left as-is: regenerating them is `npm run extract-i18n`'s job for the source-locale file and does not touch the hand-maintained `messages.en.xlf` translations at all, and this ticket's scope is the move plus import updates, not a translation-tooling refresh. They will self-correct the next time `extract-i18n` runs for an unrelated reason. - **`docs/project/backlog/*.md`, `docs/project/refactor-backlog-setup/refactor-backlog/*.md`**: several planning/history documents (WP-25, WP-74, the baseline scan, `02-testability.md`, `06-adr-conformance.md`, `07-bio2-compliance.md`, `99-backlog.md`, `rb-01.md`, `rb-09.md`) reference the old path — expected, since most of them describe or cite the violation this ticket resolves, as history. Not edited, except `99-backlog.md`'s RB-24 status cell (see below). ## What RB-25/26/27 now find where - **RB-25** (`UPLOAD_TRANSPORT` injection token, replacing `inject(KeepaliveTransport)`): `KeepaliveTransport` and `UploadShellService` are both now in `libs/shared/src/application/upload-shell.service.ts` (unchanged content, new path). The token belongs in `application/` alongside them — nothing about the token's shape or location changes because of this move. - **RB-26** (`planFileSelection` in `upload.machine.ts`): the machine is now `libs/shared/src/domain/upload.machine.ts`. `createUploadController`'s `onFileSelected` callback — the accept/reject decision RB-26 targets — is in `libs/shared/src/application/upload-controller.ts` (also renumbered, otherwise unchanged; it also now exports one more method, `previewUrlFor`, added by this ticket — see above). RB-26 should extend `upload.machine.ts` in its new location; no import path in that file needs touching beyond what this ticket already did. - **RB-27** (`uploadOutcome(status, responseText)` out of the XHR closure): the XHR closure is in `libs/shared/src/infrastructure/upload.adapter.ts`'s `xhrUpload` method — same file, same method, new path only. `load`/`error`/`abort` handlers, `parseError`, and `genericError` are all still exactly where they were, just under `infrastructure/`. ## `npm run ci` Result and step count reported in the final answer.