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 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.