Patch, override, and source replacement
Cabin's typed local-policy layer lets a developer or workspace swap a registry-resolved package for a local working copy (patches) and redirect one supported index source to another (source replacement). Both features are deliberately narrow: they cover developer / CI flows that already worked with hand- edited paths, but they do not introduce new package semantics, new registry protocols, credentials, vendoring, or publication of override state.
This document is the canonical specification. The behavior
described here is what cabin-core::patch,
cabin-core::source_replacement, cabin-manifest,
cabin-config, cabin-workspace::patch, the artifact pipeline
in cabin, the lockfile, the metadata view, and the
package archiver all agree on.
Patch syntax
Patches replace a registry-resolved package candidate with a local source. Today only local-path patches are supported.
Workspace-root manifest
[patch]
fmt = { path = "../fmt" }
spdlog = { path = "../forks/spdlog" }
The [patch] table only applies on the entry-point manifest:
either a single-package project's cabin.toml or the
workspace root's cabin.toml. Member manifests that declare
[patch] are rejected with
patch declarations may only appear in the workspace root
manifest.
.cabin/config.toml
[patch]
fmt = { path = "../forks/fmt" }
Config-supplied patches follow the same shape. Relative paths resolve against the config file's directory (not the manifest's). Multiple config files may declare patches: higher- priority files override lower files on overlap, mirroring the rest of the config layer's precedence ladder.
Supported source kinds
| Kind | Manifest / config syntax |
|---|---|
path |
{ path = "../fmt" } |
path is the only patch source kind today. Any other key is
rejected as an unknown field. New kinds would extend
[PatchSource] explicitly.
Patch precedence
For each patched package name, Cabin walks the following layers top-down and keeps the first that declares an entry. Higher layers fully replace lower layers on overlap.
[patch]in the file pointed at byCABIN_CONFIG(explicit-config).[patch]in the project-local<root>/.cabin/config.toml(project-config).[patch]in the workspace-level<workspace-root>/.cabin/config.toml(workspace-config).[patch]in$XDG_CONFIG_HOME/cabin/config.toml(or its$HOMEfallback) (user-config).[patch]in the workspace-rootcabin.toml(manifest).- No patch.
The resolved provenance label appears verbatim under
patches[].provenance in cabin metadata so the chosen layer
is auditable.
Patch validation
Before any consumer sees a resolved patch, Cabin validates each entry:
- The patch path must point at a directory containing a
cabin.toml. Missing files surfacepatch for package <name> points to <path>, but that path does not contain a cabin.toml. - The patched package's
[package].namemust equal the patch table key. Mismatches surfacepatch for package <name> points to package <actual>; patch package name must match <name>. - For every active dependency edge that requests the patched
name with a SemVer constraint, the patched package's
[package].versionmust satisfy that constraint. Mismatches surfacepatch package <name> has version <ver>, which does not satisfy dependency requirement <req>.
"Active" means the edge would actually contribute to the resolver input on this invocation. Cabin skips:
- dev / system kinds — declaration-only, never resolved by the default build;
[target.<cfg>]deps whose condition does not match the host platform — dormant on this run;optional = truedeps — feature resolution decides their membership; if a feature later enables one, the patched manifest is used directly and any version mismatch surfaces against the real resolver input.
This means a patch on foo = ">= 99" declared only as a
dev-dep does not block validation, because that requirement
is not part of the default build closure.
- Within a single layer the same name cannot appear twice (TOML table-key uniqueness already handles this); across layers the higher layer wins (documented above).
Resolver / fetch / build integration
Once patches are validated, Cabin treats them as synthesized local-path packages:
- The
cabin-workspaceloader stitches each patched manifest into the package graph askind = Local. Existing workspace-loader behaviors (cycle detection, name uniqueness, dependency edges) apply unchanged. - The artifact pipeline filters patched names from versioned- dep closure detection and from the registry-fetch pass; the patched working copy never enters the artifact cache.
- Feature resolution, dependency-kind handling, and target-conditioned dependencies flow through the patched manifest exactly as they would for any path dependency.
Source replacement syntax
Source replacement redirects one supported index source to another. Config-only — manifests cannot declare source replacements.
# .cabin/config.toml
[source-replacement]
"https://example.com/index" = { index-path = "../mirror" }
"/abs/old-index" = { index-url = "https://new.example.com/index" }
Each row carries exactly one of index-path or index-url.
Other fields (including git and replace-with = "<name>")
are rejected with stable error messages.
URLs containing userinfo (e.g.,
https://user:pw@example.com/index) are rejected at parse time
so credentials never leak into the lockfile, log output, or the
metadata view.
Replacement chain + cycle detection
When the orchestration layer opens the configured index source,
it walks the replacement map once: each hop replaces the
current locator with the entry's replacement value, until a
locator with no replacement is reached (the terminal source).
Cycles surface
source replacement cycle detected: <hop-1> -> <hop-2> -> ...
before any index is opened.
Per-command precedence
For each command that consults a patch / source-replacement policy:
--no-patchesshort-circuits patch application and source- replacement resolution for the command's dependency / index inputs. Manifest[patch]and config[patch]entries do not add replacement packages, and config[source-replacement]entries do not rewrite the selected index source. Ordinarypath = "..."dependency declarations and ordinary dependency edges remain active.- Otherwise the merged manifest + config policy applies as described above.
Observability commands may still render configured policy as
configuration data. In particular, cabin explain source
--no-patches <name> still lists the merged
[source-replacement] declarations under source_replacements;
the flag means they were not applied to resolve package inputs.
--no-patches is available on cabin metadata, cabin build,
cabin run, cabin test, cabin resolve, cabin update,
cabin fetch, cabin vendor, cabin tree, and cabin explain.
Lockfile behavior
The lockfile records active patch policy and active source- replacement policy as deterministic top-level arrays:
[[patch]]
package = "fmt"
version = "10.2.1"
kind = "path"
provenance = "manifest"
path = "../fmt"
[[source-replacement]]
original = "https://example.com/index"
original-kind = "index-url"
replacement = "../mirror"
replacement-kind = "index-path"
provenance = "user-config"
Old lockfiles without these arrays remain valid (the parser
treats the missing fields as empty). Under --locked, if the
recorded arrays differ from the active policy, the resolver
errors with
--locked cannot be used because active patch / source-
replacement policy differs from <lockfile>; re-run without
--locked to refresh the lockfile.
Metadata view
cabin metadata --format json adds two top-level arrays:
"patches": [
{
"package": "fmt",
"version": "10.2.1",
"kind": "path",
"path": "../fmt",
"provenance": "manifest"
}
],
"source_replacements": [
{
"original": "https://example.com/index",
"original_kind": "index-url",
"replacement": "../mirror",
"replacement_kind": "index-path",
"provenance": "user-config"
}
]
Both arrays are sorted (patches by package name, replacements
by original) and contain only entries that survived
validation. --no-patches empties both arrays.
Package + publish behavior
Patches are local development policy. They never enter:
- the canonical per-version package metadata (
cabin packagederives metadata from the typedPackage, which strips patch tables before serialization); - the source archive (
cabin packagerejects manifests with a non-empty[patch]table — see below); - the file / sparse-HTTP registry index;
- the lockfile's
[[package]]array (only the orthogonal[[patch]]array reflects patch state).
cabin package returns
package <name> declares a [patch] table; patches are local
development policy and not publishable. Remove the [patch]
table from this manifest before packaging, or move the patches
to a .cabin/config.toml file.
Config-derived patches and source replacements live entirely
inside .cabin/, which cabin package already excludes from
deterministic source archives via EXCLUDED_DIR_NAMES.
Layer boundaries
These responsibilities live outside this layer and are intentionally not handled here:
- Vendor materialization.
cabin vendormay consume patch / source-replacement state during resolution, but the on-disk write logic lives incabin-vendor. - Offline-mode enforcement.
--offline/CABIN_NET_OFFLINEare enforced by the CLI / config network policy, not by the patch layer. - Source replacement swaps between the existing local-path and sparse-HTTP index source kinds; it does not add new registry protocols, authentication, or credential handling.
Examples
Local fork during development
# <workspace-root>/cabin.toml
[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10.0.0 <11.0.0"
[patch]
fmt = { path = "../forks/fmt" }
The fork at ../forks/fmt ships a cabin.toml with name =
"fmt" and a version that satisfies ^10. cabin build
resolves fmt to the fork without contacting the registry; the
lockfile records the patch so --locked re-runs see the same
state.
Workspace-wide local index mirror
# <workspace-root>/.cabin/config.toml
[source-replacement]
"https://example.com/index" = { index-path = "../mirror" }
Every cabin resolve / fetch / build / update for this
workspace uses <workspace-root>/.cabin/../mirror as the
effective index source. Local config never leaks into published
metadata.
Disabling patches for one invocation
cabin build --no-patches
The active manifest / config patch policy is ignored for this one run; ordinary dependency declarations stay in effect.