Metadata & deploy
Every artifact the platform can be configured with — an object, a field, a formula, a validation rule, a record-triggered automation, a page layout, a permission set — is a canonical JSON component with a stable shape: key, label, type, body. The deploy pipeline is the control-plane machinery that moves a set of these components between environments: retrieve → diff → validate → apply, applied as one transactional operation. The pipeline is not itself business logic and so does not belong to the pure or effectful tier — but every component it moves declares its own tier, and the validate phase is where the compiler enforces the tier contract before anything is written.
The model
Section titled “The model”The unit of deploy is the component, not a file, and not an XML document. A component is a JSON object with four top-level keys:
| Key | Meaning |
|---|---|
key |
Stable, human-readable identity — invoice.margin_pct, ps_billing. Namespaced by object/type. Never a generated surrogate. |
label |
Display name. Free to change without changing identity. |
type |
The component kind — object, field, formula, rollup, validation, automation, layout, permission_set, list_view, app, tab, calc_function, … |
body |
The type-specific payload — the field’s data type and FLS, the formula’s expression and materialization, the automation’s trigger and effect graph, and so on. |
The UI-bearing kinds in that list — layout, app, tab, list_view — describe surfaces a package delivers and the shell renders, not surfaces compiled into the engine. The kernel stores and moves them as canonical components exactly like a field or a formula, and the deploy pipeline treats every kind identically; which of them happens to paint a screen is a package concern, not a pipeline one.
Where truth lives. The repository is the single source of truth. The live environment’s metadata catalog (a set of Postgres tables the kernel reads to build objects, columns, views, policies, and functions) is a projection of the repository, produced by the last successful apply. This is the same posture the incumbent’s DevOps Center adopts — “source control as the single source of truth for configuration and code” (DevOps Center Overview) — so it is parity, not novelty. What differs is what happens when the two disagree (see reverse-integration, below).
When the pipeline runs. A deploy is an explicit operation over a component set — a manifest naming the components in scope plus their bodies. It is not continuous; it is a discrete, ordered, four-phase operation:
- Retrieve — read the current committed state of the named components (from the repository, or from a live environment when capturing drift).
- Diff — compute the exact per-component change set against the target: added / changed / deleted, field-by-field within each
body. - Validate — type-check every component, enforce each component’s tier contract, run the pre-flight auto-resolve ordering pass, and simulate the apply (the dry run). No writes.
- Apply — execute the ordered change set as one transaction (with the per-layer boundary set out under Semantics & evaluation).
These four phases build the change set; a fifth step, activate, makes it live. Apply and activate are independent concerns — apply decides what changes and writes it as a not-yet-live generation, while activation decides when running sessions see it, by atomically advancing the active-generation pointer (Live activation). That is why a deploy timeline reads as five steps — retrieve, diff, validate, apply, activate — even though the four above are the phases that compute and commit the change: activation is a distinct step because what to change and when it becomes visible are separate decisions.
Read/write semantics. Retrieve and diff are read-only. Validate is read-only against the target but produces a plan (the ordered, resolved change set) and a dual-face result. Apply is the only writing phase, and it either commits the whole resolved set or leaves the target as it was. There is no partial-success end state for the transactional core; components that cannot be enclosed in the DB transaction (see below) are applied with explicit compensating actions, and that boundary is declared, not hidden.
Authoring
Section titled “Authoring”The authoring surface is the canonical component plus a manifest. A component is authored (in the repository, by an editor, or captured from a live environment) as the JSON object above. A manifest names a set of components and is the argument to the pipeline.
Component vocabulary
Section titled “Component vocabulary”The body schema is per type; the outer envelope is uniform. A minimal field component:
{ "key": "invoice.freight_total", "label": "Freight Total", "type": "field", "body": { "dataType": "currency", "scale": 2, "required": false, "fls": { "ps_billing": "edit", "ps_sales": "read" } }}Adding a new type. A new component kind enters the model through one registered path, never through out-of-band configuration: it registers a component schema (the body shape and its type-check rules) and a normalizer (the canonicalization that produces the content-addressed identity — key-ordering, whitespace neutralization, AST canonicalization for any embedded expression). Registering those two makes the type a first-class citizen of retrieve → diff → validate → apply automatically: the diff walks its body, validate type-checks it, auto-resolve places it in the dependency graph, and reverse-integration captures it. Coverage is a standing contract, not a launch checkbox — a configurable surface that is not expressible as a canonical component reintroduces the incumbent’s “do it manually in each org” gap (Unsupported Metadata Types), so every surface the kernel can be configured with ships with its component schema and normalizer as part of that surface, not after it.
Manifest vocabulary
Section titled “Manifest vocabulary”A manifest is itself a JSON document. It enumerates the component set and declares intent:
include— the list of componentkeys (ortypewildcards, e.g."field:invoice.*") in scope.delete— components to remove. Deletion is explicit and separate from the additive set — a component absent fromincludeis not deleted; it must be named indelete. This mirrors the incumbent’s separation ofdestructiveChanges.xmlfrompackage.xml(Deleting Components), but every deletion routes through safe-delete (below).mode—validate(dry run, phases 1–3 only) orapply(all four phases). The default isvalidate; promoting toapplyis a deliberate act.target— the environment the set applies to.
Safe-delete
Section titled “Safe-delete”A delete entry does not drop schema directly. Safe-delete is a fixed sequence:
- Inbound-reference check — the static dependency graph is queried for anything that references the component (a formula reading the field, a layout placing it, an automation writing it, a permission set granting it). If references exist, validate fails loud with the referencing
keys, before any write. - Soft-delete / retire — an unreferenced component is first retired (marked inactive, hidden from surfaces, retained in the catalog and in field history), not physically dropped. This is recoverable.
- Purge — physical drop is a separate operation over already-retired components, never implicit in an ordinary apply and never inside an activation transaction. It is two calls on two grants, not a flag on one:
metadata/component-forfeit-retention(metadata.author) records that the rollback of one retired component is being given up, andmetadata/component-purge(metadata.purge, held by nobody until an organisation grants it) performs the drop — refusing unless that record is open, and consuming it on success, so a retry cannot destroy whatever later answers to the same name. A field’s sidecar columns (currency, zone, search vector) go with it; the drop isrestrict, so anything still depending on the table stops it rather than being dropped alongside.
This is the direct answer to the incumbent’s destructive-changes foot-guns — purgeOnDelete=true bypassing the Recycle Bin, some types (deleted roll-up summary fields) never saved to the Recycle Bin at all, and change sets being unable to delete anything (Deleting Components).
Worked example — a manifest that adds a field, changes a formula, retires another field
Section titled “Worked example — a manifest that adds a field, changes a formula, retires another field”{ "mode": "apply", "target": "prod", "include": [ { "key": "invoice.freight_total", "label": "Freight Total", "type": "field", "body": { "dataType": "currency", "scale": 2, "required": false } }, { "key": "invoice.margin_pct", "label": "Margin %", "type": "formula", "body": { "returnType": "percent", "materialization": "stored", "expression": "record.sell_price > 0 ? (record.sell_price - record.total_cost) / record.sell_price : 0" } } ], "delete": ["invoice.legacy_markup_flag"]}Auto-resolve will order the new column before any formula that reads it; safe-delete will refuse invoice.legacy_markup_flag if anything still references it, naming the referrer.
Semantics & evaluation
Section titled “Semantics & evaluation”Retrieve materializes the committed body of each named component. Capturing from a live environment (rather than the repository) is how reverse-integration works: a click-made change in the running app is read back as a canonical component, diffed against the repository, and offered as an incoming change — so a change made in the UI does not silently diverge from source.
Drift capture is event-driven, with a periodic reconcile sweep as backstop. Because a click-made metadata change is a row write to the kernel’s catalog tables — a formula, a validation rule, a layout, a permission set is a row, not a schema alter — it is ordinary DML on ordinary tables, which is exactly what Postgres logical decoding streams. A replication slot on the catalog tables turns every click-made change into a change event the moment it commits, so reverse-integration sees drift immediately rather than on a poll interval. The one thing logical decoding does not stream is DDL — logical decoding “doesn’t capture schema changes such as adding or dropping columns” (logical replication restrictions) — but in this model DDL is only ever produced by the platform’s own apply pipeline, never by a click, so the physical-schema seam is covered by the apply’s own record of what it ran, complemented by event triggers on DDL for defense in depth. The periodic reconcile sweep — a full hash-diff of live catalog state against the repository — is the backstop that catches anything a slot missed (a slot recreated after failover, a gap during maintenance), keeping the cost profile continuous-cheap with a bounded periodic reconcile rather than poll-everything-on-a-timer.
Diff is computed on the normalized component. Each component has a content-addressed identity: its body is normalized (key-ordered, whitespace-neutral, ASTs canonicalized) and hashed, so a diff reflects a real semantic change and not a formatting difference, and two components with equal hashes are known-equal without a field walk.
Validate does four things, all read-only against the target:
- Type-check every component
bodyagainst itstypeschema. - Tier enforcement — a component that declares a pure body (formula, roll-up condition, validation, calc function) is compiled through the pure subset and rejected if it reaches an effectful construct; an effectful component (record-triggered automation) is checked against the save order of execution contract. The deploy is where tier violations are caught, before they can reach a running save.
- Pre-flight auto-resolve — the change set is topologically ordered from the static dependency graph: objects before their fields, fields before the formulas/roll-ups/validations that read them, all of those before the permission sets and layouts that reference them. Safe, deterministic ordering fixes are applied automatically. Where the graph has a cycle or a genuinely ambiguous cross-layer reference, auto-resolve does not guess — it stops and reports the cycle with the participating
keys. Guessing an order across a cycle is non-deterministic and can apply components in an order the author never sanctioned; reporting the participating keys is the deterministic, safe outcome, and resolving the cycle (breaking the reference, or splitting the set into two ordered deploys) is an authoring decision, not a pipeline heuristic. - Simulate — the ordered plan is dry-run against the target to surface failures without writing, the analogue of the incumbent’s
checkOnlydeploy (deploy() guide).
Dual-face errors. Every validate/apply failure is emitted twice from one source: a plain-English message for a human, and a machine-readable JSON error keyed by the component’s key, its phase, and a stable error code. The two faces are generated from the same structured error so the prose is not a lossy paraphrase of the machine record. This directly targets the incumbent’s cryptic, developer-only deploy errors.
Apply executes the resolved, ordered plan. Postgres DDL is transactional, so schema components (objects, columns, generated columns, views, constraints, roll-up triggers) apply and roll back atomically as one transaction. Components that live outside the database transaction boundary — edge functions, generated client types, external policy caches — are applied with compensating actions on failure, and this seam is declared, not concealed. The transactional core means a failed apply leaves the target exactly as it was; the honest scope of “one transaction” is the DB-enclosable layers.
The rollback mechanism is fixed per layer, not decided per deploy. Any layer that can be enclosed in the Postgres transaction gets true ROLLBACK; any layer that reaches outside it gets a compensating action — a registered inverse operation that undoes the completed step, the saga model, because an external step already committed cannot be un-committed by a database ROLLBACK. The apply orders DB-enclosable layers first and commits them as the transactional core; out-of-DB layers apply after that commit, each paired with its compensation, so a later failure runs the compensations in reverse and reports which succeeded:
| Layer | Rollback mechanism | Why |
|---|---|---|
| Objects, columns, type changes, constraints, indexes | True transaction ROLLBACK |
Postgres DDL is transactional |
| Generated (stored) columns | True transaction ROLLBACK |
Part of the same DDL transaction |
| Roll-up triggers & their functions | True transaction ROLLBACK |
CREATE/REPLACE TRIGGER/FUNCTION are transactional DDL |
| Views & materialized-view definitions | True transaction ROLLBACK |
CREATE/REPLACE VIEW is transactional DDL |
| Catalog rows (formula, validation, layout, list view, permission set, automation definitions) | True transaction ROLLBACK |
Ordinary DML on kernel tables |
| Data backfill (bounded, in-transaction) | True transaction ROLLBACK |
Runs inside the apply transaction when small enough |
| Data backfill (batched, out-of-transaction) | Compensating action | A batched backfill runs outside the activation transaction (see live activation); its inverse is a compensating re-batch |
| Edge functions | Compensating action | Deployed to an external runtime; inverse is re-deploy of the prior version |
| Generated client types | Compensating action | Emitted artifact outside Postgres; inverse is regenerate from the prior committed state |
| External policy / auth caches | Compensating action | Live outside the DB; inverse is re-prime from the prior generation |
Determinism. The same manifest against the same target state produces the same diff, the same resolved order, and the same apply result, because component identity is a content-addressed hash and ordering is a deterministic topological sort of the static dependency graph.
Live activation & hot deploy
Section titled “Live activation & hot deploy”Everything above builds a change set; this section is how it activates for users who are working in the app right now, with no downtime and no forced logout. The two concerns are independent: the transactional apply decides what changes; live activation decides when running sessions see it. The target behavior is the incumbent’s, and it is the right bar — deploy while people work, and the moment a user navigates or refreshes, they are on the new metadata.
How Salesforce does it. Salesforce’s hot-deploy ability is a direct consequence of a choice it made at the storage layer: a custom object or field is not a physical table or column — it is a metadata row mapped onto generic, pre-existing columns, so that because object and field definitions are managed “as metadata rather than actual database structures, the system can tolerate online … schema maintenance without blocking the concurrent activity” of other tenants and users (Force.com multitenant whitepaper; multitenant deep-dive). The runtime keeps recently-used metadata in an in-memory metadata cache and rebuilds each runtime construct — columns, layouts, logic, permissions — from that metadata per request. A deploy writes new metadata and invalidates the cache; the next request re-materializes from it. A user mid-session keeps the page already rendered (its old metadata is baked into that page) until they navigate or refresh, at which point the new request rebuilds against the new metadata. The seam in this model: cache invalidation across many app servers is eventually consistent, so there is a brief window where different servers can serve different metadata versions.
Why CAOS cannot copy it verbatim — and does not want to. CAOS made the opposite storage choice on purpose: a field is a real Postgres column, a relationship a real foreign key, so the query planner, type system, and referential integrity are real — the thesis of this whole guide. The bill for that choice is paid precisely here: some CAOS metadata changes are DDL, the one thing the flex-column model engineered away. So hot deploy is not one problem but two, and separating them is the design:
- Class A — pure catalog changes. Formulas, roll-up definitions, validation rules, page layouts, list views, permission sets, added record-triggered automation, label changes. These are rows the kernel reads to build behavior — no DDL. They are hot in exactly Salesforce’s sense.
- Class B — physical schema changes. New columns, type changes, constraints, indexes, backfills. These touch real schema, so they carry a locking cost Class A does not.
The activation model is uniform. Every deploy — Class A or Class B — activates through a single mechanism: the kernel resolves all metadata for a request against an active generation pointer, and a deploy is apply-then-activate. Apply writes the new state as a new, not-yet-active generation; activation atomically advances the pointer in one Postgres commit. Requests already resolving against generation N finish against N; the next request after the commit — a navigation or refresh — resolves against N+1. There is deliberately no separate fast path for catalog changes and slow path for schema: one uniform model of how a deploy becomes visible is worth more than a per-class micro-optimization, and it keeps the mental model, the tests, and the rollback story singular. Because the pointer advance is a single transaction commit, activation is a single linearization point — there is no multi-server propagation window in which two requests disagree, which is exactly where the incumbent’s eventually-consistent cache invalidation is weakest.
Saves are pinned to the generation they began under. A record opened and validated under generation N commits under N’s formulas, validation rules, and layout, even if N+1 activated while the user was editing — otherwise one save could be evaluated against a half-swapped rule set. If the record’s generation is no longer live at commit time, the save either completes under its pinned N or is rejected cleanly with “this changed underneath you — reload,” never applied under a mixture. A superseded generation stays readable until the last session pinned to it drains, then is archived; a save whose pinned generation has already aged out is rejected with the same reload, never silently upgraded. The explicit generation is what makes this guarantee expressible; it is not cleanly available in a cache-invalidation model.
Class B still requires online-migration discipline — activation does not make DDL free. The activation flip is hot and atomic; the physical schema change underneath it is where the real work is, and it must be planned so it never blocks live traffic:
- A nullable column add, or (Postgres 11+) a column add with a default, is metadata-only — no table rewrite, a sub-millisecond lock (add columns without locking).
- A type change, a
NOT NULLon existing data, a unique constraint, or an index build rewrites or blocks, and must use expand → migrate → contract:CREATE INDEX CONCURRENTLY,NOT VALID+ a laterVALIDATE, and a backfill batched outside the activation transaction (zero-downtime Postgres migrations). - The non-obvious foot-gun: even a “fast”
ALTER TABLEtakes anACCESS EXCLUSIVElock, and Postgres lock acquisition is a FIFO queue — a DDL statement waiting behind one long-running query makes every query behind it wait too, draining the connection pool. A one-millisecond alter becomes an outage. So every apply runs under one uniform policy: a boundedlock_timeoutwith retry-and-backoff — a short timeout, 3–5 attempts, exponential backoff with jitter, deferring the apply rather than stalling the lock queue. This is mandatory, not a tuning nicety.
The honest shape: Class-A changes are hot and free, matching Salesforce; Class-B changes get the same hot, atomic activation, but their physical migration is staged and incremental rather than instantaneous — the tax CAOS pays for real columns, made safe by an online-migration planner rather than avoided by giving up real schema.
Better / parity / cost, stated plainly.
- Better: atomic single-commit activation (no multi-server disagreement window) and generation-pinned saves (no half-swapped-rule window) — both fall out of making the metadata generation explicit.
- Parity: the “refresh and you’re current” behavior itself, in-memory metadata caching, and no-process-restart logic changes — the incumbent already does all three; matching them is table stakes.
- Cost: the generation pointer must be cheap to read on every request (it is itself cached); a superseded generation must remain readable until the last session pinned to it drains, a bounded but real metadata-retention obligation; and the entire Class-B story rests on an online-migration planner that must classify every DDL op correctly — a wrong classification is a locking outage.
This is not a kernel upgrade. Swapping the interpreter/kernel code itself is a separate, rolling code deploy with its own mechanism; live activation here moves running sessions from one metadata generation to the next, nothing more.
Platform capability versions
Section titled “Platform capability versions”A generation versions the tenant’s own configuration. A platform capability version versions the parts of the kernel that a tenant’s configuration executes against — the behavior a deployed component depends on but nobody in the tenant authored. Without one, a kernel change that alters an observable behavior is indistinguishable from an org changing its own metadata, and the platform is left with the vendor-upgrade problem the data API’s protocol versioning exists to solve, one layer down.
The test for whether something carries a version is narrow, and it is the reason the list is short: can a component the tenant deployed, or a client the tenant registered, observe a change in behavior without itself changing? Four things pass it.
| Capability | What the version pins | What moves it |
|---|---|---|
Calc sandbox (calc) |
The execution semantics of a compiled body: numeric and rounding behavior, null propagation, the resource and timeout model, the boundary between the pure and effectful tiers | A change to how an existing construct evaluates |
Expression language (lang) |
The grammar, the type rules, and the signatures and return types of the built-in library | A built-in whose signature, coercion, or edge-case result changes |
Adapter (adapter:odata_v4, adapter:openapi_3_1, one per adapter kind) |
The translation contract an external data source or external service is written against: which predicates push down, how paging and cursors behave, how remote errors map into the envelope | A change in what the adapter translates or how it reports |
Event schema (events) |
The envelope on a change or platform event: the field set, the sequence and commit-timestamp semantics, and what a consumer is promised about gaps | A change a subscriber would have to re-code for |
Everything else in the kernel — the access planes, the save order, the deploy pipeline itself, the error envelope’s classes — moves with the platform release and carries no pin. Security behavior is deliberately in that group: a tenant cannot pin an older version of the access planes, because pinning an enforcement path means choosing to run a superseded one, and no correct answer to that request exists.
A capability version is MAJOR.MINOR and only the major is pinnable. A minor is additive and behavior-preserving — a new built-in, a predicate the adapter can now push down that it previously rejected at author time — and arrives with the platform release, because nothing already deployed can observe it. A major changes what an existing construct does, and no tenant crosses one without deciding to.
What pins a tenant. An environment’s effective version for a capability is the highest of three floors — the one its environment component declares, the highest requirement derived from any component deployed into it, and the highest platform floor of any installed package — capped by the release running in the region. The derived requirement is the load-bearing part: the compiler records the minimum capability version each component needs, the same way a package build derives its semantic version from a surface diff rather than trusting a declaration. So the answer to “what is holding this org on calc@3” is a list of components and registered clients, computed, not estimated — which is exactly what the environment’s Versions surface shows beside each capability.
Moving forward is a deploy. Raising a pin is a change to the environment component, and it runs the ordinary pipeline: validate recompiles every affected component against the target version and reports what breaks before anything moves, so the upgrade’s blast radius is read in a diff rather than discovered in production. A sandbox can pin ahead of production for exactly this rehearsal, because the pin lives on the environment and environments promote in the normal direction.
The deprecation contract matches the protocol floor: every capability major is supported for a minimum of three years after its successor ships, and end of support is announced at least a year before it lands. Three years is what makes the guarantee usable by an organization whose release cadence is annual, and it is the same commitment the data API makes, because an integrator who has to reason about two different clocks will reason about neither. Through the notice period, the Versions surface names the ending version, its date, and the components and clients still requiring it. At the deadline the platform raises the pin in an announced window; the raise appears in the tenant’s own audit stream as an ordinary deploy. The trade is stated plainly: a tenant that ignores a year of notice is moved anyway, because the alternative is operating every interpreter version ever shipped, forever, which is the cost that eventually makes vendors break compatibility without notice instead of with it.
Limits, and the reasons behind them
Section titled “Limits, and the reasons behind them”The incumbent’s deploy limits are real and cited below. Some guard a constraint the Postgres kernel shares; some guard a constraint the kernel removes because it has a different mechanism.
| Concern | Salesforce exact limit (cited) | Why the limit exists | CAOS equivalent |
|---|---|---|---|
| Files per deploy/retrieve | 10,000 files (zip deploy/retrieve) | Bounds per-request work and metadata-graph traversal on a multi-tenant platform. | The unit is a component set, not a file bundle; any cap is a chosen throughput budget, not a file-count ceiling. The graph-traversal cost the limit guards is real and moves to the auto-resolve/apply cost model. |
| Compressed payload size | ~39 MB zip (zip deploy/retrieve) | The SOAP message ceiling is 50 MB; base-64 encoding inflates the payload, so the pre-encoding zip cannot exceed ~39 MB. | An artifact of SOAP/base-64 transport, not of the metadata itself; a JSON component set over a modern transport does not inherit this envelope. |
| Uncompressed size | 600 MB / 629,145,600 bytes (zip deploy/retrieve) | Caps total expanded metadata processed in one request. | Total work per apply is still bounded — a huge transaction holds locks — so an equivalent throughput budget is retained, but as an apply-cost bound, not a byte count. |
| Quick-deploy reuse window | 10 days (project deploy validate) | A successful validation may be reused for a fast, test-skipping deploy for 10 days, then must be revalidated. | A validated plan is reusable while the target state it was validated against is unchanged; validity is keyed to the target’s content hash, not a fixed calendar window. |
| Production test-coverage floor | ≥ 75% overall, with per-class/trigger coverage (quick-deploy conditions) | Enforces an Apex quality gate before prod changes land. | A test gate is retained as policy on the apply-to-prod path; the specific 75% number is a chosen threshold, not a mechanism constant. |
| Per-transaction deploy count (specific types) | 50 deploys/package, 100 deploys/24h, 100 retrieves/txn, 200 retrieves/24h for high-churn analytics/bundle types (Metadata Type Limits) | Rate-limits high-churn metadata types; note these are type-specific, not global. | No per-type deploy quota; rate control, if needed, is a uniform apply-throughput policy rather than per-type special-casing. |
| Rollback of a successful deploy | none — recovery is roll-forward only (deploy best practices) | The API applies whatever the zip declares; a completed deploy has no platform-level snapshot/restore. | Transactional apply gives true rollback of a failed apply for DB-enclosable layers; the repository’s per-component version identity makes roll-forward to any prior committed state exact. |
How Salesforce does it
Section titled “How Salesforce does it”Mechanism. Salesforce is metadata-driven, and the Metadata API reads and writes that metadata as files: a .zip containing a package.xml manifest plus one folder per metadata type (objects/, classes/, layouts/, permissionsets/, …), each component an XML document, deployed via two asynchronous verbs — retrieve() and deploy() — with deploy(checkOnly=true) performing a validation-only dry run (deploy() guide). The Metadata Types list enumerates on the order of 400+ named types as of the current API version (the widely-repeated “~200 types” figure is stale folklore), and the authoritative, version-scoped record of which types exist and where each is supported is the Metadata Coverage Report. Three generations of tooling coexist:
- Change sets — UI-driven, org-to-org over a deployment connection, no files and no source control. They are sandbox-lineage only (connections auto-populate only between a production org and its own sandboxes), cannot delete or rename components, are immutable after upload (to change one, clone and re-upload), have incomplete component coverage, and carry the same 10,000-file cap (Change Set Implementation Tips, Change Sets Best Practices).
- Salesforce DX — the modern Git-backed path. Source format decomposes large XML into per-subcomponent files for clean diffs (Decomposed Metadata Types); the
sfCLI converts source ⇄ metadata format and offersproject deploy validate/project deploy quick; unlocked packages are the recommended type for internal business apps (Unlocked Packages). High ceiling, high floor. - DevOps Center — a Salesforce-native change-and-release app over the DX foundation, positioned as the explicit “alternative to change sets” with source control as the single source of truth (DevOps Center Overview).
Four structural gaps run through all three:
- No true rollback of a successful deploy.
rollbackOnErroronly unwinds a deploy that fails mid-flight; once a deploy completes there is no undo — recovery is to deploy the previous metadata state forward again, and there is no platform-level snapshot/restore (deploy best practices). - Incomplete metadata coverage. Some features have types that are not in the Metadata API at all — “to make changes to these types, you must do it manually in each of your organizations” — and coverage varies by channel (Unsupported Metadata Types).
- Order-of-deploy is the caller’s problem. “Custom objects must deploy before their custom fields, and Apex classes referencing these fields should deploy only after both exist”; the prescribed fix is manual — “group and deploy highly interdependent metadata components together” — with no automatic topological resolver (deploy best practices).
- Deletion is a separate, dangerous ritual —
destructiveChanges.xml,purgeOnDeletebypassing the Recycle Bin, some types never recoverable, change sets unable to delete at all (Deleting Components).
Where CAOS is genuinely better:
- One transactional retrieve → diff → validate → apply. Salesforce splits these across API + client + CLI with no atomic apply and no rollback of a completed deploy. Postgres transactional DDL applies the schema-layer change set as one transaction with real rollback of a failed apply — directly answering gap 1, for the DB-enclosable layers.
- Safe-delete. Soft-delete + inbound-dependency check + explicit purge replaces the
purgeOnDeletefoot-gun and the change-set “can’t delete” hole (gap 4). - Pre-flight auto-resolve. A deterministic topological sort of the static dependency graph orders object → field → dependent logic → permissions automatically, replacing the manual bundling the incumbent requires (gap 3).
- Dual-face errors. One structured error, emitted as accurate plain English and machine-readable JSON keyed by component name — a DX win over cryptic developer-only messages, especially for a low-code audience.
- Reverse-integration. Click-made changes are read back as canonical components and diffed into the repository, narrowing the UI-vs-repo drift that coverage gaps (gap 2) make chronic in the incumbent.
Where it is mere parity (not oversold): repository-as-source-of-truth (DevOps Center already asserts this), file/component-level VCS-diffable definitions (DX source format already decomposes for clean diffs), validate-before-apply (checkOnly already exists), and a test-gated prod path (RunLocalTests + 75% floor is the incumbent gate). These are table stakes to equal, not differentiators.
Costs and risks:
- Transactional-apply boundary is not universal. “One transaction” holds for Postgres DDL; it does not cleanly enclose a change that spans schema plus data backfill plus an edge function plus generated client types. Those cross-layer applies need compensating actions (“saga” rollback), not a single COMMIT — and which layers get true rollback versus compensation must be specified per layer, not asserted globally.
- Big-org / big-diff apply time. A large schema change plus its data migration holds locks; a transactional apply that holds locks too long is its own outage. The incumbent’s minutes-long deploys are the same wall; an incremental/parallel apply strategy is required, not free.
- Drift-detection cost. Reverse-integration only works if live state can be diffed against the repository cheaply and continuously. That is an ongoing cost, not one-shot, and it must survive platform evolution.
- Coverage completeness is a standing commitment. The moment any configurable surface is not expressible as a canonical component, CAOS reintroduces the incumbent’s “do it manually in each org” gap. 100% coverage is a maintenance obligation, not a launch checkbox.
- Safe-delete correctness depends on a complete inbound-dependency graph — the same graph the incumbent lacks, and one that is non-trivial to keep correct as component types are added.
Metadata & deploy representation
Section titled “Metadata & deploy representation”The canonical component and manifest are the representation — the subject of this whole page — so this section pins the concrete shape and the incumbent analog side by side.
A CAOS component is the four-key JSON envelope (key / label / type / body); a manifest is the include / delete / mode / target JSON above. Component identity is the content-addressed hash of the normalized body, which is what makes retrieve → diff → deploy produce exact diffs and exact roll-forward to any prior committed state.
Salesforce Metadata API analog, element by element:
| CAOS | Salesforce Metadata API |
|---|---|
Manifest include list |
package.xml — <types> blocks pairing a <name> (metadata type) with <members>, plus <version> (API version); * wildcards a type (deploy() guide) |
Manifest delete + safe-delete |
destructiveChanges.xml (or destructiveChangesPre.xml / …Post.xml for ordering), with purgeOnDelete controlling Recycle-Bin bypass (Deleting Components) |
Component body (per type) |
The per-type XML document — CustomObject, CustomField, Layout, PermissionSet, Flow, ApexClass, … under its per-type folder |
mode: validate |
deploy(checkOnly=true) / project deploy validate — the dry run (deploy() guide) |
| Content-addressed component identity | No analog — the incumbent’s metadata is a mutable XML string with no version identity; diff and rollback are the client’s job over Git text |
The load-bearing difference: Salesforce’s canonical component is per-type XML, file-based, diff-and-merge-in-the-client, with no single canonical component object and no server-side diff/validate/apply-as-one-transaction (DX Source Format). That seam — one JSON component, server-side transactional apply — is the design space CAOS occupies.
Sources
Section titled “Sources”- deploy() — Metadata API Developer Guide
- Deploying and Retrieving Metadata with the Zip File (size & file limits) — Metadata API Developer Guide
- Metadata Types list — Metadata API Developer Guide
- Metadata Type Limits — Metadata API Developer Guide
- Unsupported Metadata Types — Metadata API Developer Guide
- Metadata Coverage Report — Salesforce Developers
- Deleting Components (destructiveChanges / purgeOnDelete) — Metadata API Developer Guide
- Master Metadata API Deployments with Best Practices — Salesforce Developers blog
- DX Project Structure & Source Format — Salesforce DX Developer Guide
- Decomposed Metadata Types — Salesforce DX Developer Guide
- Unlocked Packages — Salesforce DX Developer Guide
- Change Sets — Salesforce Help
- Change Set Implementation Tips — Salesforce Help
- Change Sets Best Practices — Salesforce Help
- DevOps Center Overview — Salesforce Help
- project deploy validate / quick deploy (10-day window, 75% coverage) — Salesforce CLI Reference
- The Design of the Force.com Multitenant Internet Application Development Platform (Weissman & Bobrowski) — ACM — metadata-not-DDL storage; online schema maintenance without blocking; the metadata cache
- Salesforce Database Architecture: A Multi-Tenant Deep Dive — Cirra — custom fields/objects as metadata rows over generic columns; runtime materialization
- How to Add Columns Without Locking in PostgreSQL — OneUptime — metadata-only column adds; ACCESS EXCLUSIVE lock and the FIFO lock queue
- Postgres Schema Migration without Downtime — Bytebase — expand→migrate→contract, CREATE INDEX CONCURRENTLY, NOT VALID + VALIDATE
- Logical Decoding Concepts — PostgreSQL Documentation — replication-slot streaming of row-level DML changes; the basis for event-driven drift capture
- Logical Replication Restrictions — PostgreSQL Documentation — DDL and schema changes are not replicated by logical decoding; the seam covered by the apply record + event triggers
- Saga Design Pattern — Azure Architecture Center — compensating actions as the inverse-operation model for steps outside a single transaction