Field model
An object in CAOS is a real Postgres table; a field is a real, typed column on that table; a relationship is a real foreign key. This is the schema layer — the physical shape over which every logic tier operates. A plain authored field is stored state, not logic: it holds a value the user or an integration writes. Fields that carry logic (formulas, roll-ups, overridable values) compose the pure tier over this same physical shape, but the field model itself is storage — typed columns, constraints, and keys the kernel manages with online DDL and tracks in a static dependency graph.
The model
Section titled “The model”Each object definition maps to one table. Each field definition maps to one column whose Postgres type is fixed by the field’s declared type (numeric, text, date, timestamptz, boolean, uuid, geography, …). The value lives in the column, on the row, typed at the storage engine — not in a shared generic slot converted on read.
| Concern | CAOS mechanism |
|---|---|
| Object | CREATE TABLE tenant.object_key (…) |
| Authored field | ALTER TABLE … ADD COLUMN key <pg-type> |
| Required field | NOT NULL (or a validation gate, see below) |
| Uniqueness | native UNIQUE index / constraint |
| Bounded value | CHECK constraint |
| Relationship | FOREIGN KEY … REFERENCES parent (id) with a declared ON DELETE action |
| Computed field | generated column / view column / trigger-maintained column (the formula and roll-up constructs) |
Read/write semantics follow the field kind. An authored field is read-write: it is accepted in insert/update payloads and persisted at the write step of the save order. A computed field (formula, roll-up) is read-only and never accepted in a payload. An overridable field is the composition coalesce(override, computed) — the override column is writable, the effective value is not (see Overridable values).
Tier: the field model is not a logic tier — it is the typed storage the tiers read and write. Integrity that can be expressed declaratively (type, NOT NULL, UNIQUE, CHECK, FK) is enforced by Postgres at write time, beneath and independent of authored validation rules, which run in the pure tier before the write.
Authoring
Section titled “Authoring”The authoring surface is the object designer — a surface delivered by an installable package that claims the kernel’s schema-authoring capability, not a UI compiled into the engine; the schema/DDL engine it drives stays kernel. It presents a canonical component per object and per field (see Metadata & deploy representation). A field declaration names a key, a label, a type, and type-specific options.
Field types
Section titled “Field types”A declared type composes onto the canonical three-axis storage set — a storage primitive, an optional format modifier, and (for references) a relationship flag — defined in Field types. The storage primitive fixes the Postgres column; the names below mirror the incumbent’s set so migrated schemas keep their contracts, and each resolves to exactly one canonical primitive:
text(boundedvarchar(length)),textarea,longtext(text),richtext— theTextprimitive with a length/render modifiernumber(numeric(precision, scale)),currency,percent— theNumberprimitive with a format modifiercheckbox— theBooleanprimitive (boolean)date→Date(date),datetime→DateTime(timestamptz),time→Time(time, a time of day with no date and no zone),zoneddatetime→ZonedDateTime(a future wall-clock time in a named place, kept as local time plus its IANA zone rather than as an instant)email,phone,url— theTextprimitive plus a format modifier, which resolves to the standard validator of the same name (std__email,std__phone,std__url) and is enforced on the save path, before the write transaction opens. Not a columnCHECK: the predicate is pure, so the identical code gives a form instant feedback and gives the server its authoritative answer. See Validator vocabularypicklist→Select,multipicklist→MultiSelect(see Field types for the value-set/overlay model)autonumber— theAutoNumberprimitive (sequence-backed generated identity)geolocation— theGeolocationprimitive: one field withlatitude/longitudesubfields over a single PostGISgeography(Point,4326)column (thecaos.geography_pointdomain), never two independent columns. It carries a GiST index emitted with the column, so distance and containment are index-served; it is the one field type that requires a database extension. Verified 2026-08-20 — live. See Field types for the wire shape, the range checks and the PostGIS requirement.lookup,masterdetail— relationship fields (the relationship flag onReference, below)- There is no compound-field primitive, and no separate compound-field concept anywhere in the model. A value with several parts is either a storage kind, when the parts have one unambiguous meaning and the whole is what a query has to understand (
Geolocation,ZonedDateTime, a currency amount and its code), or a composed field type over an existing primitive, when the parts are locale- or culture-dependent. An address is the second, and the composition machinery it waited on now ships: afieldTypecomponent composes a storage primitive, a validator chain and a format, and deploys through the ordinary write path with no platform release. A person’s name is neither and stays loose fields. See Values with several parts. formula,rollup— the computed primitives (pure tier)localizable— theLocalizableprimitive: a locale map held as the canonical stored value, resolved to the viewer’s language on readoverridable— theOverridableprimitive: a computed side plus one real, nullable override column, resolved ascoalesce(override, computed)
Field options
Section titled “Field options”required—trueemitsNOT NULLfor a simple column; on a relationship or where a cross-record predicate is needed, requiredness is a validation gate instead.unique— the field’s values must be distinct within the org. The platform builds and keeps the unique index that enforces it; nobody asks for an index. A save that would repeat a value is refused in VALIDATE with aconflict.unique_value_takennaming the field and the conflicting record (or counting it, when the saver may not see it), so the ordinary collision is a located error rather than a constraint violation out of the write. Uniqueness is per org and ignores the recycle bin — a tombstoned record does not hold its value hostage — and blanks are unconstrained, souniqueandrequiredstay independent. Not available on a storage kind whose value is computed or is not one scalar (Formula, Rollup, Overridable, Boolean, MultiSelect, JSON, Localizable, File, Geolocation).length/precision/scale— width for text and numeric columns.default— the value a new record starts with when it is created without one. It is an expression in the formula grammar, where a literal is the smallest expression (1,'new',false), so it may also read the record being created (record.name) or the save’s clock (today().addDays(30)). It is applied once, in SHAPE, on create only: never on update, and never over a value the caller supplied, an explicit blank included. It reads the values the create sent, not other fields’ defaults, so the order fields are declared in never changes a result. It is type-checked against the field at deploy (validation.default), and refused on Formula, Rollup, AutoNumber and Overridable fields. It is not a columnDEFAULT, so adding one fills no existing row.check— a bounded-value constraint expression.externalId— the key an outside system knows the record by. It makes the field usable as the address indata/upsert, which writes a record by that key in ONE call — created when absent, updated when present — and the platform builds the index that makes the lookup a probe. It is INDEPENDENT ofuniqueand does not imply it: an integration key is not a uniqueness claim, so two records may share one. When a key does match more than one record the upsert refuses rather than picking one; declare the fielduniqueas well if the key must always name exactly one record. Refused on the same storage kinds asunique.classification— what kind of data the field holds, from a closed vocabulary that drives how values are copied, sent, logged, and indexed (below).sensitive— marks the field as one whose values must not be copied, echoed, or granted casually (below).description— administrator-facing documentation of what the field is for: not shown to the person filling it in, shown wherever the field itself is being read about. It is also reported bydescribeFieldsand carried into the grounding projection, which makes it the one attribute that tells an assistant what an org means rather than only what shape it is — the counterpart toaiVisibility, which decides only whether a field may be seen at all. Capped at 1,000 characters (see Limits).helpText— the text a person sees beside the input, for whoever is filling the field in. The platform renders it:<caos-field-renderer>draws it under the control in edit mode, and under the inline editor while one is open on a record, so authoring it is all that is needed to make it appear. Capped at 510 characters. Deliberately not carried into the grounding projection — it is written for somebody typing into a form, and a reader already hasdescription.
Both are optional, and both refuse an empty string: omitting the attribute is how a field says it has none, so “absent” and “blank” are never two ways to say the same thing.
Data classification
Section titled “Data classification”Every field carries a classification: one label, from a set the kernel defines and an org cannot extend. The label is not documentation. Each one names the handling the platform applies to that field’s values in five specific places, and that is precisely why the vocabulary is closed — a label an org invented would have no transform in a lower environment, no disposition toward a model, and no treatment in an export manifest. It would be a comment on a field rather than a control over it, and it would be read as a control by the next person to open the page.
| Label | What it describes | Mask transform in a non-production environment | Sent to an external model | Implies sensitive |
|---|---|---|---|---|
public |
Values already outside the org — a published part number, a listed address | exempt |
Yes | No |
internal (default) |
Ordinary business data | exempt |
Yes | No |
confidential |
Commercially sensitive — margin, unit cost, compensation, supplier terms | shuffle |
Yes | Yes |
personal |
Identifies or describes a natural person; carries a required subtype | Per subtype | No, unless the capability is pinned to a provider declaring zero retention | Yes |
restricted |
Regulated by a regime that dictates handling — payment instruments, health data, government identifiers, credentials, biometrics | redact |
Never, not overridable | Yes |
personal requires a subtype because the subtype, not the label, decides the transform. Replacing an email with a fixed token breaks every screen that renders it and every format check that validates it, so the lower environment stops resembling the system under test:
| Subtype | Transform | Why that one |
|---|---|---|
personal:name, :email, :phone, :address, :dob |
synthetic |
A format-valid fake value keeps rendering, validation, and format checks behaving as they do in production |
personal:external_id — a customer number or party reference |
hash |
Deterministic within a run, so joins across objects survive the copy |
personal:free_text — a note or description field that may contain anything |
redact |
Entity detection in prose is unreliable, and nothing is passed through on the strength of a guess |
A field may additionally carry regimes — ccpa, coppa, gdpr, hipaa, pci, and any value an org adds. These carry no behavior of their own; they are the axis a compliance report groups by, which is why that list is org-editable while the classification is not. Salesforce’s CustomField records the same two ideas as securityClassification (Public, Internal, Confidential, Restricted, MissionCritical) and a complianceGroup multipicklist (CCPA, COPPA, GDPR, HIPAA, PCI, PII) (CustomField); the vocabulary here deliberately overlaps so a migrated field’s classification lands somewhere with the same name. What differs is that each label here names the mechanism it drives.
The five behaviors, in full:
- Masking in non-production environments. The label’s transform is the default the environment’s data policy applies on the way in. A field may override it to a stronger transform and never to a weaker one, and
restrictedcannot be overridden toexemptat all — the only way a restricted value reaches a lower environment in the clear is the environment-level downgrade, which is explicit and logged. - Visibility to a model.
restrictedis never sent, at any autonomy level, to any provider.personalis withheld unless the capability is pinned to a provider whose component declares zero retention. Everything else is sendable, subject to the asking user’s own field permission — classification restricts a value the user may read from being sent outward, which is a plane above field-level security rather than a substitute for it. The per-field override isaiVisibility, takingsendornever, and like the transform it may only tighten what the label already allows. - Export manifest. A column at
confidentialor above is named on the manifest of any export that carries it, and in that export’s audit entry. - Log redaction. Its values never reach an execution trace, an error’s
details, a job payload, or an integration replay body; a type-preserving placeholder goes instead. - Search indexing. It is absent from every search document, so it cannot match, rank, count, or be excerpted.
The last three are the handling rules the sensitive flag carries, and every classification at confidential or above implies that flag. The mechanics of each, and the three things the flag is regularly mistaken for, are next.
An unclassified field is internal, and internal is a real label — pass-through masking, sendable to a model, ordinary logging and ordinary indexing. Two things keep that default honest rather than accidental. The object designer requires the classification to be chosen when the field is created, with internal preselected, so “unclassified” is an answer someone gave rather than a question nobody was asked. And whether internal may leave the tenant is a single org-level AI setting, so an org that wants nothing leaving without an explicit label sets it once instead of classifying several thousand fields defensively.
Changing a classification is an ordinary component diff and appears in the metadata audit with the actor and the previous label. Tightening one takes effect at the next generation flip, like any other metadata change. Loosening one is not retroactive: a lower environment that was already provisioned holds masked values, because the transform ran on the way in and there is no unmasked copy behind it to reveal. Seeing the values requires re-provisioning that environment, which is a decision with an operator’s name on it.
Sensitivity
Section titled “Sensitivity”The field model carries a sensitive boolean, default false. It is a property of the field, declared once where the field is declared, and every surface that handles values reads it from there. It and the classification answer different questions: the classification says what the data is, and the flag says how its values must be handled. A field classified confidential, personal, or restricted is sensitive implicitly and cannot be declared otherwise. The flag is separately declarable on a public or internal field — a free-text note that occasionally carries something nobody anticipated is the ordinary case — and declaring it never lowers anything else about the field.
It exists because a permission model with bulk administration needs a field that resists bulk administration. Field access is granted in a grid across hundreds of columns, and the grant that leaks is the one nobody meant to make.
What the flag changes:
- Bulk grants stop treating it as one of the crowd. A bulk set across a field-security grid skips sensitive fields unless the operator includes them deliberately, and the confirmation counts them on their own line rather than folding them into a total. In metadata, a permission set that widens access to a sensitive field through a pattern or a wildcard is a
deploy-class error; the field must be named explicitly. Widening a sensitive field is always something someone typed. - Exports name it. A sensitive column that an export includes is listed on the export’s manifest beside the withheld columns, and the audit entry for the export names it. “Who has taken this off the platform, and when” is answerable per column rather than per file.
- Logs never carry the value. Execution traces, error
details, the developer-onlyrawcapture, job payloads, and integration replay bodies substitute a type-preserving placeholder. Data history still records the change, because history is the record of what happened to the field and is already governed by field-level security at read time. - Search never indexes it. A sensitive field is absent from every search document rather than assigned to a permission band, so it cannot match, rank, count, or be excerpted (search).
- Error messages name the field, never the value. A uniqueness or
CHECKviolation on a sensitive column reports the field and the rule that failed. Echoing the offending value back — the ordinary, helpful behavior everywhere else — would put it in a message that lands in a toast, a log, and frequently a support ticket.
What the flag deliberately does not do, because each of these is a thing it will be mistaken for:
- It is not encryption. The column is an ordinary typed column with ordinary indexes and ordinary query plans. Encryption at rest and in transit covers the whole database and is not opt-in per field; marking a field sensitive changes how its values are copied and echoed, not how they are stored.
- It is not a fourth access plane. It grants nothing and denies nothing. Who may read or edit a sensitive field is decided entirely by the object/field permission plane, and a sensitive field granted Edit is edited exactly like any other field, by any user holding that grant, through every surface. There is no separate approval, no second gate, and no way to reason about access by looking at this flag.
- It does not conceal the field’s existence. The field appears in the object’s field catalog, in Setup, in the schema, and in the metadata anyone may retrieve. Hiding a column’s name from people who may not read its values protects nothing and makes the model unauditable.
Relationship fields
Section titled “Relationship fields”A relationship is a field of type lookup or masterdetail carrying referenceTo (the parent object) and an onDelete action. Both compile to a real foreign key; the difference is in the declared semantics, not the storage:
| Kind | FK action default | Parent required | Ownership / sharing | Enables roll-ups |
|---|---|---|---|---|
lookup |
ON DELETE SET NULL (also RESTRICT, CASCADE) |
optional | child independent | no |
masterdetail |
ON DELETE CASCADE (mandatory) |
always required (NOT NULL) |
inherited from parent | yes |
A junction object (two masterdetail fields) is the many-to-many pattern, exactly as in the incumbent. Unlike the incumbent, the relationship count is not capped for engine reasons — a foreign key is an ordinary index, not a pivot-table row (see Limits).
Worked example
Section titled “Worked example”A custom object with a required scalar, a unique external id, a bounded number, and a master-detail parent:
{ "kind": "object", "key": "work_order", "label": "Work Order", "pluralLabel": "Work Orders", "fields": [ { "key": "name", "type": "text", "length": 120, "required": true }, { "key": "wo_number", "type": "text", "length": 32, "unique": true, "externalId": true }, { "key": "hours", "type": "number", "precision": 8, "scale": 2, "check": "hours >= 0" }, { "key": "status", "type": "picklist", "valueSet": "wo_status" }, { "key": "project", "type": "masterdetail","referenceTo": "project", "onDelete": "cascade" } ]}Deploying this component emits, in order, a CREATE TABLE (or the incremental ALTER TABLE ADD COLUMN set for an existing object), a UNIQUE index on wo_number, a CHECK (hours >= 0), and a FOREIGN KEY (project) REFERENCES project(id) ON DELETE CASCADE. Each is a node the kernel records in the static dependency graph.
Semantics & evaluation
Section titled “Semantics & evaluation”Where declarative integrity sits in the save order. Authored validation rules run in the pure tier before the transactional write. Declarative field constraints (NOT NULL, UNIQUE, CHECK, FK) are enforced by Postgres at the write, as the transaction persists the row. The two are complementary: validation gives a field-anchored, user-facing message pre-write; the constraint is the last-line guarantee the storage engine will not violate regardless of path (including bulk loads and integration writes that bypass a UI).
Null / blank. A null column is genuinely null — not an empty string in a shared VARCHAR. text distinguishes NULL from ''; numeric fields distinguish NULL from 0. Blank-handling policy for computed fields is a property of the formula/roll-up, not of the stored column.
Precision & determinism. number/currency/percent are numeric(precision, scale) — exact decimal arithmetic, no float drift — with scale declared at authoring time and enforced by the column type. datetime is timestamptz, stored in UTC.
Type contracts. A field’s declared type is its contract; the column type is fixed by it and is part of the deployable component’s identity. Changing a field’s type is a schema migration with an explicit cast, not an in-place metadata edit (see Limits).
Reference integrity. Because relationships are real foreign keys, referential integrity is transactional and enforced by the engine — a child cannot reference a non-existent parent, and the declared ON DELETE action is applied by Postgres, not simulated by application code maintaining a pivot table.
Ownership & sharing on master-detail. A masterdetail detail record has no owner of its own — exactly as in Salesforce, it inherits its owner, sharing, and visibility from its master (master-detail sharing). CAOS enforces that in the engine rather than the app layer: the detail table’s row-level-security policy defers to the master, so a detail row is visible exactly when its master is, and the detail’s effective owner is derived from the master rather than stored. This is intra-tenant record sharing — a distinct layer from tenant isolation, which is the schema-per-tenant boundary (see How Salesforce does it). The cost is stated plainly: an RLS predicate that joins to the parent must be backed by an index led by the sharing key, or reads slow as a tenant’s data grows.
Field usage. Before a field is retired, re-typed, or made required, the question is whether anything is actually in it. Every field’s own page in Object Manager answers it on request: how many records have a value, that count as a share of the records you can see, and — where the field holds values rather than bodies — which values are the common ones, with the number of distinct values behind the ones shown. It is counted when you ask, never kept up to date in the background, because it is a pass over the whole object. “Has a value” means the same thing here as it does to the required-field check: null and whitespace-only text are both blank. A reference is answered by the related record’s name, resolved through your own access, so a record you cannot open stays an id.
The figure is measured over the records you can see, through the same record predicate and field mask every other read goes through, so it can never become a side channel onto rows or columns you hold no grant for. A field you cannot read is refused rather than reported as empty.
Four kinds are not measured, and say so against themselves rather than showing a zero: a formula and a roll-up store no column on the object, an auto number is assigned by the platform on every record so its fill rate would measure the platform rather than your data, and long and rich text are the one limit carried over unchanged from the tool this replaced. Where the fill rate is answerable but a value breakdown is not — a place, a multi-select, anything stored as a document — the fill rate is given and the breakdown carries its own reason. A zero and an unanswerable question look identical on a screen and lead to opposite decisions, which is why one is never printed in place of the other.
Limits, and the reasons behind them
Section titled “Limits, and the reasons behind them”The incumbent’s schema-layer limits are largely artifacts of its shared-flex-column storage (see How Salesforce does it). CAOS’s limits are Postgres’s real limits, which are higher — but not infinite, and they carry their own honestly-named costs.
| Dimension | CAOS approach | Salesforce exact limit (cited) | The WHY (SF) | Does CAOS need an equivalent? |
|---|---|---|---|---|
| Custom fields per object | 1,000-field platform cap — above SF’s 800/900, below Postgres’s 1,600-column hard limit with headroom reserved for system/relationship columns | 800 most objects; 900 certain objects | flex-column slots in MT_Data are a fixed finite set |
Resolved: a 1,000 hard cap, an 800 wide-object warning, and opt-in jsonb overflow (see the note below). |
| Master-detail relationships / object | No engine cap; each is a foreign key | 2 max | >2 required parents makes inherited ownership/sharing ambiguous | Only a semantic cap for the sharing model, not a storage one. |
| Total relationships / object | No engine cap; a FK is an ordinary index | 40 | each relationship needs pivot-index maintenance; caps join fan-out | No engine limit; a planner-cost budget is the analogue if needed. |
| Roll-up summary fields / object | Trigger- or view-maintained; bounded by write-throughput budget | default 25, max 40 | roll-ups recompute on child DML; bounded to protect writes | Yes — a throughput consideration, not a hard count. |
| Field type change | Explicit migration with a cast; may rewrite the table | Many conversions disallowed or lossy; formula/encrypted/referenced fields locked | can’t retype a shared VARCHAR slot without data risk | Yes — a migration-discipline cost CAOS owns (see How Salesforce does it). |
| Deleted-field retention | Soft-delete window before the column is dropped | 15 days (reported) (Lightning, fixed) | grace window before hard delete | Design choice; a soft-delete window is table stakes. |
| Field help text | 510 characters — the incumbent’s number, taken deliberately | 510 (raised from 255 in Spring ’21, hard-capped there) | a form-layout constraint: help beside a control that runs on stops being read | Yes, and at the same number. Parity is the point — help text migrating from the incumbent must not arrive truncated, and 510 is the largest any of it can be. It is also the right number on its own terms, because this renders inside a form. |
| Field description | 1,000 characters | no equivalent cap published | administrator documentation, not rendered in a form | Yes, but for a different reason, which is why it is a different number. A description must be long enough to hold a real explanation — a 255-character cap forces a restated label, which is the failure it exists to fix — and short enough to stay bounded, because it rides in every grounding projection, where the cost is multiplied by the object’s field count rather than paid once. An object with 200 fields carries 200 of these into the context window per question. 1,000 is a short paragraph. |
Per-edition allocation figures (Enterprise/Developer reported at ~500 custom fields, Performance/Unlimited ~800; per-edition custom-object counts) are commercial tiering, not physics, and the figures available are secondary/dated — they are not stated here as current fact. The authoritative, confidently-sourced hard caps are 800/900 fields, 2 master-detail, 40 relationships per object.
How Salesforce does it
Section titled “How Salesforce does it”Salesforce does not create a table per object or a column per field. A Universal Data Dictionary stores objects and fields as metadata rows; row data for all objects of all tenants lives in one mega-table, MT_Data, whose generic flex columns are a single universal type — variable-length string — shared across many fields and converted on read/write with TO_NUMBER/TO_DATE/TO_CHAR (Platform Multitenant Architecture; Cirra deep dive). Because flex columns are untyped and shared, they carry no native index or constraint; typed pivot tables (MT_Indexes, MT_Unique_Indexes, MT_Relationships) copy values into indexed columns and a custom query optimizer rewrites SOQL against them, always filtered by OrgID (Cirra). Adding a field claims an unused slot — a metadata insert, no ALTER TABLE.
The CAOS design that is better, mechanism by mechanism:
- Native typing & integrity. A real
numeric/date/uuidcolumn enforces type at the storage engine; the incumbent simulates types over VARCHAR in the app layer. CAOS getsCHECK,NOT NULL, and nativeFOREIGN KEY … ON DELETE …transactional referential integrity with no relationship pivot table. - Real indexes and planner. Every column can carry a btree/GIN/partial/expression index; the Postgres planner uses real statistics. No value-copy index sync, no custom SOQL optimizer, no engine-imposed 40-relationship join ceiling.
- Static dependency graph → safe automated rename & reparent. Because objects/fields/relationships are real DDL objects,
pg_dependplus the kernel’s own graph give a precise compile-time dependency graph. Where Salesforce warns that changing a field’s API name breaks Flows, Apex, reports, and integrations with no automatic reference rewrite (Change the API name of a field), CAOS renames for you: a rename rewrites the fieldkeyand every in-graph reference (formulas, roll-ups, validation, automation, layouts, list views, permission sets) atomically in one deploy, so there are no dead fields and no manual cleanup. The one boundary the graph can’t see — an external API client that hard-codes the old name — is covered by keeping the old API name as an alias for a deprecation window. Master-detail reparenting follows Salesforce’s model — off by default, an opt-in per-relationship flag — but when enabled it is a transactional parent change that re-inherits the new master’s ownership and sharing (Allow reparenting). - Online DDL.
CREATE INDEX CONCURRENTLYand adding a nullable column (or a non-volatile default, PG 11+) are fast, low-lock operations — so “add a field” can feel instant while using a real column.
Parity, not better (table stakes that must simply work): API-name namespacing, the standard-vs-custom split, lookup/master-detail/junction semantics, roll-up summaries, soft-delete + undelete, and metadata-as-component deploy artifacts. None of these require the flex-column architecture.
Costs / risks CAOS owns honestly:
- Schema changes are real DDL. Every field is
ALTER TABLE ADD COLUMN; every object isCREATE TABLE. A migration engine with versioning, rollback, and failure recovery is a first-class subsystem — the incumbent hid this behind metadata inserts. - Column-count / row-width ceiling. The 1,600-column cap (lower in practice) is a real limit the flex model does not hit the same way (see the caution above).
- Multi-tenancy topology: single database, schema-per-tenant. Because every org gets its own real objects and columns, tenants do not share one physical table — a shared table with
tenant_id+ RLS would force per-tenant custom columns to collapse back into flex/jsonb, defeating the real-column thesis. Instead each org is its own Postgres schema inside one shared database (CREATE TABLE tenant.object_key …), giving real per-tenant tables and columns with clean isolation and single-database operations. Schema-per-tenant costs catalog size and migration fan-out at thousands of tenants; at the current tenant count that cost is nil, and the kernel can revisit the topology later without changing the platform contract. (Row-level sharing within a tenant is the separate RLS layer described under Semantics.) - Online-DDL is not free at all times, and that is handled. A nullable column add (or non-volatile default) is fast; a volatile default or certain type changes still rewrite and lock. Those run through the deploy pipeline’s online-migration discipline — expand → migrate → contract,
CONCURRENTLY,lock_timeout+ retry — and go live through the single metadata-generation flip described in Metadata & deploy; a field type change is always an explicit migration with a cast, never an in-place edit. When a new generation activates, open sessions keep working under their pinned generation and see a non-blocking “a newer version is available — refresh to update” banner.
The honest bottom line: the flex-column model is a workaround for 1990s–2000s DDL cost and per-tenant isolation that modern Postgres largely removes. A real-column kernel is a defensible and probably superior design for a bounded tenant count with moderate per-object field counts — not automatically superior at the incumbent’s extreme scale, where the column ceiling and DDL-under-multitenancy costs bite hardest.
Metadata & deploy representation
Section titled “Metadata & deploy representation”An object and each of its fields are first-class deployable components. The canonical component shape is key / label / type / body:
{ "kind": "field", "key": "work_order.hours", "label": "Estimated Hours", "type": "number", "body": { "precision": 8, "scale": 2, "required": false, "check": "hours >= 0", "description": "Planned labour on this work order, in hours. Set when the order is scheduled and revised when it is rescheduled; it is the plan, never the hours actually booked against the order.", "helpText": "Hours, to two decimals. Enter planned hours, not time already worked." }}description and helpText deploy like any other attribute, through the same validate-then-commit primitive as the CLI, the API, and a package install — there is no CLI-only path for them.
unique and externalId are plain booleans in the same body, and deploy the same way. Neither takes an index name, an index type, or any other knob: the index each one needs is a CONSEQUENCE the platform derives from the declaration, and it is reconciled against what is actually in the database rather than against what a given deploy happens to touch — so turning unique on for a field that already exists builds its index on the next deploy, and turning it off drops it. A deploy that would make a field unique over records that already share a value is refused before anything is written, naming the field.
A relationship field is the same shape with a relationship type and a body carrying referenceTo and onDelete. The component’s identity is the content-addressed hash of its normalized body, so retrieve → diff → deploy produces exact diffs and exact rollbacks; a change to precision or onDelete is a visible, reviewable diff, not a silent in-place edit. Deploy compiles the component graph to ordered, idempotent DDL (CREATE TABLE, ADD COLUMN, index, constraint, FK), applied within a migration transaction and recorded against the static dependency graph.
Salesforce Metadata API analog: an object is a CustomObject (fullName with the __c suffix, label, pluralLabel, nameField, sharingModel, fields[], recordTypes[]); a field is a CustomField (fullName = Object__c.Field__c, type from the FieldType enum, length/precision/scale, referenceTo + relationshipName, deleteConstraint = SetNull | Restrict | Cascade, unique, externalId, required, and the two documentation attributes description and inlineHelpText, which are what description and helpText above correspond to). These deploy as objects/<Object>/<Object>.object-meta.xml and objects/<Object>/fields/<Field>.field-meta.xml in SFDX source format (CustomObject, CustomField). The distinction: the incumbent’s fullName string is the identity and a rename silently breaks references, whereas the CAOS component’s identity is its body hash and the dependency graph makes a rename’s blast radius explicit.
Sources
Section titled “Sources”- CustomObject — Metadata API Developer Guide
- CustomField — Metadata API Developer Guide
- Platform Multitenant Architecture — Salesforce Architects
- Salesforce Database Architecture Explained — Cirra
- Custom Fields Allowed Per Object — Salesforce Help
- Object Relationships Overview — Salesforce Help
- Considerations for Converting the Field Type of a Custom Field — Salesforce Help
- Change the API Name of a Field — Salesforce Help
- The Force.com Multitenant Architecture, ch.8 — O’Reilly
- PostgreSQL Limits (1,600 columns per table) — PostgreSQL documentation
- PostGIS — Geography type & spatial indexing — geodetic lat/lon storage with sphere-based distance/containment (
ST_Distance,ST_DWithin) and a sphere-based spatial index; the storage backing for theGeolocationfield - Location-Based SOQL Queries (
DISTANCE()/GEOLOCATION()) — SOQL and SOSL Reference — the Salesforce Geolocation-field query baseline (single field, lat/lon subfields) the PostGIS backing matches and exceeds - Master-Detail Relationship (owner/sharing inheritance) — S2 Labs
- Allow Reparenting in a Master-Detail Relationship — InfallibleTechie