Membership Policy

How exclude and limit decide which packages stay in a Dist: rule fields, glob matching, version ordering, and why loosening a policy never resurrects a removed member.

Policy is the answer to “I dumped a build directory into this Dist and I do not want the debuginfo packages, and I only want the latest version of each package.” Two rules do that work, they run in a fixed order, and they run over the whole candidate set — not just the packages you happened to add this time.

The two rules and their order

candidate set  →  exclude  →  limit  →  Desired Membership

exclude drops packages that match a rule. limit then caps how many versions survive per package name and architecture. The order is fixed and never configurable, because the reverse order would let an excluded package consume a version slot on its way out.

Both rules are enforced on every add, every rm, and every build. That last one matters: editing limit or exclude in sow.yml marks the affected Dists dirty, and the next build re-applies the new policy to the existing membership. You do not have to re-add anything to make a tightened policy take effect.

dists:
  el9:
    format: rpm
    limit: 1
    exclude:
      - kind: [debuginfo, debugsource, llvmjit]

exclude

exclude is a list of rules. Within one rule, fields are combined with AND. Within one field, multiple patterns are combined with OR. Rules are combined with OR — any rule matching excludes the package. Field order and rule order never change the result.

exclude:
  - kind: [debuginfo, debugsource, dbgsym, dbg, llvmjit]
  - name: ["test-*", "*-experimental"]
    arch: [aarch64]

That reads as: drop every debug-ish package regardless of architecture, and drop aarch64 packages whose name starts with test- or ends with -experimental.

Five fields are allowed:

FieldMatches against
namethe binary package name
sourcethe normalized source name
archx86_64, aarch64, or neutral
kindthe fixed enumeration below
formatrpm or deb

Patterns are case-sensitive exact strings or shell globs (*, ?, []). There is no regex, no version comparison, no negation, and no expression language. Unknown fields, empty rules, and invalid globs fail at config check rather than silently matching nothing.

kind is derived from the binary name, most specific suffix winning:

FormatName suffixkind
RPM-debuginfodebuginfo
RPM-debugsourcedebugsource
RPM-llvmjitllvmjit
DEB-dbgsymdbgsym
DEB-dbgdbg
anynone of the abovemain

Classification comes from the package itself. It never depends on which directory the file came from or which host you are running on, so the same input always classifies the same way. sow show --json exposes the computed kind.

An excluded package is reported, not treated as a parse error, and it is not stored:

$ sow add pkg/blackbox_exporter-0.28.0-1.x86_64.rpm pkg/pev2-1.23.0-1.noarch.rpm -r demo -d el9
add repository=demo operation=7877233225745514469 accepted=1 failed=0 memberships=+1/-0 revision=3 generation=3 dirty=false
item input="pkg/blackbox_exporter-0.28.0-1.x86_64.rpm" status=excluded format=rpm coordinate="blackbox_exporter-0:0.28.0-1.x86_64" sha256:5759c643… dists=el9:excluded
item input="pkg/pev2-1.23.0-1.noarch.rpm" status=accepted format=rpm coordinate="pev2-0:1.23.0-1.noarch" sha256:d06d7f23… dists=el9:accepted

The command exits 0. Nothing was wrong with the excluded package — it just does not belong in this Dist. If a package is accepted by no Dist at all, no ownerless pool object is written for it.

limit

limit groups memberships by (binary name, native architecture) and keeps the newest N:

  • 0 — keep every version. This is the default.
  • positive N — keep the N newest by native version ordering.
  • negative — a configuration error.

Two details decide most real questions.

The grouping key includes architecture. limit: 1 does not mean “one version of this package in this Dist”; it means “one version per name and native architecture”. So pg_sample-1.13 for x86_64 and pg_sample-1.17 for noarch both survive in a limit: 1 Dist, because they are in different groups. Neutral (noarch/all) counts once as its own native architecture even though it renders into multiple views.

Ordering is native to the format. RPM uses EVR comparison — epoch, version, release, with the standard rpm segment rules. DEB uses Debian version comparison, where the version string already carries the epoch and revision. SOW does not invent a version scheme or compare strings lexically.

Here is limit: 1 deciding between two Debian versions of the same package and architecture:

$ sow add pkg/libpq5_18.4-1.bookworm_amd64.deb pkg/libpq5_18.4-1.trixie_amd64.deb -r demo -d trixielim
add repository=demo operation=2402398619981505515 accepted=1 failed=0 memberships=+1/-0 revision=4 generation=4 dirty=false
item input="pkg/libpq5_18.4-1.bookworm_amd64.deb" status=excluded format=deb coordinate="libpq5=3:18.4-1.bookworm:amd64" sha256:be8a2863… dists=trixielim:limited
item input="pkg/libpq5_18.4-1.trixie_amd64.deb" status=accepted format=deb coordinate="libpq5=3:18.4-1.trixie:amd64" sha256:0a7df397… dists=trixielim:accepted

Note the two levels of reporting: the item’s overall status is excluded (it did not become a member anywhere), while the per-Dist outcome is limited — telling you it lost on version, not on an exclude rule. When you have several Dists selected, each one reports its own outcome, so a package can be accepted in one and limited in another in a single command.

limit removing an older membership and adding the newer one happens inside the same operation, so the ledger shows one atomic decision rather than a delete followed by an unrelated insert.

Policy runs over the full candidate set

A common misreading is that add applies policy only to the packages on the command line. It does not. After merging your input into the target memberships, SOW evaluates exclude and then limit over the complete membership set of each selected Dist.

The practical consequence: adding version 3 to a limit: 2 Dist that already holds versions 1 and 2 removes version 1 in the same operation. You cannot smuggle a package past the version cap by adding it separately, and you never end up with N+1 members because the cap was only checked against the delta.

Loosening a policy never resurrects anything

This is the semantics people most often expect to work the other way, so it is worth showing directly. Continuing from the limit: 1 example above, remove the version that won:

$ sow rm 'deb:libpq5=3:18.4-1.trixie:amd64' -r demo -d trixielim
$ sow ls -d trixielim
repository=demo dists=trixielim dirty=false
SHA256	COORDINATE	DISTS	BUILT_DISTS	POOL_PATH

The Dist is empty. The bookworm build did not come back, even though its bytes are still sitting in the pool, and even though there is now a free slot under limit: 1.

The reason is that exclude and limit remove actual Desired Memberships. SOW does not maintain a shadow list of “candidates that policy suppressed but might return later”. Pool bytes are storage, not a candidate set. Raising a limit or relaxing an exclude therefore gives you room for future additions; it does not reach back into history and guess which of the packages you once had should reappear.

To get it back, add it again — explicitly:

$ sow add pkg/libpq5_18.4-1.bookworm_amd64.deb -r demo -d trixielim
add repository=demo operation=590501245267266669 accepted=1 failed=0 memberships=+1/-0 revision=6 generation=6 dirty=false
item input="pkg/libpq5_18.4-1.bookworm_amd64.deb" status=accepted format=deb coordinate="libpq5=3:18.4-1.bookworm:amd64" sha256:be8a2863… dists=trixielim:accepted

Convergence is one-directional and it is stated as an invariant: tightening policy can remove members; loosening policy never restores them. That asymmetry is what makes build safe to run at any time. If it were symmetric, editing sow.yml could silently republish a package you deliberately withdrew — which is exactly the failure you do not want in a security update.

Previewing a decision

sow rm -c computes the removals, the policy consequences, and the file changes a build would produce, and writes nothing:

sow rm patroni -r pgsql -d el9 -c

-c/--check takes no write lock and is mutually exclusive with --skip. Passing --timeout or --no-wait alongside it is a usage error, so that nobody mistakes a preview for something that waits on a write transaction.

Next

Last modified: 2026-08-08: init commit (fe725aa)