Configuration data
Between the schema and the customer’s records sits a third body of values an application cannot run without: the freight rate an invoice prices against, the margin floor a validation rule enforces, the tax table a line item looks up, the endpoint an integration calls in this environment and not that one, the switch that turns a half-finished feature on for one team. These are not columns and they are not customer data. Left unmodelled they end up hard-coded in an expression, or in a spreadsheet on someone’s desktop, or in a records table that nobody remembers to move when the app is promoted.
The load-bearing decision on this page: a configuration value is a canonical component, so it deploys. It travels between environments through retrieve → diff → validate → apply exactly like a field or a formula, it lands in the repository, its history is a before → after diff in the metadata audit, and an environment built from the repository is complete without a single manual data load. Everything else on this page — the typing, the scope chain, the effective dating, the caching, the permission model — follows from that one commitment.
The incumbent split this construct in half and has been paying for it since. Custom settings hold values but their records are not deployable; custom metadata types made records deployable but gave up per-user resolution and runtime writes; custom labels handle a third slice; feature parameters handle a fourth and only for packaged apps. CAOS has one construct.
The model
Section titled “The model”The deployable / customer-data test
Section titled “The deployable / customer-data test”Every candidate row answers one question, and the answer determines which half of the platform it lives in.
If this row is missing from a freshly provisioned environment built from the repository, is the application broken?
- Yes → configuration data. It is part of what the application is. It is a component, it deploys, it is versioned, and changing it is a release event.
- No → customer data. It is part of what the customer has done. It lives in an object, it obeys record access, and it never travels with a deploy.
Two corollaries settle the cases that actually cause arguments:
- Volume is not the test. A five-thousand-row postal-code table is configuration if the app is broken without it. A three-row table of the customer’s own sales regions is customer data.
- Who edits it is not the test either. A pricing manager, not a developer, edits the freight rate — and it is still configuration, because the app prices wrongly without it. The consequence is not to reclassify the row; it is to give the pricing manager a Setup surface and a grant, and to route their edit through the same pipeline. Editing a rate is a metadata change, and it is audited as one.
A third category exists and is deliberately excluded: secrets. See below.
Configuration sets and entries
Section titled “Configuration sets and entries”A configuration set is a named, typed shape. A configuration entry is one row of that shape, at one scope, over one effective date range. Both are canonical components.
| Piece | What it declares | Component type |
|---|---|---|
| Set | Typed fields, the scope chain it accepts, whether it is keyed, whether it is effective-dated | config_set |
| Entry | Values for one (key, scope, effective range) | config_entry |
| Flag | A boolean switch with an owner and a mandatory expiry | feature_flag |
A set’s fields are declared with the same field-type system as an object’s — storage primitive plus validators plus format. A rate is a number with a declared precision and scale, stored in a numeric(p,s), and it arrives in an expression as an exact decimal. A date is a temporal. An enum is an enum drawn from a real value set. Nothing is a string parsed at read time, and there is no “everything is text, cast it yourself” tier — the failure mode where a rate silently becomes 0 because someone typed 4.5% into a text box cannot occur, because the validator rejects it at deploy.
A set is either singleton (one value per scope — the margin floor) or keyed (many rows per scope, addressed by a declared business key — the tax table). Keyed sets are the case the incumbent calls a list custom setting; the scope chain still applies, within each key.
Scoping
Section titled “Scoping”A value that is the same everywhere is the easy case. The hard case is a value that varies — by environment, by app, by who is asking. CAOS models that as an ordered scope chain, declared per set, resolved most-specific-first:
| Scope kind | Varies by | Binding class |
|---|---|---|
user |
one named user | viewer-dependent |
permission_set |
holders of a permission set | viewer-dependent |
locale |
the viewer’s language | viewer-dependent |
app |
the app the request is running in | binding-stable |
environment |
dev / test / prod | binding-stable |
org |
everyone — the base, and it is required | binding-stable |
Three properties matter more than the list itself.
- The chain is declared and closed. A set declares which scope kinds it accepts; an entry at any other scope is rejected at deploy, by name. A margin floor declared
["org"]cannot acquire a per-user override eighteen months later because someone found the field on a screen. The incumbent’s hierarchy accepts a profile-level and a user-level override on every hierarchy setting whether or not that ever made sense for that setting, and there is no way to say no. orgis mandatory, so resolution is total. Every set has a base value. Resolution therefore always returns a value, the accessor’s type is non-nullable, and the whole class of “the setting was null in production and the calculation divided by zero” is a compile-time impossibility rather than a runtime incident.- The binding class is a compiler input, not a note in the documentation. Whether a scope kind depends on who is reading determines what may read it — see reading configuration from the pure tier.
permission_set replaces the incumbent’s profile level, because CAOS has no profiles. That substitution introduces one genuine problem the profile model never had: a user holds exactly one profile but may hold many permission sets, so two entries can match at once. It is resolved explicitly and never at run time — see below.
Feature flags
Section titled “Feature flags”A flag is a configuration set with the shape fixed and two fields made mandatory:
owner— a user or group that must resolve to a live principal.expiresOn— a date. A flag with no expiry fails deploy validation. Not a lint warning, not a report someone reads quarterly:validatefails and names the flag. A permanent on/off switch is not a flag; it is a configuration value, and it should be modelled as one so it is reviewed as one.
A flag is boolean only. Anything with three states is a configuration value wearing a flag’s clothes, and the deploy rejects a feature_flag whose body declares a non-boolean default.
What happens after the expiry date is the part that makes the rule bite. The flag does not flip — silently changing production behavior on a calendar date is worse than the debt it would be punishing. Instead the flag freezes: past expiresOn, no deploy may change its value or its scope entries. The only two moves left are to extend expiresOn with a fresh rationale (an ordinary, audited component edit that a reviewer sees) or to retire the flag through safe-delete, whose inbound-reference check names every formula, automation, layout, and list view still branching on it. A stale flag cannot be used to keep shipping, and it cannot be deleted while dead branches still reference it.
Secrets are not configuration
Section titled “Secrets are not configuration”Configuration is deployable, diffable, committed to the repository, and rendered as a before → after diff in the audit stream. Every one of those properties is precisely wrong for a credential.
So the configuration field-type vocabulary has no secret type — no “encrypted text”, no write-only string. There is no way to type a password into a configuration component, because the component body is repository content and the repository is not a vault. A configuration entry may hold a credential reference: a name resolved at call time by the credential store described in Integrations, which is where authentication material, rotation, and the never-readable-after-write contract live. The endpoint URL is configuration and deploys; the token behind it is not and does not.
Authoring
Section titled “Authoring”A set declares its shape:
{ "key": "config.pricing_rates", "label": "Pricing rates", "type": "config_set", "body": { "cardinality": "singleton", "scopes": ["org", "environment"], "effectiveDated": true, "asOfField": "invoice.price_date", "fields": [ { "key": "freight_usd_per_lb", "type": "number", "precision": 9, "scale": 4, "required": true }, { "key": "shop_rate_usd_per_hr", "type": "number", "precision": 9, "scale": 2, "required": true }, { "key": "freight_basis", "type": "enum", "valueSet": "vs_freight_basis", "required": true } ], "read": { "ps_billing": "read", "ps_sales": "read" }, "edit": { "ps_pricing_admin": "edit" } }}An entry supplies one row of it, at one scope, over one date range:
{ "key": "config.pricing_rates@org#2026-07-01", "label": "Pricing rates — org — from 2026-07-01", "type": "config_entry", "body": { "set": "config.pricing_rates", "scope": { "kind": "org" }, "effectiveFrom": "2026-07-01", "effectiveTo": null, "values": { "freight_usd_per_lb": "0.8140", "shop_rate_usd_per_hr": "96.00", "freight_basis": "prepaid_and_add" } }}A keyed set addresses rows by a declared business key, and the scope chain still applies inside each key:
{ "key": "config.tax_rates", "label": "Tax rates", "type": "config_set", "body": { "cardinality": "keyed", "keyedBy": { "key": "jurisdiction", "type": "text" }, "scopes": ["org"], "effectiveDated": true, "fields": [{ "key": "rate", "type": "number", "precision": 7, "scale": 5, "required": true }] }}A flag, with both mandatory fields present:
{ "key": "flag.new_pricing_engine", "label": "New pricing engine", "type": "feature_flag", "body": { "owner": "group_pricing_platform", "expiresOn": "2026-10-31", "rationale": "Dual-run the rewritten rollup against the current one until the variance report is clean.", "scopes": ["org", "environment"], "default": false }}A pilot flag scoped to permission_set or user is viewer-dependent like any other configuration at those scopes: it can gate a read-time surface — component visibility, a virtual formula, an automation entry condition — and the compiler will refuse to let a stored computed field branch on it.
Reading it is typed property access in the one language, not a query and not a function-call ceremony:
// a formula field: invoice.material_cost → Currencyrecord.net_weight_lb * config.pricing_rates.freight_usd_per_lb
// a keyed setrecord.taxable_amount * config.tax_rates(record.ship_to_state).rate
// a flagflag.new_pricing_engine ? newRollup(record) : legacyRollup(record)config and flag are bound identifiers with generated types, so the editor completes config.pricing_rates. with the set’s real fields and their real types, a typo is a compile error, and a set-field rename knows every expression that reads it.
Semantics & evaluation
Section titled “Semantics & evaluation”Resolution
Section titled “Resolution”Resolution takes four inputs — set, key (if keyed), scope path, and asOf instant — and walks the declared chain most-specific-first, returning the first entry whose scope matches the requester and whose effective range contains asOf. It never merges entries: a matching entry supplies the whole row. Partial overlay, where a user-level entry silently contributes one field and the org level contributes the rest, is not offered, because the resulting value is a row no author ever wrote and no reviewer ever approved.
Two ambiguities are possible in principle and both are resolved at deploy, never at run time:
- Two entries at the same scope kind. Within
org,environment,app,locale, anduser, at most one entry can match by construction — those scopes are singular.permission_setis the exception, because a user may hold several. Apermission_set-scoped entry therefore carries an explicit integerpriority, and resolution takes the highest. Two entries with equal priority on the same set are a validate failure, naming both — a tie is an authoring bug, and the platform refuses to break it with an arbitrary rule the author never chose. - Overlapping or missing effective ranges. Within a (set, key, scope), ranges must be non-overlapping and, from the earliest
effectiveFromonward, gapless. Validate reports an overlap or a gap with both entry keys and the offending dates. An effective-dated set therefore cannot resolve to “no value” for any instant at or after its inception.
Resolution is a pure function of its four inputs plus the metadata generation. That single property is what the rest of this section is built on.
Reading configuration from the pure tier
Section titled “Reading configuration from the pure tier”A pure expression must be reproducible: same inputs, same output, forever. A naive “read the current rate” would destroy that — the rate changes, and last quarter’s stored total silently stops being explicable by the formula that produced it.
The fix is the one calc functions already use: make the configuration an input, and pin it by content.
Every configuration set has, in each metadata generation, a configuration digest — a hash over the set definition, all of its entries, and their effective ranges, computed by the same normalizer that produces every other component’s content-addressed identity. config.pricing_rates.freight_usd_per_lb does not mean the rate now. It desugars to the rate under this evaluation’s binding, and the binding is supplied by the caller, never read from ambient state. Purity is preserved because there is no hidden input left to read.
Every stored computed value carries its evaluation binding alongside the calc-function hashes it already records:
{ "generation": 4187, "asOf": "2026-03-14", "config": { "config.pricing_rates": "b91f…", "config.tax_rates": "27ac…" }, "functions": { "markup": "5e0d…", "wrightCurve": "a412…" }, "scopePath": { "environment": "prod" }}Recomputing a stored result with its binding replays the exact vintage and returns a byte-identical value. Recomputing it without one deliberately re-resolves against today — the two are different operations with different names on the Setup surface, and the second is what an author means by “re-price at current rates.” The binding also makes divergence legible: replaying a production binding in a lower environment either honours the recorded scopePath or reports, per set, that the environment-scoped entry differs.
The compiler enforces one further rule, and it is the reason the scope table above carries a binding class:
- A stored formula field or roll-up may read only binding-stable configuration —
org,environment,app. Reading auser,permission_set, orlocale-scoped set from a stored computed field is a compile error naming the set and the scope kind. A column whose value depends on who last looked at it is not a column. - A virtual (read-time) formula may read viewer-dependent configuration freely. Its value is explicitly a function of the viewer, it is never stored, and nothing about it claims reproducibility.
The dependency is recorded in the static graph like any other, so “which formulas read the shop rate” is a query, a set-field rename reports its blast radius, and safe-delete refuses to retire a set that logic still reads.
Effective dating
Section titled “Effective dating”An effective-dated set declares asOfField — the record field that supplies the instant resolution uses. This is the whole design, and it is deliberately not a clock read: an expression that called now() would be impure, would produce a different answer on every recalculation, and would quietly re-price history the first time anyone touched an old record.
Because asOf comes from a declared, typed field on the record — an invoice’s service date, a subscription’s renewal date — a March invoice resolves March’s rate in July, because its price date still says March. A recalculation triggered by an unrelated edit is safe by construction; there is nothing to remember and no batch job to exclude old rows from. Re-pricing at today’s rates is an explicit act that writes a new value into the asOf field, and that write is an ordinary field change with ordinary data history — visible, attributable, and reversible.
A set with no asOfField is not effective-dated: one active entry per scope, and a new value replaces the old.
Publishing a future rate is an ordinary deploy of an entry whose effectiveFrom is in the future. It changes nothing on the day it lands, it is reviewed weeks before it takes effect, and the change is in the repository rather than in one person’s memory of a Tuesday afternoon in production.
Caching and invalidation
Section titled “Caching and invalidation”Configuration is read on nearly every request and written a few times a quarter, which is the shape that usually produces a cache-invalidation protocol — and a class of bugs where two servers disagree about the current rate.
There is no invalidation protocol here, because the metadata generation is the cache key. A generation is immutable, resolution is a pure function of (set, key, scope path, asOf, generation), so the resolved value for a generation can be cached forever and can never be wrong. Changing a rate produces a new generation; the atomic pointer flip changes the key. Nothing is ever evicted for correctness — only for space.
Concretely: at generation activation the kernel materializes the binding-stable slice of every configuration set — the org, environment, and app entries — into one in-memory structure keyed by generation. A formula reading a rate is a map lookup with no query, no round trip, and no per-transaction budget to blow. Viewer-dependent scopes resolve per request, keyed by (generation, user), and are small by construction because they are the exception rather than the default.
A request already resolving against generation N keeps N’s configuration until it finishes, and a save pinned to N commits under N’s rates even if N+1 activated mid-edit. That is the same guarantee the rest of the platform makes, arrived at the same way, and it is the deliberate version of a behavior the incumbent documents as an accident: “Requests that are in flight when metadata is updated don’t get the most recent metadata” (Custom Metadata Types Limitations).
Who may edit what
Section titled “Who may edit what”Three grants, all ordinary additive rows resolved by the same effective-access resolver as everything else, and all default-deny.
| Action | Grant |
|---|---|
| Define or change a set — its fields, types, scope chain, effective-dating | metadata.author (system permission) |
| Edit entries and flags at all — reach the surface | config.author (system permission, config domain) |
| Edit entries of one particular set | a per-set edit grant on a permission set, unioned exactly like FLS |
| Read a set’s values outside logic | a per-set read grant, unioned the same way |
The split between the first two is the point: “may change the shape of pricing” and “may change the freight rate” are different trust levels, and the pricing manager who needs the second must not be handed the first. The per-set grants then narrow config.author to the sets a team actually owns, using the union machinery that already exists rather than a parallel one.
Read access is default-deny from the first line of the model — a set is invisible until a permission set grants it. The incumbent reached the same conclusion in Winter ’20 and had to ship it as an org preference an admin must switch on, plus a per-type allowlist on profiles and permission sets (Control Read Access to Custom Metadata Types).
Why a rate edit is audited as a metadata change. Because it is one. A configuration entry is a component, so editing it produces a metadata-audit entry carrying the before → after diff of the entry body, attributable to a user and a correlation id, and it moves through validate → apply like any other change: reviewable before it lands, revertable to any prior committed state, promotable from a lower environment on the normal path. Routing the same edit through the record-data pipeline would give it a one-line history entry, no review gate, and no promotion story — for a value that is a direct input to money.
A business user editing a rate on a Setup screen in production is not an exception to this. The repository owns truth in every environment, so that edit is captured back to source by reverse-integration on the environment’s declared policy, and the person who typed the number never touches a manifest.
Limits, and the reasons behind them
Section titled “Limits, and the reasons behind them”| Concern | CAOS approach | Salesforce’s limit | Why their limit exists |
|---|---|---|---|
| Total configuration volume | No character allocation; bounded by a per-org generation-slice budget measured at deploy and reported with the change | 10,000,000 characters of custom metadata per org, charged by maximum field size rather than content | CMDT records live in the metadata store, not a tenant table, so they are billed against a metadata allocation |
| Fields per set | Bounded by the object field model, not by a configuration-specific cap | 100 fields per custom metadata type or record | Metadata rows map onto a fixed generic structure |
| Sets per org | No cap | 200 custom metadata types per org, plus 150 from certified managed packages | Same metadata-store accounting |
| Rows returned per transaction | Whole binding-stable slice is resident; a read is a map lookup | 50,000 custom metadata records returned per transaction | Bounds per-transaction metadata materialization |
| Text length on the fast path | Full value; text is TOAST-backed and untruncated |
Apex static accessors return only the first 255 characters; a SOQL query is required for the rest | The application cache stores a truncated projection |
| Read cost in logic | Map lookup; no query, no per-transaction budget | SOQL on CMDT is unlimited in Apex, but counts toward governor limits in Flows | Flow’s query path is not the cached Apex path |
| Query shape | Any typed predicate over the set’s real columns | Single-object SOQL only; no ORDER BY on relationship fields; OR cannot span columns |
The metadata store is not a general query engine |
| Traversing between sets in a formula | Ordinary typed property access | “Spanning custom metadata type relationships isn’t supported in formula fields” | The formula compiler cannot join across the metadata store |
| Sets referenced by one object’s validation rules | No cap | 15 unique custom metadata types per entity across all validation rules | Each reference is a lookup the rule engine must resolve per save |
| Cache coherence | Generation-keyed; a change is a new key, so staleness is impossible | Records cached at type level after first read; “requests that are in flight … don’t get the most recent metadata” | No versioned identity to key the cache on |
| Encryption of values | Not applicable — secrets are excluded by construction and live in the credential store | “Custom metadata types don’t support Shield Platform Encryption for fields” | The metadata store sits outside the field-encryption path |
How Salesforce does it
Section titled “How Salesforce does it”Salesforce spreads this one concern across five surfaces, and the seam between the first two is the most-asked question in the area.
Custom settings are the older construct: a custom-object-shaped container in two flavours. List settings hold static application data available org-wide. Hierarchy settings resolve through a built-in three-level hierarchy — organization, then profile, then user — returning the most specific value found. They support full CRUD from Apex, so an application can write them at run time — the capability custom metadata types then gave up.
Their defining flaw is that the records do not deploy. Gearset states it plainly: “you can’t deploy the data using packages, the Metadata API or Change Sets,” leaving teams to re-enter records by hand or write an Apex script per environment — an approach that article characterizes as “time-consuming and error-prone” (How to deploy custom settings). Every sandbox refresh re-opens the same wound, and a set of values that must be identical across environments has no mechanism guaranteeing that it is.
Custom metadata types were the answer, and the idea behind them is right: “The records of custom metadata types are also metadata, not data,” and “when you deploy apps with custom metadata types, all of the records and fields are included in the package installation, so no additional steps are needed” (Understanding Custom Metadata Types). Records are cached — “all custom metadata is exposed in the application cache, which allows access without repeated queries to the database” — and readable from Apex without SOQL through static accessors such as getAll() and getInstance(developerName) (Custom Metadata Type Methods). SOQL against them is unlimited per Apex transaction, though queries issued from flows do count toward governor limits, and long text area fields consume the character allocation whatever they contain (Custom Metadata Allocations).
What CMDT gave up in exchange is a long list. There is no hierarchy — no per-profile or per-user resolution at all. Runtime writes are gone: modifying records from Apex requires the asynchronous Metadata package, and deleting a protected record that shipped in a released package permanently burns its name. Formula access is by literal reference — $CustomMetadata.Support_Tier__mdt.Bronze.MasterLabel names one record inline (Optimize Workflow with Custom Metadata) — and “spanning custom metadata type relationships isn’t supported in formula fields,” “formulas that reference custom metadata types aren’t supported in approval processes,” and validation rules may reference at most 15 unique types per entity (Custom Metadata Types Limitations). The Tooling API and Developer Console do not support them, and Shield Platform Encryption does not apply.
The result is a genuine fork in the road with no good branch: pick custom settings and lose deployability, or pick custom metadata types and lose hierarchy, runtime writes, and formula reach. The two constructs are near-identical from the authoring screen and divergent everywhere else, which is why “custom settings versus custom metadata types” remains a standing topic in Salesforce architecture writing rather than a settled question.
Custom labels carry a third slice — user-facing strings, translated per language — as yet another separate construct with its own Setup node and its own limits.
Feature Management is the closest thing to a feature-flag primitive, and it is not available to ordinary orgs. System.FeatureManagement exists “to check and modify the values of feature parameters, and to show or hide custom objects and custom permissions in your subscribers’ orgs” (FeatureManagement Class) — a managed-package mechanism requiring a License Management Org, capped at 200 parameters per package, limited to booleans, integers, and dates, with subscriber-to-LMO values taking “up to 24 hours” to appear (Feature Parameters in AppExchange Applications). Nothing in it carries an owner or an expiry.
So orgs that are not ISVs build flags by hand out of custom permissions plus custom metadata types — a pattern common enough to have community frameworks written around it (salesforce-feature-flags). It works, and it has no owner field, no expiry, no staleness report, and no mechanism preventing a flag from outliving the engineer who added it.
Where CAOS is genuinely better:
- One construct where there are five. Configuration sets subsume custom settings, custom metadata types, custom labels, feature parameters, and the custom-permission-as-flag pattern, because typed fields plus a declared scope chain plus effective dating cover every case each of those was invented for.
- Values deploy, and hierarchy survives. Entries are canonical components, so there is no metadata-versus-data seam inside the construct — an environment built
from_repois complete, and per-permission-set and per-user scoping still work. - The scope chain is declared and closed. A set that accepts only
orgrejects a user-scoped entry at deploy. Hierarchy custom settings accept a profile and user override on every setting, always, with no way to forbid one. - Ambiguity is a deploy failure, not a runtime rule. Equal-priority permission-set entries and overlapping effective ranges both fail
validatewith both entry keys named, instead of being broken by a tie-break the author never chose. - Reproducibility by binding. A stored result records the configuration digest that produced it, so it recomputes to the same number years later. A CMDT record is mutable in place with no revision identity, so the value that produced a historical figure is simply gone.
- Effective dating driven by a declared record field. Ranges are validated for overlap and gaps at deploy, and the vintage follows the record’s own date rather than the clock.
- Cache correctness with no invalidation protocol. The generation is the key, so the in-flight staleness the incumbent documents cannot arise.
- Typed reads with no truncation and no literal record names.
config.tax_rates(record.ship_to_state).rateis a typed, analyzable expression;$CustomMetadata.Type__mdt.Bronze.Field__cnames one record inline, cannot span a relationship, and returns 255 characters on the cached path. - Flags with a required owner and a required expiry, enforced by the pipeline, and frozen rather than silently permanent once expired.
Parity: deployable configuration records — CMDT got the central idea right and CAOS copies it deliberately; scoped resolution with most-specific-wins, which is what hierarchy settings do and it is the correct shape; a cached read path that avoids a query per lookup; a firm separation between configuration and customer records; and typed fields on configuration rather than a bag of strings.
Costs and risks:
- Every rate change is a deploy. That is heavier than typing a number into a production custom setting, and a team that wants to move a threshold at two in the morning will feel the ceremony. Reverse-integration and per-set edit grants make it survivable; they do not make it free, and this is the single largest adoption cost on the page.
- Configuration revisions cannot be garbage-collected freely. A stored result binds a digest, so that revision must remain resolvable for as long as the result is expected to reproduce — a retention obligation that outlives generation drain and grows without bound on a high-churn set. A rebind-or-archive policy is mandatory, not an optimization.
- A generation flip rebuilds the whole configuration slice. Cheap at ordinary sizes, not free at pathological ones, which is why the per-org slice budget is measured at deploy rather than assumed.
asOfFieldis a modelling decision the platform cannot check for correctness. Validate confirms the field exists and is temporal. It cannot confirm it is the right date, and choosing the wrong one produces historical prices that are confidently and silently wrong.- The stored / viewer-dependent split will surprise authors. “Why can’t my stored formula read the user’s rate?” is a compile error with a clear message, and it is still a rule people have to learn before they understand why it protects them.
- A declared scope chain is more expressive than three fixed levels, and therefore easier to over-model. The guardrail is that a set declares only the scope kinds it needs and the default is
orgalone — a convention the platform enforces at deploy but cannot enforce in an author’s judgement.
Metadata & deploy representation
Section titled “Metadata & deploy representation”| Component | type |
Body |
|---|---|---|
| Configuration set | config_set |
cardinality, keyedBy, scopes[], effectiveDated, asOfField, fields[], read, edit |
| Configuration entry | config_entry |
set, key, scope, priority, effectiveFrom, effectiveTo, values |
| Feature flag | feature_flag |
owner, expiresOn, rationale, scopes[], default |
All three are ordinary components, so nothing on this page needs a pipeline of its own. Retrieve emits them; a diff of an entry is a value diff — freight_usd_per_lb: 0.7980 → 0.8140 — which is exactly what a reviewer wants to see in a pull request and exactly what the metadata audit stores. Validate runs the configuration-specific checks (scope-kind admissibility, priority ties, effective-range overlaps and gaps, mandatory flag owner and expiry, frozen expired flags, per-set field validators). Apply is transactional. Activation is the generation flip — no configuration change is ever DDL, so every one of them is a Class-A hot deploy.
Deleting a set or an entry routes through safe-delete: the inbound-reference check queries the static dependency graph for every formula, roll-up, validation rule, automation, layout, and list view that reads the set, and fails loud with their keys before anything is written.
Salesforce analogs, for migration mapping: a custom setting is a CustomObject declared as a List or Hierarchy setting type — its definition deploys and its records do not, so a migration must carry the records separately from wherever they were being kept. Custom metadata records deploy as CustomMetadata components and map onto config_entry almost directly, with the type mapping onto config_set. Custom labels map onto a config_set with a locale scope. Feature parameters (the featureParameters folder, boolean / integer / date) and custom permissions used as switches both map onto feature_flag, which is the point at which four incumbent constructs collapse into two.
Sources
Section titled “Sources”- Custom Metadata Allocations and Usage Calculations — Salesforce Help — 10 million characters per org, 200 types per org plus 150 from certified packages, 100 fields per type or record, 50,000 records returned per transaction, unlimited SOQL per Apex transaction, flow queries counting toward governor limits, long text areas charged at 255 characters.
- Custom Metadata Types Limitations — Salesforce Help — type-level caching and the in-flight staleness window; 15 unique types per entity in validation rules; spanning relationships unsupported in formula fields; approval-process formulas unsupported; no Shield Platform Encryption; no Tooling API or Developer Console; Apex modification only through the Metadata package; protected released record names not reusable.
- Control Read Access to Custom Metadata Types — Salesforce Help — read access granted per type through profiles and permission sets, behind the “Restrict access to custom metadata types” org preference.
- Understanding Custom Metadata Types — Trailhead — records as metadata; records and fields included in package installation; the comparison against custom settings (records deployable, lookup relationships, create/read/update but not full CRUD in Apex).
- Optimize Salesforce Workflow with Custom Metadata — Trailhead — the
$CustomMetadata.Type__mdt.Record.Field__cformula syntax and its literal record reference. - Protect Custom Metadata Types & Records — Trailhead — public versus protected types and records, and what a subscriber may edit after installation.
- Custom Metadata Type Methods — Apex Reference Guide — the application cache,
getAll()/getInstance(), and the 255-character truncation on the cached path. - FeatureManagement Class — Apex Reference Guide — checking and modifying feature parameters, and showing or hiding custom objects and permissions in subscriber orgs.
- Feature Parameters in Salesforce AppExchange Applications — Beyond The Cloud — LMO-to-subscriber and subscriber-to-LMO direction, 200 parameters per package, booleans/integers/dates only, up to 24 hours to sync.
- How to deploy custom settings in Salesforce — Gearset — custom setting data cannot be deployed by packages, the Metadata API, or change sets, and the manual and Apex-script workarounds teams use instead.
- salesforce-feature-flags — GitHub — the community pattern of building feature flags out of custom permissions and custom metadata types, in the absence of a first-class flag construct for non-ISV orgs.