Packaging
A deploy moves components between environments you control. A package moves them to someone you do not control: a tenant with its own schema, its own admins, its own customizations, and its own upgrade schedule. Everything that is easy inside one org — rename a field, drop a component, fix an ordering mistake by redeploying — becomes a compatibility contract the moment a third party has installed it.
A package is therefore two things. The package is a canonical component you author, naming what belongs to it and how each member behaves in a subscriber’s tenant. A package version is what a build produces from it: an immutable, content-addressed artifact that installs, upgrades, and uninstalls as one transaction. Content-addressed is meant literally: publish recomputes the artifact’s content hash from the submitted body and refuses a version whose declared hash disagrees, so a version’s identity derives from what it actually contains rather than from what its publisher asserted about it.
Verified 2026-09-10 (CAOS-909) — a published package version carries a Sigstore signature over its closure and identity header. Publish verifies it before recording the version, and install by key verifies it again before any gate runs. What is checked, who may sign, and what is out of scope are under distribution, trust, and entitlement. This notice previously withdrew the signed claim, because until CAOS-909 nothing produced or checked a signature.
A package is also the mechanism by which user interface arrives at all. The kernel is a pure engine that interprets metadata; it ships exactly two baked-in surfaces — a sign-in screen and an empty-workspace landing — and nothing else a user would recognize as an application. The shell, the setup app, every business app, the design system: all of it is metadata delivered by an installed package. A package may therefore do more than add fields and layouts to an org — it may claim a platform capability and become the org’s shell or setup app. That is the load-bearing addition this page exists to explain, and it comes first.
Packages and capabilities
Section titled “Packages and capabilities”The kernel exposes a fixed set of capability slots — the shell, the setup app, and the rest — and a package becomes one of them by registering a Capability Registration in its manifest. This is a metadata type in its own right (see the object model): a package’s claim to either be a capability or extend one. There are two shapes of claim, and the kernel’s Capability Registry governs each differently.
Mastering a capability. A package that declares, for example, the shell capability asks to be the org’s shell — to own the header, navigation, global search, and the app switcher. The registry enforces one master per capability. At most one installed package holds a given capability at a time; a second package that declares the same capability does not silently co-exist, layer, or merge. The registry either rejects the second claim or performs a governed handoff — never a silent combination of two masters. Before any claim is accepted, the registry validates the claimant against the capability’s contract — for the shell, the set of required surfaces, injection points, and theme/brand seams a conforming shell must provide. Conformance is checked at claim time, not discovered at render time: a package that declares shell but omits a required surface does not become the master.
Extending a capability you do not own. A capability master owns the frame, but not everything mounted into it. The registry exposes injection points — named seams where a non-master package contributes content without claiming the capability. A package can add an item to the shell’s avatar menu, drop a control into the global header, or contribute a page to the setup app, all without replacing the master that owns it. Each injection point is a contract in both directions: the master guarantees a contributed element is mounted, positioned, and rendered against a stable interface; the contributor guarantees it supplies content that conforms. One convention worth stating because tooling relies on it: a package that injects setup pages brings at least one group, and each group contains at least one page — a lone page with no group to hang under, or an empty group, is a malformed setup contribution and is rejected, so the setup app’s navigation is never left with a dangling or empty entry.
Replacement is the org owner’s decision, and the incumbent may hand off in advance. Whether a sitting master may be replaced by a challenger is not compiled into the engine, and it is not the incumbent’s to refuse. The kernel provides the mechanism — it can transfer a capability from one master to another — and reads two inputs to decide whether a given transfer is permitted. The default is deny: with neither input present, a challenger’s claim is refused and the incumbent stays master. The two inputs are deliberately not symmetric.
- The org’s grant. An org owner may record, per capability, that this org’s master of it may be replaced. That grant is org-scoped metadata, not part of any package: it is set and cleared over the API and the CLI (
caos capability-override set --capability shell), it is reachable in an org with nothing installed — no shell, no setup app, no UI — and no install, upgrade, or deploy touches it, so a publisher can neither acquire it nor take it away. A setup page over it is an optional editor, never the mechanism. - The incumbent’s hand-off. The incumbent’s publisher may declare
replaceable: trueon its own capability claim, which is a standing hand-off — “whoever installs me, you may swap me out without asking”. It is a grant a publisher gives, and it is the only thing a publisher decides here. Its absence, and an explicitreplaceable: false, mean the same thing: no standing hand-off. Neither is a veto over an org owner’s own grant.
Either input alone permits the replacement. The refusal, when both are absent, names the incumbent and the exact command that lifts it — a default-deny gate whose real failure mode is not the denial but an administrator who is entitled to proceed and cannot find out how. Claiming an unoccupied slot — the first shell in an empty org — is not a takeover and is allowed by default; one-master forbids a second master, not a first.
Verified 2026-09-07 (CAOS-846) — the org grant is what this paragraph previously said did not exist. Until CAOS-846 the incumbent’s publisher-authored flag was the ONLY way through, and no package in the tree sets it, so every installed master was in practice irreplaceable in every org that installed it: an org that installed a shell could not evaluate a different one, whatever its owner wanted. That was not a safety property — it was a third party holding a decision that belongs to the tenant. The earlier text described the situation accurately and called the gap a gap; what it framed as “the incumbent’s policy” is now the incumbent’s OPTIONAL hand-off alongside the org’s own decision.
Verified 2026-08-27 (CAOS-857) — before CAOS-857 there was no supported route to change the incumbent’s flag, but there was an unsupported one: an administrator could deploy a changeset re-declaring the incumbent’s own package body with the flag flipped, and the ordinary deploy path accepted it, after which the next deploy re-derived the new value and admitted a challenger it had just refused. That is refused as a write to an installed package’s own body (see Manageability). It is also why CAOS-846 is framed as replacing an unsupported workaround with a real, audited mechanism rather than as granting a power nobody had.
Install is not a special mechanism. There is exactly one way metadata changes an org: a validate-and-commit operation that checks a change set against its schemas and referential integrity and applies it in one transaction or rejects it whole. Installing a package runs that same operation over a versioned bundle plus its capability registrations. It is the same commit the CLI and the API reach; the only difference is that the metadata was authored and shipped by someone else as a managed unit, and that the commit also records the package’s capability claims with the registry. “Install” is not a second write path, and because the API is the floor — reachable with no package installed — an org is never trapped: an admin can install, remove, or reconfigure any package headlessly, even with no UI at all.
A package never extends the engine. This is the line that makes all of the above safe. Even a capability master only edits metadata the kernel interprets — an app definition, a set of page shapes, a design system, a capability registration. It does not add code to the enforcement engine. The access engine that checks every read and write, the interpreter that renders metadata, the deploy primitive, the closure/version/merge machinery on this page — those live in the kernel and cannot be swapped by any package. The engine owns the rules; a package owns the editor and the presentation. Mastering the setup capability lets a package own the administration UI; it does not let that package decide who is allowed to administer.
An app does not depend on a specific workspace, and must not declare that it does. An app package delivers apps, tabs, page shapes, and their supporting metadata; it is seen inside a workspace — the package that masters the shell capability owns the navigation, header, and app switcher an app is reached through. That relationship is a runtime capability relationship, not a build-time package dependency, and the distinction is load-bearing. A package dependency is reserved for a genuine closure dependency — a formula that reads another package’s field, a layout that binds another package’s object — the kind of reference that makes two packages share real schema and forces single-version resolution. “An app needs some workspace to be visible” is not that: it names no member, shares no column, and would be satisfied by any conforming shell. Encoding it as a dependency on one shell package would privilege that workspace — breaking the white-label and shell-swap guarantees the capability model exists to protect, since swapping the shell is supposed to be installing a different package with no change to the apps it frames. So the rule is: an app package declares no dependency on any shell/workspace package. It installs and validates on its own, and installing an app into an org with no workspace is allowed — installation is a metadata commit that is independent of render, and the engine never imposes a workspace-first order that would make one package a precondition for another.
What the platform owes the admin instead is a soft hint, not a hard gate. With an app installed but no workspace registered, the kernel’s boot decision still resolves the empty-workspace landing — a valid empty state, not an error — and it makes that landing app-aware: it reports that an app is installed and guides the admin to add a workspace to open it, with the primary call-to-action pointed at installing one. An org that has installed nothing sees the neutral standing state instead, offering a workspace or a first app as equal starting points. Either way the guidance is advisory: the app is already a fact the engine holds, and it becomes reachable the moment any shell is installed, whichever one the tenant chooses.
The resulting ownership graph is not hidden state. The describe layer reflects it as capability_registration rows — for each capability, which package masters it and which packages inject into it — queryable through the same describe surface as any other fact about the org.
Two derived properties
Section titled “Two derived properties”The rest of this page rests on two decisions that follow from packaging into a tenant you cannot see.
The first: the version number is computed from a diff of the package’s public surface, not declared by the publisher. A build compares the surface signature of what is being built against the surface signature of the last released version, derives the minimum legal semantic-version bump from the difference, and refuses to emit an artifact whose declared version is lower than that. Semantic versioning stops being a promise a human remembers to keep and becomes a property the build proves — the same posture Rust’s ecosystem reaches for with cargo-semver-checks, lifted into the platform rather than bolted on beside it.
The second follows from the first: because the version is derived from the surface, the upgrade path is derived from the version order. There is no ancestry field to fill in, and therefore no ancestry field to get wrong.
The model
Section titled “The model”| Concept | What it is | Mutable? |
|---|---|---|
| Package | A canonical component: identity, namespace, root members, capability registrations, dependencies, per-member manageability | Yes — it is source |
| Package version | A build artifact: the closure of components at one commit, plus a surface signature and a dependency lock | No — immutable once built |
| Release state | A published version is beta until its publisher promotes it to released. A beta version installs by key only into a trial or sandbox org; only a released version installs into a customer tenant or serves as an upgrade source there. protection separately decides who may install it — public needs only the universal key, protected also needs a live secret key — and revoking the universal key withdraws it in either state |
Promotion and revocation are each one-way |
| Installation | A record in the subscriber tenant binding an installed package version to the components it owns | Tracks upgrades |
| Entitlement | A control-plane record granting a tenant the right to install and run a package version | Yes |
Verified 2026-09-10 (CAOS-1555). The registry records a release as promoted_at and promoted_by on the published version (migration 0106), and a null promoted_at is beta. packages install-by-key refuses a beta version in a paid org, or in an org with no registration, as permission.published_version_unreleased, before any secret key is checked or anything is written. An upgrade is that same install over an older version, so the gate covers the upgrade source too. Promotion is gated package.promote, and only the org that published a version can promote it. Versions published before the gate existed are recorded as released at their publish time, because publishing was the release act when they were published. Before CAOS-1555 there was no release state: a version was installable in every org the instant it was published, and CAOS-1251 recorded that.
Membership: declared roots plus a computed closure
Section titled “Membership: declared roots plus a computed closure”A package does not own “whatever is in a directory.” It declares root members by component key, and the build computes the dependency closure of those roots from the static dependency graph — the fields a formula reads, the object a layout binds, the calc function an automation calls.
The build then enforces one rule: every component in the closure must be either a declared root, or owned by a declared dependency. A closure that reaches anything else fails the build, naming the component and the reference path that pulled it in. Accidental capture — shipping a component you did not intend to support forever — and accidental omission — shipping a formula whose field is not in the package — are the same class of error, and both are caught at build time rather than at a subscriber’s install.
The public surface
Section titled “The public surface”Only part of a package is a contract. Each member declares a visibility:
public— the subscriber and dependent packages may reference it. It is in the surface signature; changing it moves the version number.protected— usable at run time by the package’s own logic, not referenceable by anything the subscriber authors, not readable as source. It is not in the surface signature.internal— not referenceable at all, not visible in any authoring surface. Not in the surface signature.
A protected member’s source is hidden, but the member is never hidden from the execution trace or audit streams — a trace names the protected key that ran and how long it took. IP protection is a source-visibility property, not an observability exemption; a package that could execute invisibly inside a customer’s tenant would make the tenant’s own audit record incomplete.
The surface signature is the hash of every public member’s key, data type, nullability, required-ness, scale and precision, picklist domain, function signature and return type, and manageability declaration. It is the thing version numbers are computed from.
Manageability: four independent axes, declared per member
Section titled “Manageability: four independent axes, declared per member”What a subscriber may do to an installed component is not a property of the component type. It is declared by the publisher, per member, on four axes that vary independently:
| Axis | Values | Meaning |
|---|---|---|
upgrade |
publisher | subscriber | merge |
Who wins when a new version changes a member the subscriber also changed |
subscriberEdit |
none | { attributes: [...] } | full |
Which attributes of the member the subscriber may change at all |
retirable |
derived from visibility |
Whether the publisher may remove the member in a later version |
visibility |
public | protected | internal |
Referenceability and source visibility, as above |
retirable is derived rather than declared because the two are the same fact: an internal member may always be retired, a protected member may be retired in a minor version, and a public member may be retired only in a major version — which the surface diff already knows and already enforces. Declaring it separately would only create a way for the two to disagree.
The full manageability table for a package version is presented to the installing admin before install, not discovered afterward — it is the contract, so it is presented as one. That pre-install screen is itself part of the Package Manager surface, which is package-delivered UI over the kernel’s package-management APIs; the table’s contents — the manageability the build captured — are produced by the engine, and the screen only reads them.
What the axes govern, and what they never touch
Section titled “What the axes govern, and what they never touch”Verified 2026-08-27 — subscriberEdit is enforced on the deploy path as of CAOS-857. Before that it was declared and read by nobody, and an administrator holding metadata.author could rewrite any installed component, including a package’s own body.
Three tiers, and the question that separates them is always whose statement is this — never who holds the stronger permission.
An installed package’s own body is not writable by either side. Its roots, dependencies, version, manageability and capabilityClaims are the subscriber org’s record of what it agreed to install. An org that edits it does not change the package; it changes its own account of the package, and every gate that reads the record afterwards is reading a forgery — the version floor compares against a version the publisher never shipped, the removal set is computed from roots the publisher never wrote, and the capability master is re-derived from it on every deploy. The publisher cannot make it org-editable either: manageability is keyed by member, and a package is not one of its own members, so there is deliberately no axis meaning “the subscriber may edit my manifest.” It changes only through install, upgrade, or uninstall of that package — and it cannot be deleted on its own either, since deleting the install record would otherwise reach the same end in two steps.
A component the package’s roots declare is governed by subscriberEdit. This is legitimately the publisher’s declaration, because what it governs is the publisher’s own definition, and upgrade is the companion axis deciding whose version wins when the subscriber has changed one. none refuses any change; an { attributes: [...] } allow-list refuses the attributes outside it, naming them; full — and an undeclared axis, because an undeclared axis is not a prohibition — permits the edit.
Everything the org created is always the org’s. A field the admin added to a package-shipped object, a page, a permission set, a list view: never gated by any of this. Nor is the org’s data. A publisher declaring subscriberEdit: "none" is saying “do not edit my definition” — never “you may not use your own records,” and never anything about the permissions the org grants over them. The org’s genuine decisions about an installed package — whether a capability master may be replaced (CAOS-846), whether a package may be uninstalled (CAOS-844) — belong on org-owned records with their own audit trail, not on the publisher’s body.
Two limits, stated rather than left to be discovered. Ownership resolves only to a package’s declared roots, because the platform persists no per-component publisher record; a member not named in roots is therefore not covered (CAOS-911). And every first-party package currently declares subscriberEdit: "none" as its default with no per-member overrides, which is an untested default rather than a considered contract (CAOS-910).
Authoring
Section titled “Authoring”The package is a canonical component with the standard key / label / type / body envelope.
{ "key": "pkg_billing_suite", "label": "Billing Suite", "type": "package", "body": { "namespace": "dgn", "roots": [ "object:invoice", "object:invoice_line", "calc_function:labor_hours", "permission_set:ps_billing", "layout:invoice_billing", "app:billing" ], "capabilities": [ { "extend": "shell", "injectionPoint": "avatar-menu", "member": "action:open_billing_settings" }, { "extend": "setup", "group": "grp_billing", "members": ["setup_page:billing_rates"] } ], "dependencies": [ { "package": "pkg_pricing_core", "range": "^2.4" }, { "package": "pkg_units", "range": "^1.0" } ], "platform": ">=2026.2", "manageability": { "default": { "upgrade": "publisher", "subscriberEdit": "none", "visibility": "public" }, "members": { "layout:invoice_billing": { "upgrade": "merge", "subscriberEdit": "full" }, "permission_set:ps_billing": { "upgrade": "merge", "subscriberEdit": { "attributes": ["assignments"] } }, "field:invoice.margin_floor": { "upgrade": "publisher", "subscriberEdit": { "attributes": ["defaultValue", "helpText"] } }, "calc_function:labor_hours": { "visibility": "protected" } } }, "data": [ { "object": "pricing_rate", "keys": ["rate_std_labor", "rate_std_freight"], "upgrade": "overlay" } ], "install": ["automation:pkg_billing_bootstrap"] }}Every key in that body is a decision the publisher is asked to make once, in source, under review — not a checkbox someone clicks in a packaging org.
namespace— the prefix applied to member API names at build time. Source keys are written unprefixed; the namespace is applied by the build.roots— the declared members. The closure is computed; it is never declared.capabilities— the package’s capability registrations: amasterclaim to be a capability, or anextendclaim into a named injection point. A setup extension carries itsgroupbecause a package injecting setup pages brings at least one group containing at least one page.dependencies— other packages, by range, not by pinned version.^2.4means “compatible with 2.4” under the derived-semver rules below, which is only a safe thing to write because the bump is proven rather than promised.platform— a floor on kernel capability. A tenant below the floor refuses the install with the required version named, rather than installing and failing at first use.manageability— adefaultplus per-member overrides.data— configuration-data records the package ships (below).install— declared install steps, which are ordinary effectful automation components (below).
Disposition of data retained after an uninstall is not a key in this body. It is the subscriber org’s own decision, made over the CLI/API — purge now, or never, with never as the default. Verified 2026-09-24 — retention is the shipped default and purge is immediate; purging after a stated period is not built. A deferred purge is a scheduler plus a visible, cancellable pending-purge record, and it was deliberately left out rather than half-built.
The build artifact
Section titled “The build artifact”caos package build emits a package version, which is produced and never authored:
{ "artifactVersion": 1, "key": "pkg_billing_suite", "version": "3.2.0", "namespace": "dgn", "dependencies": [ { "package": "pkg_pricing_core", "range": "^2.6.0" }, { "package": "pkg_units", "range": "^1.3.0" } ], "contentHash": "sha256:4a7e…", "package": { "type": "package", "key": "pkg_billing_suite", "body": { /* the manifest, verbatim — roots, capabilityClaims, platform, manageability */ } }, "members": [ /* every member component, in stable (type, key) order */ ]}Every header field is a projection of the manifest inside package, never a second opinion about it — buildArtifact refuses to emit when the two disagree. contentHash is a pure function of {package, members} under the kernel’s normaliser, which is what makes two builds of the same source byte-comparable.
Verified 2026-09-10 — this example previously carried build, state, commit, closureHash, surfaceSignature, capabilityClaims, resolvedDependencies, platform and signature as top-level fields, and spelled package as the package key. None of those nine are in the emitted artifact: the shipped shape is the PackageArtifact interface in caos-cli/src/packages/build.ts, asserted against a real emitted file in test/package-build.test.js. capabilityClaims and platform are real, but they live in the manifest inside package, not on the header. signature is not a header field: the Sigstore bundle travels beside the artifact as <artifact>.sigstore.json (CAOS-909); state is not a header field either: a version’s release state is recorded on its registry row when its publisher promotes it (CAOS-1555); the dependency lock is dependencies, carrying the declared RANGES verbatim rather than resolved versions — resolution happens at install.
caos package publish puts the artifact in the global registry as a beta version: installable by its universal key into a trial or sandbox org, where it is validated, and nowhere else. caos package promote <package> --version 3.2.0 releases it, one-way, and only a released version installs into a customer tenant or serves as an upgrade source there. Promoting again changes nothing and says so. --protection protected requires an install to additionally present a live secret key; public (the default) installs off the universal key alone. Revoking the universal key withdraws installability for good, in either state, and leaves every tenant that already installed it untouched.
Verified 2026-09-10 (CAOS-1555) — against the kernel’s packages promote and packages install-by-key routes and the CLI’s package promote. Until CAOS-1555 this line described a gate that did not exist: there was no promote verb on any noun and no release state, so a version published was a version customers could install. CAOS-1251 established that.
Semantics & evaluation
Section titled “Semantics & evaluation”Namespacing, and what it costs
Section titled “Namespacing, and what it costs”A namespace is applied at build time. The repository holds invoice.margin_pct; the built artifact holds dgn__invoice.margin_pct; the subscriber’s tenant gets a real Postgres column named for the namespaced key. A customer’s own invoice.margin_pct and the package’s dgn__invoice.margin_pct are two different columns with two different names, so they do not collide, and neither one has to know the other exists.
Verified 2026-09-10 — the build does not do this yet. buildArtifact validates the declared namespace and carries it on the header, and deliberately does not rewrite member API names: applying a prefix is a reference-rewriting transform across every body that names another member, and install-package.ts records the general case as deferred build-time work. One narrow slice does run, and only in the kernel’s own tooling — applyPackageNamespace in kernel/scripts/authored-source.mjs (CAOS-952) can prefix theme members when it generates the standard registry, theme being the one type with no ComponentRef pointing out of it or in from authored source. It applies only to a package that DECLARES a namespace, and no first-party package does: the generated registry contains no __ at all, measured. So nothing anywhere prefixes anything today — not the caos package build a publisher runs, and not the kernel’s own generator — and installs work on unprefixed names and the collision this paragraph describes is not yet prevented by this mechanism. The design stands; the tense was wrong. Found while correcting the build artifact above rather than by a sweep of this page, so the rest of this section has not been re-measured.
Because the namespace is applied by the build rather than baked into source, it is not permanent. A package may declare renamedFrom: "old_ns", and the upgrade performs the physical rename as an ordinary expand → migrate → contract migration: the new names are added, data and references move, the old names are retained as read-only alias views for a declared deprecation window, and the window’s expiry drops them. This is possible only because a member is a real column with a real name that DDL can rename — the same property that makes side-by-side versions impossible (below). The cost is honest and it is not small: an integration or a subscriber-authored expression referencing the old API name keeps working through the window and breaks at its end, so a rename is a major version, announced, with the alias window sized to the deprecation policy rather than to convenience.
The residual cost of namespacing at all is a real one and it is physical. Postgres caps an identifier at 63 bytes and a table at 1,600 columns (PostgreSQL limits). A namespace prefix spends part of the identifier budget on every member, and every packaged field on a shared object spends part of the column budget of a table the customer also wants to extend. Neither is a limit the platform can wish away.
Moving a member to another package
Section titled “Moving a member to another package”A rename and a move look like the same migration and are not. A rename changes a member’s key, which is why it rewrites every reference that names it, keeps the old name resolving as an alias, and hands the physical layer a DDL rename. A move changes a member’s owning package and nothing else. The key is untouched, so every typed reference, every permission grant and every saved surface — all of which name a member as <type>:<key> — keep resolving across the move without a single body being rewritten, and no DDL runs at all. A field is the same column the day after the move as the day before.
What changes hands is ownership: who may write the member, whose manifest its manageability axes are resolved from, and whose upgrade or uninstall it travels with.
A move is declared by both packages, and it is staged the same way a rename is — expand, migrate, contract:
// the version of the package giving the member up"releases": [{ "member": "field:invoice.margin_pct", "to": "pkg_billing", "until": "2027-01-01T00:00:00Z" }]
// the version of the package taking it on"adopts": [{ "member": "field:invoice.margin_pct", "from": "pkg_quoting", "until": "2027-01-01T00:00:00Z" }]- expand — the donor releases. The member is not retired and stays the donor’s, pending transfer. Both packages resolve it; this is the window in which both answers are valid.
- migrate — the receiver installs its
adoptsversion, and ownership transfers. - contract — the window closes. A transfer nobody completed lapses and the member stays where it was.
The order the two packages are upgraded in does not matter, and the donor may be upgraded long before the receiver exists. That is the reason the donor’s half is required rather than optional: without it, a donor that drops the member is indistinguishable from a donor that removed it, and the removal path retires it.
Five things are refused, so that a move cannot quietly become something else:
- adopting a member its owner never released to you, or releasing a member you do not own;
- a window that has already closed on either side;
- a move that would change the member’s manageability axes — a member crosses unchanged, and widening or narrowing what a subscriber may edit is a separate, declared version;
- a move that leaves a reference crossing a package boundary with no dependency declared in the direction it now runs. Note which direction that is: a field names its object, so moving a field out while its object stays behind makes the receiving package depend on the donor;
- adopting a member without shipping it. A package that takes a member on becomes its publisher, and a publisher that does not declare the member owns nothing to publish.
A donor cannot be uninstalled out from under a move it started. While the window is open the member is still the donor’s, so removing the donor would take the member with it and leave nothing for the receiver to adopt. The uninstall is refused and names the member, the receiver and the window. Completing the move, shipping a donor version that does not release it, or letting the window lapse each clear it — but all three are the publisher’s to perform, and until is an instant the publisher chose that a subscriber cannot shorten. So that a subscriber is never trapped by a move somebody else abandoned, allowDestructive abandons it and removes the package, taking the released member with the donor’s own. Like every other use of that flag it is a considered act over the CLI and API, offered on no authored surface.
Splitting a package and folding two together are the same operation. A split publishes the new package and moves a subset of members to it. A fold moves every member of one package to the other and uninstalls what is left. There is no separate mechanism for either, which is the point: the packaging decision recorded on a capability is revisable rather than permanent.
Two packages declaring the same member identically remains what it always was — a shared declaration, not a move. It is still permitted, and the last install to run still ends up recorded as the publisher. A move is the declared, gated, reversible form of that, and only the declared form holds a member back from retirement.
Deriving the version
Section titled “Deriving the version”The build computes the minimum legal bump from the surface diff, and the declared version must be at least that:
| Change | Minimum bump |
|---|---|
| Remove or retire a public member | major |
Narrow a type, reduce scale or precision, add NOT NULL to an existing public field |
major |
| Add a required public field with no default | major |
| Remove a value from a public picklist domain | major |
| Change a public calc-function signature or return type | major |
| Tighten a validation rule on a public object | major |
| Change the namespace | major |
| Add a public member, or an optional public field | minor |
| Widen a type, add a value to a public picklist domain | minor |
| Loosen a validation rule; change a formula’s expression without changing its declared type | minor |
Any change confined to protected or internal members |
patch |
| Labels, descriptions, help text, layout position | patch |
Two of those rows are decisions worth stating plainly. Tightening a validation rule is breaking because it can reject a save that previously succeeded, which is indistinguishable to a subscriber from removing a capability. And adding a picklist value is minor, not major, because the risk it carries — a subscriber’s expression that matches exhaustively over the old domain — is caught by the compiler at upgrade validation with the expression named, rather than at run time with a silent fallthrough. The one typed language makes that a compile error, so the version rule does not have to be pessimistic to compensate for a weak type system.
Ancestry, without an ancestry field
Section titled “Ancestry, without an ancestry field”The ancestor of a released version is the highest released version of the same package that precedes it in version order. It is not written down, so it cannot be written down wrongly, and a version cannot be built against a nonexistent or unpromoted ancestor because the build reads the registry rather than a hand-maintained field.
Parallel development does not need a branching ancestry tree — it needs two packages. If two teams genuinely ship independently upgradeable artifacts, they publish two packages with a dependency between them, which is a structure the resolver already understands and a subscriber can already see. A branch of one package’s version history that both must be upgraded along is not a modelling win; it is the mechanism that strands subscribers on abandoned branches.
The upgrade transaction
Section titled “The upgrade transaction”An upgrade is one generation flip. The subscriber’s tenant computes the diff between the installed version’s closure and the new version’s closure, orders it with the same topological auto-resolve as any deploy, and applies it. Class-A members — formulas, validation rules, layouts, permission sets, automation — are catalog rows and are hot. Class-B members — columns, type changes, constraints, indexes — go through expand → migrate → contract with a bounded lock_timeout and retry, exactly as an ordinary deploy does.
Packaged data migrations are declared automation components, and the contract on them is strict: idempotent and resumable. A migration that has already run against a row must be a no-op on a second pass, because the only safe recovery for a partially-applied batched migration is to run it again. A migration that cannot state that property is not shippable.
Customer data is never dropped implicitly. Removing a member from a package does not drop the subscriber’s column. The upgrade routes the removal through safe-delete: the member is retired — inactive, hidden from authoring surfaces, absent from the package’s ownership record, and still holding its data. Purge is a separate, explicit act by the subscriber’s admin. A publisher can remove a field; only a subscriber can destroy the data in it.
That rule closes the incumbent’s most-cited packaging trap. A retired member is no longer owned by the package, so the same API name can be reused by a later version without colliding with a leftover the subscriber never cleaned up; the retired member is renamed into a retirement namespace at the moment of retirement rather than left occupying the live name.
Subscriber edits and the merge
Section titled “Subscriber edits and the merge”upgrade: publisher means the publisher’s definition is authoritative and replaces whatever is there. upgrade: subscriber means the member is seeded at install and never touched again. Neither is interesting. upgrade: merge is the one that does work, and it uses the same field-level three-way merge as reverse-integration between environments:
- The common ancestor is the member’s definition as shipped by the currently-installed version, which the installation record stores for exactly this purpose.
- One side is the subscriber’s edited definition; the other is the new version’s definition.
- Attributes changed on only one side merge automatically.
- An attribute changed on both sides is a genuine conflict: the rest of the member merges, the conflicting attribute is quarantined, the installed value is kept, and the conflict is surfaced to the admin with both values and their provenance.
This is why a merged page layout can gain the publisher’s new section and keep the subscriber’s reordered fields in the same upgrade, and why a package cannot silently discard a customer’s work. The alternative the industry has settled for — layouts that simply never upgrade, so existing customers stay on the layout they installed with forever — trades a merge problem for a permanent divergence problem.
A quarantined attribute waits until a person decides, however long that takes. The conflict is a row on the installation record — member key, attribute, the installed value that is in force, the publisher’s incoming value, the provenance of each, the version that raised it, and when. It has no retention window and no expiry. A timer would resolve the conflict by deleting it, and deleting it resolves it in the publisher’s favour by making the divergence invisible: the subscriber’s value stays in force, the publisher’s change is never applied, and nobody is told that the two ever disagreed. Nothing else on the platform destroys a pending human decision on a schedule, and neither does this. A conflict is cleared three ways, all of them attributable: the admin keeps the installed value, takes the publisher’s, or writes a third value; each is an audited act with an actor. Uninstall clears the rest, along with the installation record that holds them.
A further upgrade rebases the conflict rather than stacking on it. When version N+2 arrives and the same attribute has moved again, the three-way merge runs against the same ancestor it always did — the definition as shipped by the currently installed version, which is still N, because the subscriber’s value was never replaced. The pending conflict’s incoming side is replaced by N+2’s value; the entry keeps its original raised-at timestamp, records the versions it has survived, and does not multiply. One attribute therefore never carries more than one open conflict, and an admin returning after four releases makes one decision per attribute instead of four — each against the publisher’s current intent rather than against a queue of superseded ones. If a later version happens to move the attribute to the value the subscriber already holds, the conflict disappears on its own: the two sides now agree, and there is nothing left to decide.
Unresolved conflicts never block an upgrade. Blocking would let one contested layout attribute hold a security fix hostage, so the upgrade proceeds, the conflicted attribute stays quarantined, and the count and the age of the oldest are on the installation record and in the upgrade’s pre-flight report. Age is the pressure the design applies; refusal is not.
Dependency resolution and the diamond
Section titled “Dependency resolution and the diamond”Dependencies are declared as ranges; the resolver computes the transitive closure and solves for one version per package per tenant.
The diamond — A depends on C ^1.2, B depends on C ^1.5, and both A and B are being installed — resolves to the lowest version of C that satisfies every constraint in the closure, here 1.5.x. If no version satisfies all constraints, the install is refused, and the error names the two constraints that cannot both hold and the packages that impose them. It is never resolved by picking a winner.
Single-version resolution is not a preference; it is forced by the storage model. A packaged field is a real Postgres column with a real name. Two versions of the same package cannot coexist in one tenant because they would need two definitions of the same column. Ecosystems that allow side-by-side versions — npm’s nested node_modules being the canonical case — can do so because a module is a file, not a schema object. CAOS is necessarily in the other family, alongside Maven, which resolves a conflict by mediation rather than duplication (Maven dependency mechanism). Where CAOS differs from Maven is the rule: Maven picks the “nearest definition” in the dependency tree, which makes the outcome depend on graph shape and can silently select a version that satisfies nobody’s stated constraint. CAOS solves the constraints and fails loudly when they are unsatisfiable, because a wrong schema version is not a runtime surprise a developer can catch — it is a column that is the wrong type in a customer’s tenant.
Circular dependencies are rejected at build. The resolution itself is not recorded in the artifact. The artifact carries the declared dependencies ranges verbatim and nothing else; the closure is solved at install time, against what the tenant already has. That is deliberate rather than a gap, because a lock frozen at build time would assert an answer the publisher cannot know: which version of C is chosen depends on what else is installed in that tenant and at what version, so the same artifact can resolve to different versions on two tenants and be correct on both. What is reproducible is the RULE — the same ranges against the same tenant state always produce the same closure, and an unsatisfiable set is always refused rather than resolved by picking a winner.
Install-time logic, and whose identity it runs as
Section titled “Install-time logic, and whose identity it runs as”There is no free-form post-install script. Install steps are declared automation components listed in install, subject to the ordinary tier rules and the ordinary save order, and they run under a package service identity.
That identity’s permissions are the intersection of two sets: what the package declared it needs in its manifest, and what the installing admin granted at install time. It is never the installing user’s identity — borrowing a human’s permissions makes the audit record wrong and makes the blast radius a function of who happened to click install — and it is never platform.root. Every write an install step makes is attributed to the package service identity in the data-history stream, so “what did this package do to my data when I installed it” is a query, not an inference.
Install steps are idempotent and resumable, and a failure runs the registered compensations in reverse and reports which succeeded — the same compensating-action boundary any out-of-transaction deploy layer lives under. A failed install leaves the tenant on the generation it started on.
Packaged configuration data
Section titled “Packaged configuration data”Packages ship records, not only schema: default rates, seeded thresholds, reference rows. Those are configuration data, and they upgrade as an overlay rather than an overwrite. The publisher’s shipped value is the base layer; a subscriber’s tuned value is an overlay on top of it; an upgrade replaces the base layer and leaves the overlay standing — the same overridable-value mechanism the platform already uses, applied across the publisher/subscriber boundary instead of within one tenant.
A publisher who genuinely must control a value declares upgrade: "pinned" on it, which suppresses the overlay and is shown in the pre-install manageability table. Pinning is legible before install rather than discovered when a customer’s tuned rate silently reverts.
Distribution, trust, and entitlement
Section titled “Distribution, trust, and entitlement”A released package version is published to the marketplace. The artifact is content-addressed and signed with keyless signing over a transparency log, so the consumer verifies four things rather than trusting a key: the signature is valid, the signing identity is the one expected for the publisher, the certificate chains to the trust root, and the signing event is present in the public log (Sigstore overview). An install whose artifact hash, signature, or log inclusion fails verification is refused before any component is written.
How that is built:
- Signing.
caos package build --signsigns the version with public Sigstore, as the OIDC identity the build runs as, and writes the bundle beside the artifact as<artifact>.sigstore.json. That identity is a CI workload identity such as a GitHub Actions workflow.caos package publishsends the bundle; with--signit signs a project build in memory instead. An unsigned version is refused before anything is sent. - What is signed. The signature covers the closure,
packageandmembers, plus the header the registry acts on:key,version,namespaceanddependencies. So a signed closure cannot be republished under another version or with wider dependency ranges.protectionis chosen at publish and is not signed. - Who may sign. A publisher org’s signing identities, each an OIDC issuer and subject, are platform deployment configuration. A publisher cannot enroll one through the API, so a stolen publish credential cannot add its own. Install checks the signer against the configuration in force at install time, so withdrawing an identity stops installs of everything it signed.
- Trust root. Verification runs against a committed snapshot of Sigstore’s public-good trusted root, so an install never calls a Sigstore service. A new log shard or certificate authority is refused until the snapshot is refreshed in a reviewed kernel change.
- Unsigned versions. There is no “unverified” state and no backfill. A version published before signing existed carries no signature, and install refuses it as
unsigned. - Refusals. Publish refuses with
validation.artifact_signature_invalid, and install withpermission.artifact_signature_unverified. Each names the check that failed:unsigned,malformed,identity_unconfigured,certificate,transparency_log,timestamp,signature,identityorverification.
Verified 2026-09-10 (CAOS-909) — against the kernel’s publish and install-by-key routes, including verification inside the Worker runtime the kernel deploys to, and against the CLI’s package build --sign and package publish. Before CAOS-909 this section was the design only: nothing produced a signature and install verified nothing.
Updated 2026-09-26 (CAOS-1561) — a signing identity is now configured for the first-party publisher org, one per shipped package repository, so identity_unconfigured is no longer what refuses every publish. What is trusted is a workflow ref rather than a repository or a person: the subject is one workflow file on main, compared exactly, so the same repository signing from another branch or another workflow is refused as an identity that does not speak for that publisher. That also makes publishing a deliberate, manually dispatched act — a tag-triggered run could not match, because its ref is not a branch ref.
PUBLISHING STILL CANNOT HAPPEN, and the remaining reasons are not this configuration. None of those seven repositories has the publish.yml the subjects name, so nothing can present a matching identity yet; and a signing run needs two things nobody has granted. It needs a publishing credential for the publisher org stored in each repository — those repositories today hold one secret each, for reading private packages, and creating a publishing credential on shared infrastructure is a decision rather than a workflow edit. And signing writes a permanent, public entry to the Sigstore transparency log naming the repository and workflow, which page 28573697 §3 accepted for real first-party releases while CAOS-909 deliberately kept it out of proving — so whether a throwaway proving fixture may write one is still undecided. End-to-end publication is therefore not proved, and this section does not claim it is.
Entitlement is separate from distribution. The right to install and run a package version is a control-plane record against the tenant (onboarding & provisioning), and package members can carry a license gate that participates in the permission planes like any other grant. A lapsed entitlement removes access to the package’s surfaces; it does not delete the customer’s data, and it does not uninstall anything.
An entitlement names the tenant, the package, a version range it covers, and a term. It has five states:
| State | Meaning | What the tenant may do |
|---|---|---|
granted |
Issued, not yet in force — before its valid-from date, or issued ahead of the first install | Install a version inside the range once the date arrives |
active |
In force | Install, upgrade, and run everything the package ships |
lapsing |
The term ends inside the notice window — 30 days by default, publisher-settable with a floor of 7 | Everything active allows, plus a dated notice on the package record and to the installing admin |
lapsed |
The term ended | Run nothing gated; install and upgrade refused; data and installation intact |
revoked |
Withdrawn for cause, without notice | Same removal as lapsed, immediately, with a recorded reason |
Transitions are mechanical and each one is an entry in the metadata audit with an actor and a time. granted → active on the valid-from date. active → lapsing automatically as the notice window opens. lapsing → active on renewal, with no admin action and no reinstall, which is the common case and must therefore be the silent one. lapsing → lapsed at term end. lapsed → active on reinstatement. Nothing transitions to uninstalled: a lapse never removes a package, because auto-uninstalling on a billing event would destroy a customer’s data over a payment dispute. Uninstall stays an act by the subscriber’s admin, on the path below.
What a lapse actually removes is reachability, and nothing else. License-gated members stop resolving, so the package’s apps, tabs, list views, record pages, and actions leave navigation for every user, and invoking one returns a permission-class error naming the entitlement rather than a generic denial. Jobs and schedules the package owns are paused, not deleted, so a lapsed package consumes none of the tenant’s throughput budget and calls nothing outbound. Install and upgrade are refused; a version outside the entitlement’s range is refused with the range named.
What keeps running is everything that determines whether stored data is correct: the columns, their values, the constraints on them, and the packaged formulas, roll-ups, and validation rules that maintain and guard them. A lapse must not change the meaning of a row or let one drift out of validity, because the customer still owns that data, still has to report on it, and still has to be able to take it with them — export of packaged columns is a platform capability and is never license-gated. The honest cost of that line is that a lapsed subscriber keeps some of the value the package computes; the alternative is a platform that answers a billing lapse by silently corrupting a customer’s numbers, which is not a trade worth making.
Reinstatement restores reachability and nothing more, because nothing else was taken. Gates resolve again, the surfaces return, and the paused schedules resume under their own onMissed policy — so a six-month lapse produces the catch-up behavior the schedule declared rather than a herd of missed occurrences arriving at once. The tenant comes back on the version it was on: reinstatement is not a reinstall and never an implicit upgrade. Versions released during the lapse are reachable by the ordinary upgrade path in version order, on the subscriber’s schedule, and because ancestry is derived rather than authored, a long lapse cannot strand a tenant on a branch nobody maintains.
Uninstall
Section titled “Uninstall”Uninstall is safe-delete applied to the whole ownership set:
- Reference check. Subscriber-authored components referencing packaged members are found in the dependency graph and reported by key. The admin either breaks the references or explicitly accepts a cascade-retire of them.
- Retire. Every member the package owns — including previously retired ones — is retired. Columns are retained, data intact, exportable in bulk through data management.
- Retain. Data is held indefinitely by default. Disposition is the subscriber org’s choice, made over the CLI/API — purge now, or never, with never as the default. Verified 2026-09-24 — retention is the shipped default; purging after a period the org chooses is not built, and
purgeis immediate when asked for. - Purge. Physical drop is an explicit, separately-authorized operation, never implicit in uninstall.
A field another installed package added is not swept away with the object. Uninstall retires the fields on the objects the package owns. When one of those fields belongs to a different package that is still installed, the uninstall is refused and names that package, so it can be uninstalled with this one or first. Ownership is read from that package’s declared roots, from the publisher record an install writes for each member, or, where an install left no publisher record, from the package’s member list. When that member list cannot be looked up, the uninstall goes ahead, and its result names the installed packages it could not check. An install made before publisher records existed is the usual reason a package has none. A package whose members are all other packages has none either, so it can appear in that list even though nothing about it is unknown.
A capability master carries one extra step: uninstalling the package that holds a capability releases that capability slot. Removing the shell package does not remove a feature from the kernel — it returns the org to the empty-workspace landing until another shell is installed. The capability registration is retired with the rest of the ownership set, and the slot is vacant, not broken.
Reinstalling before the data is purged reattaches the retired members to the new installation rather than creating a second set, which makes an uninstall/reinstall a recoverable operation rather than a data-loss event.
Lifecycle, against the environment model
Section titled “Lifecycle, against the environment model”The package development loop maps onto environments with no packaging-specific machinery:
| Stage | Environment | What happens |
|---|---|---|
| Develop | origin: from_repo, lifecycle: ephemeral |
The component set is applied to a clean tenant; source is authoritative |
| Build | A clean ephemeral env provisioned from the manifest | The closure is computed and applied, the surface signature captured, the artifact emitted and, with caos package build --sign, signed (CAOS-909) |
| Validate | A clean ephemeral env, install-from-artifact | The artifact is installed as a subscriber would install it, then upgraded from the previous published version to prove the upgrade path |
| Publish | — | The artifact enters the global registry as a beta version, installable by key only into a trial or sandbox org — see the build artifact |
| Promote | — | caos package promote releases the version, one-way: from then on it installs into a customer tenant, serves as an upgrade source there, and becomes the next version’s ancestor |
The upgrade rehearsal in the validate stage is mandatory, not optional. A build that has never been upgraded to has not been tested; the failure modes that matter for a package — a migration that is not idempotent, a merge that quarantines everything, a column type change that locks — appear only on the upgrade path and never on a clean install.
Limits, and the reasons behind them
Section titled “Limits, and the reasons behind them”| Concern | CAOS approach | Salesforce’s behavior (cited) | Why theirs exists |
|---|---|---|---|
| Namespace | 1–15 characters; changeable via renamedFrom with an alias window, at a major version |
Assigned at package creation and “can’t be changed”; “after you associate a namespace with an org, you can’t change it or reuse it” | An API name is a metadata row keyed by prefix; there is no rename primitive under a flex-column store |
| Version numbering | MAJOR.MINOR.PATCH + build ordinal; the bump is derived from the surface diff |
MAJOR.MINOR.PATCH.BUILD, declared by hand in sfdx-project.json |
Nothing computes the API-surface diff, so the number is a human promise |
| Upgrade path | Version order. No ancestry field | ancestorVersion / ancestorId / HIGHEST; only managed-released versions may be ancestors; "NONE" blocks upgrades entirely |
Ancestry is stored state, so it must be authored — and can be authored wrongly |
| Delivering UI | A package claims a platform capability (shell, setup) or injects into one; the kernel ships no compiled UI beyond two surfaces | The shell, setup, and standard apps are Salesforce’s own product, compiled into the platform; a managed package extends them but cannot be them | The platform’s own UI was never metadata, so there is nothing for a package to claim |
| Moving a member between packages | Declared by both packages (releases / adopts) with a window, in either upgrade order; ownership transfers, the key and the data do not move |
Not compared — no primary Salesforce source was confirmed for this row | — |
| Removing a member | Retired via safe-delete, data retained, name freed by retirement-namespace rename; a member declared as released to another package is mid-move rather than removed, and is not retired | Most removed components “remain in the subscriber org after package upgrade and are marked as deprecated”; reusing that API name later breaks upgrades for subscribers who kept the deprecated component | No rename primitive, so the dead name keeps occupying the live namespace |
| Subscriber edits | Declared per member on four axes; merge uses a field-level three-way merge |
Manageability rules are fixed per component type and enforced at version creation: page layouts are never updated on upgrade; Apex and formula fields always are | A per-type table is the only thing expressible without a merge engine |
| Dependency resolution | Ranges; transitive closure computed; one version per package; unsatisfiable → refuse and name the conflict | Transitive dependencies are not computed by default — dependencies must be listed “in the package installation order” unless calculateTransitiveDependencies is enabled; circular dependencies unsupported |
Resolution was added later than packaging, so hand-ordering is the default |
| Members per package | Bounded by the apply-cost budget of the upgrade transaction | Not a published per-package cap | Both are bounded by what one transaction can hold |
| Packaged fields on one object | Bounded by Postgres: 1,600 columns per table, 63-byte identifiers, tuple must fit one 8 KB page | No equivalent limit — a custom field is a metadata row over generic columns | Real columns are the trade CAOS makes for a real planner and real constraints |
| Version build rate | A throughput budget on the build service | Unlocked package versions per day per Dev Hub equal the daily scratch-org allocation | Each build provisions an org |
| Patch releases | An ordinary derived-patch version; no special enablement, no separate approval |
Patch versioning “requires approval from Salesforce Partner Support and is available only to packages that have passed AppExchange security review”; patches may not add or delete components, add dependencies, or change visibility | Patch orgs are separate infrastructure with their own gating |
| Push upgrade | An entitlement the subscriber grants per package, revocable, and visible in the tenant’s audit stream | Available only to partners whose package passed security review, enabled by Partner Support, and performed “without asking customers to install the upgrade themselves” | Push is a partner privilege, not a subscriber grant |
How Salesforce does it
Section titled “How Salesforce does it”Salesforce ships three packaging models, and the differences between them are the whole story.
First-generation managed packages (1GP) are built in a packaging org: the org, not a repository, is the source of truth. Second-generation managed packages (2GP) are built from source by the CLI — “version control serves as the source of truth with no packaging or patch orgs required” — and a package version is “a fixed snapshot of the package contents and related metadata … an installable, immutable artifact.” Unlocked packages are the same source-driven machinery aimed at internal business apps rather than distribution, and they are markedly more permissive: an admin “can modify packaged metadata directly in production environments,” with the corresponding risk that a later package version overwrites the change. There is no migration between the first two: “You can’t currently migrate a first-generation managed package to a second-generation managed package.”
Configuration lives in sfdx-project.json, where each packageDirectories entry carries path, package, versionName, versionNumber (MAJOR.MINOR.PATCH.BUILD), versionDescription, ancestorVersion, definitionFile, and dependencies, with namespace — “a 1–15 character alphanumeric identifier” — at the top level (2GP project configuration file).
Ancestry is the mechanism that decides upgradeability, and it is hand-maintained. Only versions promoted to managed-released may be named as an ancestor; "ancestorVersion": "NONE" means “existing customers cannot upgrade to that version”; and HIGHEST exists specifically to spare developers from updating the field every release (package ancestors, configuring ancestry). Upgrades follow ancestry lines only — 1.1 → 1.7 is fine along a shared path, 1.2 → 1.3 is not if they do not share one, and downgrades are prohibited (understanding upgrades). Abandoning a branch is permanent: “if abandoned versions like 1.2 and 1.5 are installed in customer orgs, those customers no longer have an upgrade path.”
Practitioners report the failure mode this produces. One published account describes building 0.3 with 0.1 as its ancestor — skipping 0.2 — and finding the version unusable, summarizing the rule as “upgrades can skip versions, but the ancestry for each version cannot,” with the only recovery being to burn the version number and cut another (Foglight Solutions, 2GP and Ancestry).
Manageability rules are fixed per component type and enforced at package-version creation. The axes are the right ones — upgradeable versus locked, subscriber-deletable versus locked, developer-deprecatable versus locked, IP-protected versus visible — but the values are not the publisher’s to choose. The documented consequence: page layouts “are not updated during upgrades — only new customers receive modifications,” while Apex and formula fields always are (components available in 2GP, manageability rules and ancestry).
Removal is deprecation, and deprecation is a trap. Most components removed from a package version “remain in the subscriber org after package upgrade and are marked as deprecated”; a subscriber admin may delete them; uninstall deletes them. But the API name stays occupied, and Salesforce documents the consequence directly: remove project__c in 2.0, ship a new project__c in 5.0, and subscribers who kept the deprecated component fail to upgrade. The prescribed mitigation is procedural — tell your team, keep an internal list of names never to reuse (what to consider before removing metadata, remove metadata components).
Protected components are the partial escape hatch. Custom labels, custom metadata types, custom objects, custom permissions, custom settings, and several workflow types can be marked protected; a protected component “can’t be linked to or referenced by components created in a subscriber org,” and the developer “can delete a protected component in a future release without worrying about failing installations.” The catch is the one-way door: “after a component is marked as unprotected and is released globally, the developer can’t delete it” (protected components).
Namespaces are permanent. A 2GP namespace is “assigned … at the time that it’s created, and can’t be changed,” and at the org level, “after you associate a namespace with an org, you can’t change it or reuse it” — which is why the documentation’s advice for experimentation is to burn a disposable one (namespaces for 2GP, register a namespace). Two packages sharing a namespace must not share an API name or they cannot be installed into the same org, and a 1GP and a 2GP sharing a namespace cannot coexist at all (namespace collisions).
Dependencies are declared per package directory and, by default, are not resolved for you: unless calculateTransitiveDependencies is enabled, you “list all dependencies in the sfdx-project.json file in the package installation order,” including indirect ones. Circular dependencies are unsupported. Version selectors distinguish LATEST (most recently created) from RELEASED (most recently promoted), and the documented guidance is to promote the base package first and reference it with RELEASED (create dependencies, promoting packages with dependencies). The dependency matrix has one hard edge: a 1GP package may not depend on a 2GP package, and installation of 2GP packages into 1GP packaging orgs is blocked (dependency overview).
Uninstall deletes “all components in the package, including any deprecated components that were previously associated with the package,” offers an optional data export, and is blocked in several cases — an external component referencing a packaged component, a packaged custom field referenced by certain Einstein features, a removal that would eliminate all active account record types (uninstall a 2GP package).
Distribution requires promotion to released, an installation key, and — for AppExchange listing — the security review, which exists to “identify security vulnerabilities that a hacker, malware, or other threat can exploit” while stating that Salesforce “makes no guarantees regarding the quality or security of any Partner Application.” Apex must meet “a minimum 75% code coverage requirement,” and “every Apex Trigger in a package needs test coverage” (prepare to distribute, security review overview).
Where CAOS is genuinely better:
- A package can deliver the platform’s own UI, because there isn’t any built in. The shell, the setup app, and app classes are metadata a package claims a capability to become — not a compiled Salesforce product a package can only extend. Swapping the shell is installing a different package, with no engine change.
- The version bump is computed from a surface-signature diff. A build that would break a subscriber cannot be published under a minor version, because the artifact is refused. Compatibility becomes a build output rather than a release-manager habit.
- Ancestry is derived from version order, so there is no ancestry to get wrong. The published failure mode — a mis-authored ancestor burning a version number and stranding subscribers on an abandoned branch — has no field to originate in.
- Membership is declared roots plus a computed closure with a hard boundary. Accidental capture and accidental omission both fail the build, naming the reference path, instead of shipping.
- Removal retires rather than deprecates, and frees the name. A retired member is renamed out of the live namespace, so the “never reuse an API name” rule that the incumbent enforces with an internal spreadsheet is enforced by the platform.
- Manageability is per member and declared by the publisher, and
mergeis a real merge. A page layout can take the publisher’s new section and keep the subscriber’s reordering in the same upgrade, because the installation record stores the shipped definition as a three-way-merge ancestor. - Dependencies resolve by constraint solving over the transitive closure, and an unsatisfiable set is refused with the conflicting constraints named — rather than hand-ordered by the publisher or silently mediated by graph shape.
- Install-time logic runs as a package service identity whose grants are the intersection of declared and admin-approved, attributed in the audit stream — not as a human, not as root, not as an unnamed system context.
- The namespace is reversible. It is applied at build time and renameable through an expand → migrate → contract upgrade with an alias window, because a member is a real column with a real name.
- Uninstall retires and retains before it destroys. Reinstalling before the data is purged reattaches rather than duplicating.
Parity: immutable, content-addressed package versions; a beta → released promotion that is one-way; semantic version numbers; per-member visibility including IP-protected source; declared dependencies between packages; an installation key; a security review before public distribution; push upgrades as a capability; and a source-of-truth repository with an ephemeral build environment. These are the right primitives and CAOS copies them deliberately.
Costs and risks:
- Derived versioning is only as good as the surface signature. A behavioral change that leaves the signature identical — a formula that now returns a different number for the same inputs — computes as a patch. The signature captures shape, not semantics, and no diff engine closes that gap. Release notes and the execution trace carry what the signature cannot.
- Single-version resolution is a real constraint on customers, not just on publishers. A tenant that needs two packages with genuinely incompatible requirements on a third cannot install both, and there is no vendoring escape because the conflict is a column. The failure is loud and legible, which is the best available outcome, but it is still a failure.
- Namespace rename is expensive and only partly safe. The alias window protects expressions the platform can see; it does not protect an external integration hitting the API by name after the window closes. A rename is a major version with a migration cost borne by every subscriber.
- Merge on upgrade shifts work rather than eliminating it. Every quarantined attribute is an admin decision someone must make, and a package with permissive
subscriberEditon many members will generate them. The alternative — never upgrading edited members — has a lower immediate cost and an unbounded divergence cost. - Retirement consumes budget. Retired columns keep occupying the 1,600-column ceiling and the tuple’s page until purge. A package that churns members across many versions puts pressure on a physical limit, and purge is deliberately the subscriber’s decision, so the publisher cannot relieve it.
- The install service identity must be scoped correctly by the publisher. An over-broad declared capability set that an admin approves without reading is exactly as dangerous as an unscoped script. The manifest makes it visible; it does not make it read.
- Packaged data migrations must genuinely be idempotent. The contract is stated and testable, but it is enforced by the upgrade rehearsal and review, not by the type system. A non-idempotent migration that survives review is a data-corruption bug in a customer’s tenant.
Metadata & deploy representation
Section titled “Metadata & deploy representation”| Artifact | type |
Body |
|---|---|---|
| Package | package |
namespace, roots[], capabilities[], dependencies[], platform, manageability, data[], install[] |
| Capability Registration | capability_registration |
The master or extend claim, the capability name, the injection point (for extend), and the member(s) contributed |
| Package version | not a component — a build output | artifactVersion, key, version, namespace, dependencies[], contentHash, package, members[] — see the build artifact for what this does not carry yet |
| Installation | record data in the subscriber tenant | Installed version, owned member keys, the shipped definition of each merge-managed member, open merge conflicts, entitlement reference |
| Entitlement | not a component — a control-plane record | Tenant, package, version range, term, state, notice window |
The split matters and it is the same split metadata & deploy draws between a component and a manifest: the package component is source and is edited; the package version is produced, signed, and never edited. Changing a package’s manageability declaration is an ordinary component diff that takes effect in the next built version, never in an already-released one.
One clarification the engine/UI separation forces here: the Package Manager — the install screens, the pre-install manageability table, the upgrade pre-flight report — is itself package-delivered UI, rendered over the kernel’s package-management APIs, and it is typically part of the setup surface a package masters. What is in the kernel is the machinery those screens read: the closure computation, the surface-diff and version derivation, the dependency solver, the three-way merge, and the deploy primitive that commits the install. The screens can be rebranded or replaced; the engine that computes a closure or refuses an unsatisfiable dependency set cannot.
An installation is record data, not metadata — the same treatment permission-set assignments and role assignments get. A subscriber tenant’s installation record is what makes the upgrade merge possible, because it is where the currently-shipped definition of every merge-managed member lives.
Salesforce analogs, for migration mapping: the package component corresponds to a packageDirectories entry in sfdx-project.json plus the Package2 record; the package version corresponds to Package2Version and its subscriber-visible 04t id; dependencies corresponds to the per-directory dependencies array; manageability has no single analog, being distributed across fixed per-type rules and the protected flag on individual components; and the installation record corresponds to InstalledSubscriberPackage. There is no analog for the surface signature, the computed closure boundary, the derived version bump, or the capability registration — the last because Salesforce’s own shell and setup are product, not metadata a package can claim.
Sources
Section titled “Sources”- Project Configuration File for a Second-Generation Managed Package — Salesforce Developers —
packageDirectorieskeys,MAJOR.MINOR.PATCH.BUILD, 1–15 character namespace - Second-Generation Managed Packages Overview — Salesforce Developers — source of truth without packaging orgs; no 1GP → 2GP migration; beta versions are not upgradeable
- Create a Package Version — Salesforce Developers — package versions as fixed, immutable snapshots; release status cannot be changed back
- Package Ancestors for Second-Generation Managed Packages — Salesforce Developers — only
managed-releasedversions may be ancestors; abandoned versions strand subscribers - Configure Package Ancestry — Salesforce Developers —
ancestorVersion,ancestorId,HIGHEST, andNONEblocking upgrades - Understanding Package Upgrades with Ancestry — Salesforce Developers — valid upgrade paths; no downgrades
- Components Available in Second-Generation Managed Packages — Salesforce Developers — the four manageability axes and their definitions
- How Manageability Rules and Ancestry Impact Upgrades — Salesforce Developers — rules enforced at version creation; layouts not upgraded, Apex and formula fields upgraded
- Remove Metadata Components from Second-Generation Managed Packages — Salesforce Developers — removed components remain as deprecated; hard-deleted exceptions
- What to Consider Before Removing Metadata Components — Salesforce Developers — the reused-API-name upgrade failure and its procedural mitigation
- Protected Components in Managed Packages — Salesforce Developers — protectable types; deletable while protected; irreversible once released unprotected
- Namespaces for Second-Generation Managed Packages — Salesforce Developers — namespace assigned at creation and unchangeable; one namespace per package
- Link a Namespace to a Dev Hub Org — Salesforce Developers — a namespace cannot be changed or reused once associated with an org
- Avoid Namespace Collisions — Salesforce Developers — same-namespace API-name collisions block installation; 1GP and 2GP cannot share a namespace
- Which Package Types Can Your Package Depend On? — Salesforce Developers — the dependency matrix and the 1GP → 2GP block
- Create Dependencies Between Second-Generation Managed Packages — Salesforce Developers — dependencies listed in install order;
calculateTransitiveDependencies; no circular dependencies - Considerations for Promoting Packages with Dependencies — Salesforce Developers —
LATESTversusRELEASED; promote the base package first - Patch Versions for Second-Generation Managed Packages — Salesforce Developers — what a patch may not contain; Partner Support enablement and security-review prerequisite
- Push a Package Upgrade for Second-Generation Managed Packages — Salesforce Developers — push requires passing security review and is performed without asking the customer
- Uninstall a Second-Generation Managed Package — Salesforce Developers — what is deleted, the data export, and the conditions that block uninstall
- Prepare to Distribute Your Second-Generation Managed Package — Salesforce Developers — 75% Apex coverage, trigger coverage, installation key, released-only listing
- Security Review Overview — Salesforce Developers — what the review checks and the explicit no-guarantee statement
- Unlocked Packages — Salesforce DX Developer Guide — source-driven packaging aimed at internal business apps
- What’s an Unlocked Package? — Salesforce DX Developer Guide — admins may modify packaged metadata in production, with overwrite risk
- Hard-Deleted Components in Unlocked Packages — Salesforce DX Developer Guide — hard-deleted versus deprecated on removal; uninstall deletes both
- Before You Create Unlocked Packages — Salesforce DX Developer Guide — daily package-version creation limit equals the daily scratch-org allocation
- Foglight Solutions — Second Generation Packaging (2GP) and Ancestry — practitioner account of a mis-authored ancestor burning a version number
- Unlocked Packages in Salesforce: A Comprehensive Guide for Developers — Salesforce Ben — practitioner comparison of managed versus unlocked editability
- Semantic Versioning 2.0.0 — the MAJOR / MINOR / PATCH definitions and the requirement to declare a public API
cargo-semver-checks— linting a crate’s public API against a baseline to catch semver violations before publication- Introduction to the Dependency Mechanism — Apache Maven — dependency mediation and the “nearest definition” rule
- PostgreSQL Limits — 1,600 columns per table, 63-byte identifiers, tuple must fit one page
- Sigstore Overview — keyless signing, the Rekor transparency log, and what a consumer verifies