Skip to main content

Dependencies

Each dependency is code that people outside this project can change, and each one brings its own dependencies. We add a package only when it gives more than it costs in that trust. ADR 0072 records the decision, the incidents behind it, and the rejected alternatives. This page gives the current rules. Supply-chain security in CI lists the controls that CI runs.

The constraint​

Each package in a lockfile is one more maintainer account or publish token that an attacker can take over. Thus, the number of packages is a cost, in addition to the size of the code. ADR 0072 gives the incidents that show it.

The bar for a new dependency​

A new package needs a reason in its pull request. Answer each item in the pull request body:

  1. What we use. Name the functions or components that we import. If it is one or two small functions, read When to own code instead.
  2. Transitive cost. Give the change that gate:dependency-baseline reports. The section The dependency baseline explains it.
  3. Install scripts. The package and its dependencies run no install script. If one must run, add an allowBuilds entry in pnpm-workspace.yaml and give the reason.
  4. Provenance. The version has npm provenance from a trusted publisher, or the maintainers publish it the same way for each release.
  5. Maintainers. An organization or more than one active maintainer releases the package. A release in the last 12 months shows that it is maintained.
  6. Bundle bytes. For a webapp or extension runtime package, give the gzip bytes that it adds to the production build.
  7. License. The license permits use in an MIT project. Copyleft and unknown licenses need a maintainer decision.

A Gradle dependency answers items 1, 5 and 7. Its transitive cost is the change to the Gradle lockfiles. For its provenance, Gradle dependency verification checks the checksum of each artifact against server/gradle/verification-metadata.xml. Updating Java dependencies describes that review. The dependency baseline, install scripts and bundle bytes apply only to npm packages.

A platform feature comes before a package. Examples are a Node built-in such as util.parseArgs, util.styleText, fs.glob or fetch, a browser API, or a feature of a framework that we already use.

In webapp/ and server/, ask a maintainer before you add a dependency. Their package guides say so.

Exceptions to the bar​

Only a maintainer can accept a package that fails an item. Each accepted exception has a row here, with the condition that makes us examine it again.

PackageItemReasonExamine again when
@isaacs/nonrecursive-glob-match@1.0.35. Maintainersminimatch 11 needs it, and only minimatch 11 fixes GHSA-g3hv-75wg-m249. The single maintainer of minimatch released this code from the minimatch tree. We already ship minimatch with the same single maintainer. Thus, the trust boundary stays the same. The package has no dependencies, no install script and a permissive license.minimatch gets an organization or a second maintainer, or npm provenance.
@hylimo/cli@3.0.0-alpha.2 and its @hylimo/* packages5. MaintainersOne npm account publishes the packages, with npm provenance. The maintainer chose HyLiMo for the system diagrams. It runs only in scripts/render-diagrams.ts, at development time. The rendered SVGs are committed, so the docs build and the site do not run it. It has no install script and an MIT license.A stable 3.x release ships, or the project releases nothing for 12 months.

Held versions​

An update that breaks a budget or a suite leaves its batch, and stays out until a newer release fixes it. Each held version has a row, with the condition that makes us try it again.

PackageHeld atReasonTry again when
react, react-dom19.2.819.3 adds 29,236 bytes to the entry, which every page loads. react-dom is part of the entry, so no route split can move it.The entry budget has room for it.
lucide-react1.41.01.64.0 adds 2,687 gzip bytes to the entry. The bundle budget in webapp/tools/bundle-budget allows 1,024.A release does not grow the entry.
@base-ui/react1.8.01.9.0 adds 2,088 gzip bytes to the entry. The bundle budget allows 1,024. 1.9 also renders a disabled accordion trigger with the native disabled attribute instead of aria-disabled, so ui/accordion.tsx and the catalog story move with it.A release does not grow the entry.
@tanstack/react-query, @tanstack/react-query-devtools5.102.8From 5.103, the devtools stop bundling their UI and depend on 12 packages, among them the Solid UI framework and its component library. The devtools are a runtime dependency, so these become production packages.The devtools leave the production dependencies, or a release bundles its UI again.
recharts3.8.03.9 deprecates the legend's verticalAlign, which ChartLegendContent in the vendored ui/chart.tsx reads, so no-deprecated fails the type-aware lint.The shadcn registry's chart moves to position.
monaco-editor0.56.00.57.0 adds about 30,000 gzip bytes to each page with the code editor. No feature that we use needs 0.57.We need a feature of a newer release, or a release does not grow these pages.
msw, msw-storybook-addon2.15.0, 2.0.7Thirteen route tests fail on 3.0.3. A handler sees a request's credentials as same-origin when the app sends omit. A test of a response body in transit times out.A release keeps the request as the app sent it.
motion13.5.114.8.0 adds 4,612 gzip bytes to the landing page. The bundle budget in webapp/tools/bundle-budget allows 1,024. No feature that we use needs 14.A release does not grow the page, or we need a feature of 14.
entities8.1.09.0.0 adds a second copy to the tree for one lint rule. Its synchronous decoding, which we use, does not change.Other packages in the tree move to 9.
com.graphql-java:graphql-java25.0Every release shades Guava 32.1.2-jre. security/vulnerability-policy.json has a reviewed CVE-2026-102554 decision for the 25.0 jar only. The 25.1 jar that Spring Boot 4.1.2 manages has not been reviewed.A release shades a fixed Guava, or a newer jar gets its own reviewed decision.

When to own code instead​

Own the code when all of these are true:

  • We use a small part of the package, such as one or two functions.
  • That part is small enough to review in one sitting, typically less than 200 lines with its tests.
  • That part is stable. It implements a fixed format or a simple algorithm, not a moving specification.
  • No platform feature does the same work.

Keep the package when it is a foundation. Foundations include React, TanStack, Base UI, Spring and parsers for complex formats. Code that a security property depends on is also a foundation, such as an HTML sanitizer or a cryptographic library. Keep the large, well-run foundations, but remove the optional add-ons that we do not use.

Owned code has a cost that a package does not have. Renovate does not update it, and an advisory against the upstream package does not reach us automatically. Thus, own only a part that has no security history and that changes rarely.

How owned code lives​

  • One place. Owned code is one module in the tree that uses it, such as webapp/src/lib/ or scripts/lib/. Do not copy it into a second tree. Import it from the first.
  • A header. Each file of owned code starts with a comment that gives its source. A tool reads it, so the format is exact. The owned-code header gives it.
  • The license notice. MIT, ISC and BSD licenses require that the copyright line and the notice stay with the code. Put them in the header.
  • Tests. Port the upstream tests for the part that we keep. Add tests for our callers. A module without tests does not replace a package.
  • The SBOM entry. Each owned module in shipped code needs a component in the release SBOM. Its upstream package is the pedigree ancestor of that component. Thus, the license and the provenance stay true. Do not merge owned code into a shipped tree before the release SBOM lists it. Release management describes the SBOM.
  • A patch is an owned fork. A patchedDependencies patch changes a package that we install, so it is owned code. A directory notice named NOTICE.md beside the patch records its source, its changes and their reasons, and its tests. It also names who makes the patch again for a new version. docker/agents/pi/patches/NOTICE.md is the example.

The owned-code header​

The header is the first comment in the file: /** … */ in TypeScript and JavaScript, /* … */ in CSS. It has four fields, each on its own line, in this order and once each. A line can start with * and spaces.

FieldValue
Owned from:An https URL of the upstream source at a tag or a commit, not at a branch
Package:The canonical package URL of the upstream release, such as pkg:npm/%40gitlab/svgs@3.164.0. A scope's @ is %40
License:The upstream license as an expression of SPDX license identifiers, such as MIT
Changes:One line: what we kept, removed or changed

After the fields, an empty line and the upstream copyright line and license notice follow.

/**
* Owned from: https://github.com/<owner>/<repository>/blob/<tag>/<path>
* Package: pkg:npm/<name>@<version>
* License: MIT
* Changes: <what we kept, removed or changed>
*
* <the upstream copyright line and license notice>
*/

Shipped code is each file under webapp/src/, extension/src/, docker/ and server/application/src/main/, except the package patches that Owned static assets covers. A symbolic link in shipped code must point inside these directories. Test files, story files, and files in test/ and __tests__/ directories are not shipped code. In shipped code, a file with an Owned from: line in its first 40 lines is owned code. Such a file needs a complete header and a component in the release SBOM. Run vp run gate:owned-code to check these headers before merge.

The gate checks the header structure and npm coordinates. Reviewers verify the SPDX expression and the fixed upstream tag or commit.

Owned static assets​

Static assets under webapp/public/ and extension/public/, and package patches under docker/agents/pi/patches/, use a directory notice. A directory enters this inventory when its notice contains an Owned from: line. Keep the asset bytes unchanged. Put the four fields from the owned-code header in a directory notice named NOTICE.md. For example, webapp/public/brand/ai/NOTICE.md can declare the brand marks.

Each upstream has its own block. Add a fifth field, Files:, after Changes:. Its value is a JSON array of explicit sibling basenames on one line:

Owned from: https://github.com/<owner>/<repository>/tree/<tag>
Package: pkg:npm/<name>@<version>
License: MIT
Changes: Keep unmodified marks.
Files: ["first.svg", "second.svg"]

A non-npm upstream can use a versioned package URL such as pkg:github/ls1intum/edutelligence@<full-commit>. Keep directories with ownership blocks flat. Use no paths or globs in Files:. Each asset must exist and belong to exactly one block. The gate rejects missing files, undeclared assets, duplicate declarations and symbolic links. Only the notice itself and license filenames that match ^LICENSE(?:-[A-Z0-9-]+)?$ are excluded from asset coverage.

The release records each declared file's raw SHA-256 and the same license and pedigree fields as owned code.

The dependency baseline​

security/dependency-baseline.json records, for each lockfile and workspace, the number of direct and transitive packages. It also records the packages of pnpm's env document: the package manager and the config dependencies, which run during an install. gate:dependency-baseline compares it with the lockfiles and fails when a count rises above the baseline. Thus, an addition that takes a count above the baseline raises the baseline in its pull request. The review sees the cost beside the change.

A count below the baseline passes, and the gate names the lower value. Any later pull request can lower the baseline in passing. Thus, removals, and updates that stay within the baseline, merge in parallel.

To raise the baseline with an addition, or to lower it after a removal, run:

node scripts/check-dependency-baseline.ts --write

How to remove a dependency​

  1. Find each import site with git grep -n "<package>". Include configuration files, package.json scripts and CSS @import and @plugin lines.
  2. Replace each use with a platform feature or owned code, or delete the use.
  3. Remove the package from its package.json.
  4. Run pnpm install.
  5. Find each pnpm-workspace.yaml entry that names the package: allowBuilds, overrides, minimumReleaseAgeExclude, packageExtensions and patchedDependencies.
  6. For each entry, run pnpm why -r <package> in each lockfile directory, so that every workspace importer is searched. Remove the entry only if no package in any lockfile still needs it. A security override for a package that stays as a transitive dependency must stay.
  7. If you removed an entry, run pnpm install again. The lockfile records the workspace settings.
  8. Update the dependency baseline.
  9. Run the tests of each tree that used the package.
  10. If the package was in shipped code, add a changeset.

Install hardening​

pnpm-workspace.yaml owns the install policy, such as the release age and the packages that can run install scripts. Its comments give the reason for each setting. pnpm reads the nearest pnpm-workspace.yaml. Thus, the two agent images resolve with their own files in docker/agents/pi/ and docker/agents/practice-scripts/. Supply-chain security in CI describes how CI and Renovate apply the policy.

Next steps​