Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d22c944
docs(specs): spec 091, CI fails on a dependency whose licence is not …
plainprogrammer Nov 14, 2026
e339d88
docs(specs): add spec 091 to the index and mark #402 in progress on t…
plainprogrammer Nov 14, 2026
343803b
docs(specs): spec 091 v0.2.0, review round 1 findings fixed (#402)
plainprogrammer Nov 14, 2026
0aac95d
docs(adr): 0031, a dependency licence is a decision (#402)
plainprogrammer Nov 14, 2026
a438548
docs(specs): spec 091 v0.4.0, the maintainers rulings and review roun…
plainprogrammer Nov 14, 2026
19d3412
docs(specs): spec 091 v0.5.0, Gate A recorded and the all_required ex…
plainprogrammer Nov 14, 2026
1dbbc07
docs(specs): plan for spec 091, the licence check in bin/ci checks (#…
plainprogrammer Nov 14, 2026
5c1ddcf
docs(specs): spec 091 v0.5.1 and ADR 0031, the maintainer rules vcr a…
plainprogrammer Nov 14, 2026
bc63cc6
docs(specs): plan for spec 091, review round 1 fixed and vcr ruled (#…
plainprogrammer Nov 14, 2026
86471eb
docs(specs): Gate B recorded; spec 091 v0.5.2 says why all_required e…
plainprogrammer Nov 14, 2026
d82bee9
feat(licences): the policy's rules over injected sections (#402)
plainprogrammer Nov 14, 2026
f05764c
feat(licences): named exceptions that go stale, and the all_required …
plainprogrammer Nov 14, 2026
93524c5
feat(licences): the policy reads the real file, sections and lockfile…
plainprogrammer Nov 14, 2026
78f6144
feat(licences): the allowed licences and the first two exceptions (#402)
plainprogrammer Nov 14, 2026
fcddaec
feat(licences): Check joins the guard's problems and the policy's (#402)
plainprogrammer Nov 14, 2026
7e3ee4b
feat(licences): the licences:check task (#402)
plainprogrammer Nov 14, 2026
4b802e3
feat(ci): the licence check is a step of bin/ci checks (#402)
plainprogrammer Nov 14, 2026
51ba6a0
feat(licences): vcr is let through by name, its declared MIT not trus…
plainprogrammer Nov 14, 2026
776c0a9
test(licences): the core spec covers the policy files (#402)
plainprogrammer Nov 14, 2026
dc5e3ad
docs(architecture): the licence policy and its check in the area docs…
plainprogrammer Nov 14, 2026
4837974
docs(specs): verification record for spec 091, evidence so far (#402)
plainprogrammer Nov 14, 2026
0238777
fix(licences): the policy rejects a malformed all_required and an emp…
plainprogrammer Nov 14, 2026
119ab36
docs(licences): the policy's comments say stale exceptions follow and…
plainprogrammer Nov 14, 2026
c0e4ed7
docs(specs): implementation review of spec 091 recorded, SPEC-ALIGNED…
plainprogrammer Nov 14, 2026
e227103
chore(memory): capture session learnings from spec 091 (#402)
plainprogrammer Nov 14, 2026
6e1a376
docs(architecture): distil spec 091 into catalog core and release and…
plainprogrammer Nov 14, 2026
049066f
docs(specs): mark spec 091 frozen by the catalog core and release and…
plainprogrammer Nov 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/rules/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@
- ✓ Document code by `.claude/rules/code-documentation.md` (YARD: rationale, reusable code, extension contracts, syntax).
- ✗ Don't disable cops inline without a justification comment on the same line.
- ✗ Don't create god-object services or `utils.rb` dumping grounds; name classes after what they do (`MTG::BulkImporter`, `Exports::CollectionArchive`).
- ✗ Don't add a gem or a package under a licence that isn't in `config/licence_policy.yml` to get `bin/ci` green: allowing one is the maintainer's decision, recorded there with a reason, and a named exception is for a tool the app never loads (ADR 0031).
- ✗ Don't add gems for trivial functionality; prefer Rails built-ins (Solid*, `has_secure_password`, Active Storage, `Rails.cache`).
2 changes: 1 addition & 1 deletion .claude/rules/testing.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Testing (RSpec)

- ✓ `bin/ci` (driven by `config/ci.rb`) is the project's local CI tool and the pre-push gate. Keep it maintained: it runs `bundle exec rspec` (not the default `bin/rails test`), `bin/rubocop`, `bin/brakeman`, `bin/bundler-audit`, and `bin/importmap audit`. When adding a new check, add it to `config/ci.rb`.
- ✓ `bin/ci` (driven by `config/ci.rb`) is the project's local CI tool and the pre-push gate. Keep it maintained: it runs `bundle exec rspec` (not the default `bin/rails test`), `bin/rubocop`, `bin/brakeman`, `bin/bundler-audit`, `bin/importmap audit`, and `bin/rails licences:check` (a dependency's licence is allowed in `config/licence_policy.yml`, spec 091). When adding a new check, add it to `config/ci.rb`.
- ✓ Mirror `app/` in `spec/` (`spec/models`, `spec/requests`, `spec/system`, `spec/jobs`); files end in `_spec.rb`.
- ✓ Prefer request specs over controller specs; keep system specs (Capybara) for critical Hotwire flows.
- ✓ FactoryBot with minimal valid factories and traits; prefer `build`/`build_stubbed` when persistence isn't needed.
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions config/ci.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error"
step "Security: Gem audit", "bin/bundler-audit"
step "Security: Importmap vulnerability audit", "bin/importmap audit"
step "Licences: dependencies are under allowed licences", "bin/rails licences:check"
end

step "Tests: RSpec", rspec unless selection.group == :checks
Expand Down
34 changes: 34 additions & 0 deletions config/licence_policy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# The licences Collector's dependencies may be under, and the dependencies that are let through by name (spec 091, ADR 0031).
# `bin/ci checks` fails a gem, a pinned JavaScript package, an OCR package, a font or an icon set under a licence that is not
# in `allowed` and has no entry in `exceptions`. Adding to either list is a decision for the maintainer: say why in `reason`,
# and put the change in a pull request of its own or beside the dependency it is for.
#
# A component with several licences passes if any one is allowed. A licence is compared exactly as an SPDX id: "MIT License"
# or an expression in one string fails. An exception names a gem, the licences it was reviewed under (a set) and a reason; it
# goes stale, and fails the check, when the lockfile no longer names the gem, the gem declares other licences, or the gem is
# allowed without it. `all_required: true` marks a gem whose list of licences is not a choice, so every one must be allowed. Why
# it exists: a gem such as vcr declares Hippocratic-2.1 and MIT, and the rule "any one allowed" would pass it on MIT, so an
# ordinary exception for it would read as stale (the gem is allowed without it). With `all_required` the gem fails without the
# exception, the exception is what lets it through, and it goes stale only when every one of the gem's licences is allowed.
# Copy this form only for a gem whose declared alternatives you do not trust; an ordinary exception is for a gem that fails.

allowed:
- { id: MIT, reason: "Permissive, notice only: the copyright line and licence text travel with the copies, which the third-party licences page carries (spec 087)" }
- { id: BSD-2-Clause, reason: "Permissive, notice only; carried by the third-party licences page" }
- { id: BSD-3-Clause, reason: "Permissive, notice only, and a clause against using the authors' names to endorse; carried by the third-party licences page" }
- { id: Apache-2.0, reason: "Permissive, with a patent grant and a notice file that travels with the copies; carried by the third-party licences page" }
- { id: ISC, reason: "Permissive, notice only; the icons' licence" }
- { id: Ruby, reason: "Ruby's own licence, declared by the default gems, usually beside BSD-2-Clause" }
- { id: OFL-1.1, reason: "The licence of the self-hosted typefaces: the licence text travels with the font files, which are unmodified copies of their packages" }

exceptions:
- gem: bundler-audit
licences: [GPL-3.0-or-later]
reason: "A command-line audit run by bin/ci; the application never loads it, and it leaves the image when #408 lands"
- gem: brakeman
licences: [Brakeman Public Use License]
reason: "A static analyser run by bin/ci and the commit hook; the application never loads it, and it leaves the image when #408 lands"
- gem: vcr
licences: [Hippocratic-2.1, MIT]
all_required: true
reason: "A test recorder the application never loads. It declares Hippocratic-2.1 and MIT, ships no licence file, and issue #408 calls it Hippocratic-2.1, so its declared MIT is not trusted. all_required is why this is an exception at all: without it the gem would pass on its MIT and this entry would read as stale. It goes stale only when every licence it declares is allowed"
52 changes: 52 additions & 0 deletions docs/adr/0031-a-dependency-licence-is-a-decision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# 0031: A dependency's licence is a decision: an allowed list, named exceptions, and a CI step that enforces both

## Status

Proposed (2026-11-13). Numbered 0031 because 0030 is spec 090's, on its own branch. Written with spec 091; the maintainer ruled its decisions 1 to 9 as recommended on 2026-11-13 and asked for this ADR (the coordinator's number 0031). It becomes Accepted with the pull request.

**Date:** 2026-11-13
**Feature:** 091-licence-check-in-ci

## Context

Collector is AGPL-3.0 as one codebase, and the maintainer intends a hosted service on the same code ([households and hosted service design](../superpowers/specs/2026-11-13-households-and-hosted-service-design.md), decision on one AGPL codebase). A dependency's licence is then something the maintainer wants to decide, not find out: a copyleft, field-of-use or source-available licence can arrive with a gem or a pinned JavaScript package. Until spec 091 nothing read which licence a dependency was under: spec 087's guard fails a pin or a font with no manifest entry and its page lists the licences, but a gem under the GPL-3.0 would pass `bin/ci`.

Measured on `main` at `71212946` (spec 091, Problem Statement): 142 gems in every Bundler group, 13 distinct sets of licences; all but two gems declare at least one of MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0 or Ruby. The two are development and test tools the application never loads, `bundler-audit` (GPL-3.0-or-later) and `brakeman` (Brakeman Public Use License). Three gems declare several licences and pass on one (`rdoc`, `diff-lcs`, `vcr`). The manifest side holds MIT, Apache-2.0, OFL-1.1 and ISC.

Facts about the tools: a gemspec's `licenses` is a list the author gives, and RubyGems gives it no either/or meaning (it is commonly written as a choice); the lockfile names gems, and `Bundler.load.specs` gives the installed ones with their declared `licenses`; a JavaScript package has no machine-readable metadata in CI (there is no `node_modules` and no network), so its licence is the `spdx` a person typed into `config/third_party_licences.yml` from the package.

## Options considered

**For what the check compares against:**

- **A. An allowed list of SPDX identifiers, with named exceptions (chosen).** Anything not on the list fails, so a new kind of licence is a decision by default. Costs: an occasional exception for a fine licence that is spelt unusually.
- **B. A deny list of copyleft families.** Fewer failures, but a licence the author didn't think of (a field-of-use licence such as Hippocratic-2.1, a source-available licence) passes silently. Not chosen.
- **C. No check; rely on review.** The state before this ADR. A copyleft gem enters as a transitive dependency of a bump nobody reads. Not chosen.

**For a component that declares several licences:**

- **A. It passes if any one is allowed (chosen).** The check reads the list as a choice, as it is commonly written, and the page shows the same identifiers. Costs: a gem whose list is not a choice passes on its allowed id, and the check trusts that id. `vcr` was the doubtful case: it ships no licence file, and issue #408 calls it Hippocratic-2.1, so the maintainer ruled it a named exception (2026-11-13, `all_required`), and the rule stays as ruled for `rdoc` and `diff-lcs` (spec 091 decisions 2 and 3).
- **B. Every one must be allowed.** Stricter and unambiguous, but `diff-lcs`, `vcr` and `rdoc` would each need an exception for a licence nobody chose to use, which buries the exceptions that are real decisions. Not chosen.

**For which gems are judged:**

- **A. Every Bundler group (chosen).** Independent of what the image installs ([#408](https://github.com/plainprogrammer/Collector/issues/408)) and of the `Dockerfile`; a development tool under a restricted licence is a recorded exception.
- **B. Only what the image ships.** Smaller list of exceptions, but it depends on #408 and on a Dockerfile edit, and a contributor's tools escape. Not chosen.

## Decision

1. **`config/licence_policy.yml` records the allowed SPDX identifiers, each with a reason, and the exceptions.** Allowed: `MIT`, `BSD-2-Clause`, `BSD-3-Clause`, `Apache-2.0`, `ISC`, `Ruby` and `OFL-1.1`, and nothing else. Adding an identifier is a change to that file, which shows in review and is the maintainer's decision.
2. **A component with several identifiers passes if any one is allowed;** a component with none, or with a string that isn't exactly an allowed identifier (`MIT License`, an expression in one string), fails and names the string. Nothing is normalised.
3. **An exception names a gem, the set of licences it was reviewed under and the reason.** It goes stale, and fails the check, when the lockfile no longer names the gem, when the gem declares other licences, or when the gem is allowed without it. An exception may mark a gem whose list is not a choice (`all_required`), so every identifier must be allowed and the exception is what lets it through. Exceptions are for gems; a manifest entry under a disallowed licence is a change to the list or no dependency. Today: `bundler-audit`, `brakeman` and `vcr`.
4. **Every Bundler group is judged, and so is every entry of the third-party manifest** (JavaScript, OCR engine, fonts, icons) by its `spdx`, by the same rules. A run with some group left out judges what is installed, and a gem the lockfile names but the bundle lacks is neither judged nor stale; a manifest key the reader doesn't list is not judged (spec 091 decisions 3, 4 and 6). A manifest entry with a misspelt `spdx` has no exception and is corrected.
5. **The check is a step in `bin/ci checks`,** `bin/rails licences:check`, which judges the list spec 087's reader builds and also reports spec 087's guard, so a pull request that adds a gem or a pin under a licence off the list fails the checks job. The failure names the dependency, its licence and the policy file.

## Consequences

- **Good:** a dependency that declares only licences off the list can't enter unnoticed; the diff that allows one is one small file with a reason beside it. The page and the check read one list, so they can't disagree about what ships.
- **Good:** a release that changes an excepted gem's licence makes its exception stale, so the change is seen.
- **Cost:** a gem with an unusual spelling of an allowed licence needs an exception, or a correction of the list, until a better rule is worth its parser (spec 091 Out of Scope).
- **Limit:** the check trusts what is declared. A gem whose metadata says MIT and whose files say otherwise passes, and a manifest entry's `spdx` is only as good as the person who typed it from the package (its `source` says where); and a gem whose list of licences isn't a choice (`vcr`, above) passes on its allowed id unless it is excepted. The check makes no claim that a listed licence is compatible with the AGPL-3.0; the list is the maintainer's decision, recorded.
- **Limit:** a licence expressed as "all of A and B" can't be written in the metadata the check reads; it is judged by its listed identifiers, any one of which may pass.
- **Cost:** `bin/ci checks` gains a step that boots the application and reads about 400 KB of licence files (its time is recorded in spec 091's `verification.md`).
- **Follow-up:** [#408](https://github.com/plainprogrammer/Collector/issues/408) takes the two exceptions' gems out of the image; the exceptions stay, as development-tool exceptions.
4 changes: 4 additions & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ Add a line here with each new ADR. All are Accepted unless the line says otherwi
- [0029](0029-prices-database-held-by-the-backup.md): Prices live in a fifth database with `WITHOUT ROWID` history, and the backup holds it (backup format version 2)
- [0026](0026-forms-show-field-errors-through-the-design-system.md): Forms show field errors through the design system, not Rails' wrapper

### Dependencies and licensing

- [0031](0031-a-dependency-licence-is-a-decision.md): A dependency's licence is a decision: an allowed list, named exceptions, and a CI step that enforces both (Proposed)

### Expected

Named by the [roadmap](../roadmap.md) and written with their specs: none now. The upload-retention ADR expected by spec 028 is 0022 above, and the YARD ADR (spec 027 slice B) is 0027 above (it was expected as 0023, then as the next free number: 0023 went to locations, 0025 to lots in locations, 0026 to form errors).
Loading
Loading