Field types
A field type in CAOS is not a fixed platform enum entry. It is a composition: one physical storage primitive (a real Postgres column type) plus an ordered list of validators and a display format, all stored as metadata. “Phone” is text + a phone validator + a phone display format; “Email” is text + an email validator; “Percent” is numeric + a range validator + a % format. The storage primitive is the only part that touches DDL. Validators and formats are pure — evaluated at save/read time in the pure tier, deterministic and statically analyzable — which is why a field type can be defined, extended, or invented by an org admin without a platform release.
The model
Section titled “The model”A field type is modelled on three orthogonal axes — storage, format, and relationship — the platform’s headline field-type decision, rather than the single flat type enum Salesforce fuses them into:
- Storage — one kind from a small, canonical primitive set (below), each mapping 1:1 to a real Postgres column. This is the only axis that emits DDL.
- Format + input mask — a modifier, not a type: Email, Phone, and URL are validated
Text; Currency and Percent areNumberplus a format; Rich and Long are render hints onText. Presentation only; never affects storage or validity. - Relationship — a flag on
Reference(a loose lookup vs. a composition that cascades and inherits ownership), not a separate storage type.
Cutting across all three: validators — metadata rows (regex, range, length, uniqueness, referential, cross-field) evaluated by one shared engine on both client and server.
Storage primitives
Section titled “Storage primitives”There are 18 storage kinds. Each maps to one Postgres column. Most store a concrete value; Formula and Rollup are the two computed kinds — first-class field types materialized as a generated or trigger-maintained column rather than a plain writable one, and Overridable composes a computed side with a real override column.
The table below is the whole set, and it is checked mechanically against the platform source on every test run: a kind that ships and is not listed here fails the build, as does a kind listed here that does not ship, as does the count in the sentence above. That is not belt-and-braces. The count on this page was wrong from its first version (2026-08-11) and stayed wrong; four shipped kinds were missing from it entirely; and one kind was described here in detail before it existed. None of the three was something anything was in a position to notice.
| Primitive | Postgres column type | Holds |
|---|---|---|
Text |
text |
UTF-8 character data, effectively unbounded (Postgres field limit ~1 GB, TOAST-backed) |
Number |
numeric(p,s) |
exact decimal; precision/scale enforced by the column |
Boolean |
boolean |
true / false / null |
Date |
date |
a calendar date |
DateTime |
timestamptz |
an instant, UTC-stored and TZ-rendered |
Time |
time |
a time of day with no date and no zone — a shift start, an opening hour |
ZonedDateTime |
local timestamp + a text IANA zone |
a FUTURE wall-clock time in a named place — an appointment, which must not be frozen to today’s offset |
Select |
text + value-set FK |
single value drawn from a first-class value set |
MultiSelect |
jsonb array of value-set refs |
multiple values from a value set — no delimiter-string trap |
Reference |
uuid (FK) |
foreign key to another object’s row |
AutoNumber |
sequence-backed generated identity | a monotonic record number |
JSON |
jsonb |
structured/compound values (Name, Address) and embedded config |
File |
file reference (external store + ref column) | uploaded content, referenced by id |
Localizable |
jsonb locale map |
one value per language, resolved to the viewer’s language on read |
Geolocation |
caos.geography_point — one PostGIS geography(Point,4326) |
a single latitude/longitude point, used as one field, with real distance/containment queries |
Formula |
generated / view column (computed) | read-only pure-tier expression over the row |
Rollup |
trigger-maintained column (computed) | parent aggregate over child rows |
Overridable |
one real, nullable column of the computed side’s leaf type (composite) | a computed value a person may pin: coalesce(override, computed) |
Every field, whatever its declared type, resolves to exactly one of these. Formula and Rollup are the computed kinds, layered over the same physical shape and detailed under Formula fields and Roll-up summaries. Overridable is a composite rather than a leaf primitive — a computed side plus one real, nullable override column of the computed’s underlying type, resolved as coalesce(override, computed) — but it is a storage kind you declare on a field, exactly like the others; see Overridable values. Time and ZonedDateTime are likewise kinds in their own right and not render variants of DateTime: a shift start has no date to render, and a future appointment stored as an instant fossilises today’s daylight-saving offset, which is a data defect no amount of formatting can undo.
Where the value lives, and when it computes
Section titled “Where the value lives, and when it computes”- Scalar field types (
Text/Number/Boolean/Date/DateTime/Select/Referenceand every composed type over them) store a concrete value in their column. No computation on read. - Validators run in the pure tier during save, before the write is issued (see Save order of execution). They never mutate; they accept or reject.
- Display formats run at read/render time only. A stored phone number is stored as entered (or as normalized by a save-time validator); the format is applied when it is displayed.
Values with several parts
Section titled “Values with several parts”A postal address has a street and a city and a country; a person’s name has parts; a place has a latitude and a longitude. The question of what the platform does with a value that has several parts has been asked repeatedly, and it has one answer: there is no separate compound-field concept, and there will not be one. A value with several parts is one of the two things the model already has.
A storage kind — when the parts have one unambiguous meaning and the whole is the queryable unit. Geolocation is this: one field, latitude and longitude subfields, one geodetic point underneath, subfields never separately writable. So is ZonedDateTime, which is one field over a local timestamp plus its named zone, and so is a currency amount with its code. The test a value has to pass is not “does it have parts” — it is whether the database has to understand the whole in order to answer a question about it. A distance query cannot be served by two loose numbers, which is the entire reason a place earned a kind of its own. A kind is a platform release and a migration, so the bar is deliberately high.
A composed field type — when the parts are locale- or culture-dependent. A field type is defined as a composition (a primitive plus validators plus a format, above), and the JSON primitive ships today and holds a structured value — Verified 2026-09-24 — JSON is one of the kernel’s storage kinds. Nothing about “several parts” needs a new concept; it needs the composition this page already describes.
One thing has to be said plainly about timing, because it is the difference between a decision and a promise: the composition machinery is documented intent, not shipping platform. A field today names a storage kind and an optional format and carries no validators, and composed and user-defined field types — the sections below — are not built yet. So an address waits on that work being done, and it waits on nothing else. What this decision settles is that it is not waiting on a compound-field concept, because there will not be one.
The three values usually named are answered separately, because they are not one question:
| Value | Answer | Why |
|---|---|---|
| A place | A storage kind — shipped, see Geolocation above |
Two numbers with one meaning, and real distance and containment queries that only the whole can serve |
| An address | A composed field type over JSON — not a kind |
Its parts are locale-dependent and their order and validation are country-driven, and nothing queries an address as an indexed whole. It needs composition, not DDL |
| A person’s name | Neither — loose fields remain correct | Nothing forces it, and a name is the most culturally loaded of the three. A platform that bakes in salutation and suffix encodes one culture’s name structure as everybody’s. If one is ever built it is designed, not copied |
A consequence worth stating plainly, because it is what this decision is for: no control arrives ahead of the model it needs. An address control that invents its own grouping in the palette would be the compound concept smuggled in through the component library rather than decided here.
Authoring
Section titled “Authoring”A field type is authored as a metadata record. The vocabulary is fixed and small; the power is in composition.
A field definition
Section titled “A field definition”{ "key": "account.primary_phone", "label": "Primary Phone", "type": "phone", "storage": "text", "validators": ["phone_us"], "format": "phone_us", "required": false, "unique": false}type names a composed field type (built-in or user-defined). storage, validators, and format are resolved from that type’s definition and may be shown read-only on the field, or overridden per field.
Composed field-type definitions
Section titled “Composed field-type definitions”A field type is itself a metadata record: a storage primitive, an ordered validator chain, and a display format. It deploys through the ordinary write path — the CLI, the API, or a package install — with no platform release, because nothing about it is compiled into the platform except the predicate primitives it names.
{ "type": "fieldType", "key": "std__email", "label": "Email", "origin": "standard", "primitive": "Text", "validators": ["std__email"], "format": "email"}{ "type": "fieldType", "key": "std__percent", "label": "Percent", "origin": "standard", "primitive": "Number", "params": { "precision": 5, "scale": 2 }, "validators": ["std__percent_0_100"], "format": "percent"}A field states its own storageType and it must agree with the primitive of the type it names; a
deploy that disagrees is refused (validation.field_type_primitive_mismatch). The type does not
supply a missing storage. Storage is the axis a later change migrates data for, so it stays legible
in the field that owns the column rather than resolving out of another component.
A field takes the type’s format, params and validator chain wherever it states none of its own.
Stating its own validators replaces the type’s rather than extending it — a type is a
starting point, and a field that has said what it asserts has said all of it. Extending would make
a type’s rules unremovable, which is exactly what “clone std__percent and drop the range
validator” has to be able to do.
Validator vocabulary
Section titled “Validator vocabulary”A validator is a named pure predicate, authored as its own component — not an inline blob on the field — so that “Email” means one thing across an organisation and the organisation can read what that one thing is. The field names the validator; the validator says what it asserts and what it says when it rejects.
{ "type": "validator", "key": "acme__part_number", "label": "Part number", "origin": "org", "predicate": { "kind": "regex", "pattern": "^[A-Z]{2}-[0-9]{4}-[VDT]$", "flags": "i" }, "message": "A part number reads like AB-1234-V."}The predicate is a discriminated union on kind, not a call string such as range(0,100).
Earlier drafts of this page wrote the call form, and it is not what shipped: a deploy is judged by
a generated JSON Schema validator, which can check the arms of a union exhaustively and cannot
check the inside of a string at all. A call string would have had to be parsed after validation, by
something that could still fail, which is a schema in front of a compiler that cannot honour it.
kind |
Applies to | Parameters | Rejects when |
|---|---|---|---|
length |
Text, Localizable | min, max |
Unicode character count outside the range |
byte_length |
Text, Localizable | min, max |
UTF-8 byte count outside the range (for parity with a legacy byte cap) |
regex |
Text, Localizable | pattern, flags (i and u only) |
the value does not match |
email |
Text | — | not an email address |
phone |
Text | style: any (default), e164, us |
not a phone number in that style |
url |
Text | schemes (default https, http) |
not an absolute URL, or a disallowed scheme |
range |
Number | min, max as decimal strings |
the value is outside [min, max] |
flags is restricted to i and u because g and y carry lastIndex between calls, which
would let the same predicate give different answers about the same value. range bounds are
decimal strings, not JSON numbers: a JSON number is a double by the time it is parsed, and a bound
an administrator wrote as 0.1 would be compared as 0.1000000000000000055511151231257827.
Validators compose in order and the first rejection wins — the author decides what a person is told first by deciding what to check first.
Where they run today, stated exactly. The chain is evaluated on the server, in the pre-write phase of the save order, on every write path — the UI, the API, an automation, a package install. That is the authoritative answer and there is no way around it, so there is no check here that happens only in a browser and can be skipped by calling the API directly.
A client does not run these predicates yet. The engine is deliberately written so that it could:
applyPredicate is one pure function with no I/O, no clock, no randomness and no Node built-ins, so
the identical code can run in a browser to give a form instant feedback without becoming a second,
drifting implementation of the same rule. Shipping that is a separate piece of work. Until it does,
a form learns a value was rejected by attempting the save — correct, just not immediate.
The distinction matters because the two failure modes are opposites. A UI-only check is a hole: the rule is skippable. A server-only check is a latency cost: the rule always holds, the person just finds out later. This is the second.
A validator attached to a field whose storage its predicate cannot judge is refused at deploy
(validation.validator_not_applicable), rather than silently accepting every value: an always-true
predicate and an enforced one look identical from outside.
Not predicates: the axes a declaration enforces
Section titled “Not predicates: the axes a declaration enforces”Earlier drafts listed unique, referential, precision / scale, in_value_set and required
in the table above. They are deliberately not in the vocabulary, and their absence is a decision
rather than a gap:
| Concern | Enforced by | Why not a predicate |
|---|---|---|
| Uniqueness | field.unique |
A unique index; a predicate would be a second, weaker answer that disagrees under concurrency |
| Referential integrity | field.referenceTo |
A foreign key and the delete gate |
| Precision / scale | field.precision, field.scale |
The numeric(p,s) column itself, on every write path |
| Value-set membership | field.valueSet |
The value-set guard, which is retire-aware in a way a membership predicate is not |
| Presence | field.required |
Keeping “must be present” orthogonal to “must be well-formed” is what lets a validator be attached to an optional field without silently making it mandatory |
The standard library
Section titled “The standard library”The platform ships a small set of validators and composed field types, each marked
"origin": "standard". Every one is a rule the platform already enforced under the format axis, so
the number of behaviours their arrival changes for an existing organisation is zero.
Validators: std__email, std__phone, std__phone_e164, std__phone_us, std__url,
std__url_https, std__percent_0_100, std__non_negative.
Field types: std__email, std__phone, std__url, std__percent.
They are readable, locked and clonable, and the three words are meant precisely:
- Readable — retrieve one and you see exactly how it is defined. There is no hidden platform behaviour behind the name; the predicate you read is the one that runs.
- Locked — the original cannot be edited in place (
validation.standard_component_locked), because every field in the organisation that names it would change meaning at once, silently, including fields in packages the organisation did not write. Redeploying one unchanged is not an edit and is accepted. - Clonable — retrieve it, change the key, deploy it. That is a supported act, not a loophole, and it is how an organisation gets a stricter email rule without arguing with the platform.
An organisation’s own component declares "origin": "org" or omits the field. Claiming standard
for a component the platform does not ship is refused (validation.standard_origin_claimed) —
otherwise the mark would tell a later reader nothing.
std__email is deliberately permissive, not RFC 5322: a local part, a single @, a dotted
domain, no whitespace. It refuses what is plainly not an address without rejecting the many
legitimate forms an address takes. Narrowing happens by naming a stricter intent — which is what
std__phone_e164 and std__url_https are for — or by cloning and tightening, never by the platform
quietly raising the bar underneath records that already exist.
Where the platform boundary actually is
Section titled “Where the platform boundary actually is”This is the line, stated plainly, because it is the whole point of the model and it is easy to lose:
Adding a new predicate kind is the only change here that touches the platform. The seven kinds
in the table above are the closed vocabulary; each one is code in the engine, and an eighth — a Luhn
check, an ISO country code, a cron expression — is a platform change: a schema arm, an
implementation in applyPredicate, an entry in the applies-to table, and a release.
Everything else is metadata, and needs no release at all:
| You want to | Platform release? |
|---|---|
Add a validator — any composition of an existing kind and its parameters |
No. Deploy a validator component. |
| Add a composed field type | No. Deploy a fieldType component. |
| Narrow a standard rule for your org | No. Clone the standard component under your own key. |
| Attach a rule to a field, or change which rules it runs | No. Deploy the field. |
| Ship a validator or field type inside an installable app | No. They are ordinary package members. |
| Add a new predicate kind | Yes. |
The incumbent’s line sits somewhere else entirely: its FieldType is a closed enum, so every row
above is a vendor release, and an org whose data has a shape the vendor did not anticipate waits for
one — or gives up and uses Text. Moving the boundary down to the last row is the difference this
model is for.
The boundary is deliberately narrow rather than absent. A predicate is code that runs on both sides
of the wire and decides whether a value may be stored, so an org minting arbitrary executable
predicates would be an org running arbitrary code inside the save path of a shared platform. A
closed vocabulary with open composition gives an administrator the expressiveness without that, and
regex is the escape hatch that makes the closed half rarely binding in practice.
Display formats
Section titled “Display formats”Formats are pure value → rendered string functions: phone_us, mailto_link, url_link, suffix('%'), currency(iso), decimal(places), deg_min_sec. A format never changes what is stored and never gates a save.
Rich text
Section titled “Rich text”A Text field with format: "rich" holds formatted text, stored as HTML. The field renderer reads it and edits it. It is one renderer in two modes, so the reader and the editor cannot disagree about what a value may contain.
Nothing that shows a rich value trusts it. A value can be written by the editor, the API, an import or a package. So the reader sanitises it every time it is drawn. The editor sanitises what it stores, what is pasted into it, and what a toolbar button inserts. The allow-list is:
- Elements kept:
p,br,strong,b,em,i,u,s,ul,ol,li,blockquote,code,pre,h2,h3,a - Attributes kept: only the address on a link, and only an absolute address with no user name or password in it.
- Link addresses:
http:,https:,mailto:. Every kept link is givenrel="noopener noreferrer nofollow" - Removed together with their content:
script,style,template,iframe,object,embed,svg,math,noscript,textarea,select - Any other element is removed and its text kept. Comments are removed. A link whose address is refused keeps its text.
When a rich value is read, web and email addresses written as plain text are shown as links. A list cell or a card has room for one line, so it shows a rich value as its words.
The toolbar has bold, italic, underline, strikethrough, a bulleted list, a numbered list, a link and clear formatting. A package adds a button of its own with a capability claim:
{ "capability": "rich-text", "claim": "inject", "injectionPoint": "toolbar", "order": 10, "richTextToolbar": { "key": "signature", "label": "Signature", "icon": "file-text", "insert": "<p>Regards,</p>" }}A button has exactly one action, and deploy refuses a button with neither or both:
insertplaces markup at the cursor. The same allow-list sanitises it, so a button cannot put into a record anything the editor could not.commandraises acaos-rich-text-commandevent carrying{ command, key, sourceKey, selectedText }. The package’s own component handles it.
Contributed buttons come after the platform’s own, grouped by group, in order.
User-defined field types
Section titled “User-defined field types”Because a field type is a saved composition, an admin can mint one without a release. Two components: the predicate, and the type that names it.
{ "type": "validator", "key": "acme__asset_tag", "label": "Asset tag", "origin": "org", "predicate": { "kind": "regex", "pattern": "^[A-Z]{2}-[0-9]{4}-[VDT]$" }, "message": "An asset tag reads like AB-1234-V."}{ "type": "fieldType", "key": "acme__asset_tag", "label": "Asset Tag", "origin": "org", "primitive": "Text", "validators": ["acme__asset_tag"]}Uniqueness is not among the validators, because it is not a predicate — a field of this type
declares "unique": true and gets a unique index. See Not predicates above.
Semantics & evaluation
Section titled “Semantics & evaluation”- Evaluation timing. Validators run in the pre-write phase of the save order: the record is shaped, then every field’s validator chain runs, then the transactional write is issued. A rejected validator aborts the save before any row is written. Display formats run only at read/render.
- Null / blank handling. Null is the absence of a value and is distinct from empty string for
text. Every validator treats null as vacuously valid — a blank email is not an invalid email, it is a blank, and whether a blank is allowed is what the field’srequireddeclaration answers. Presence is not a predicate for exactly this reason: keeping the two orthogonal is what lets a validator be attached to an optional field without silently making it mandatory, which is the mistake that makes people stop attaching them. - Precision & determinism.
numberisnumeric(p,s)— precision and scale are enforced by the column, at write time, for every path (UI, API, automation). There is no path that can store more scale than declared. This is a deliberate, provable departure from Salesforce (see below). - Type contracts. A field’s declared type is a contract the compiler enforces on every writer: formulas, validators, and automation that reference the field see its resolved primitive type. Changing a field type is a metadata migration with a storage consequence (see Limits) — not a free relabel.
- Length counting.
lengthcounts Unicode characters and is the default text-size constraint.byte_lengthcounts UTF-8 bytes and is opt-in, attached only to reproduce a legacy byte cap (Salesforce Rich Text counts bytes, not glyphs). The two are never mixed silently on one field — the enforced count is whichever validator is attached, and character counting is what a field gets by default. - Canonical stored value. The value in the column is canonical: formulas, roll-ups, reporting, and export all read it. A display format is applied at render time only and never rewrites storage, so the stored value and the rendered string can differ. There is no separate display-normalized shadow value, and no path where reporting sees a different value than the one on disk. A validator does not normalize either — a predicate answers yes or no and never rewrites the value it is shown. When a single stored form is required, the field says so by naming the narrower intent (
std__phone_e164rather thanstd__phone), so that a value not already in that form is refused while its author is present rather than silently rewritten into something they did not type.
Limits, and the reasons behind them
Section titled “Limits, and the reasons behind them”CAOS replaces most of Salesforce’s per-type caps with a single mechanism (a text column plus an optional length/byte_length validator, or a numeric(p,s) column). The Salesforce caps below are stated with their governing reason and whether the CAOS stack still needs an equivalent guard.
| Concern | Salesforce limit | Why SF caps it | CAOS equivalent |
|---|---|---|---|
| Single-line text | 255 chars (reported; see note) | Row-width / index budget in the multi-tenant shared schema | text column, optional length validator; no structural cap |
| Long Text Area | 131,072 chars/field | Bounds a single field’s contribution to row size | text (TOAST-backed, ~1 GB); optional length validator for parity |
| Rich Text (Html) | 131,072 chars/field, counted at the byte level (embedded <img> tags count) |
Byte accounting bounds storage regardless of glyph count | text; byte_length validator for byte-parity when migrating |
| Long + Rich pool | 1,638,400 chars total across all long/rich fields on one object | Caps aggregate row growth from big-text fields | No shared pool; per-column TOAST. Optional object-level budget as UX policy |
| Number precision | precision ≤ 18 total digits; scale < precision; UI-only enforcement — Apex/API can write more scale and it is stored | 64-bit decimal envelope; enforcement lives in the UI layer, not storage | numeric(p,s) enforces at the column, every write path — closes the leak |
| 80 chars (reported; see note) | String length budget | text + email_rfc5322; length is policy, not structural |
|
| Phone | 40 chars (reported; see note) | String length budget | text + phone_*; stored as entered/normalized |
| URL | 255 chars (reported; see note) | String length budget | text + url validator |
| Encrypted (Classic) text | 175 chars | Classic Encryption masking envelope | Not a field type; encryption is a storage/column concern, not a masked-text type |
| Formula text output | 3,900 chars, longer is truncated | Bounds computed-string materialization | Computed text length is a formula-tier limit, tracked separately |
| Picklist values | ≤ 1,000 active values/picklist; label ≤ 255, API name ≤ 80 (reported, secondary) | Set-membership validation cost / UI rendering | First-class value set (rows); size bounded by index/UX policy, not a hard 1,000 |
| Multi-select picklist | up to 500 selectable/record; combined ≤ 40,000 chars (reported, secondary) | ;-delimited string storage |
MultiSelect — a jsonb array of value-set refs; no delimiter trap |
| Geolocation | consumes 3 custom-field slots (lat, long, internal); compound read-only; API 26.0+ | Compound modeled as multiple physical fields | Single Geolocation field over one PostGIS geography column, real spatial queries |
| Roll-up per object | ≤ 25; requires master-detail (secondary) | Synchronous recompute cost on the parent | Storage-enforced roll-ups via triggers/materialized paths; cap is a perf policy, not 25 |
How Salesforce does it
Section titled “How Salesforce does it”Salesforce exposes a fixed, closed catalog of field types via a single FieldType metadata enum: Text, TextArea, LongTextArea, Html, Number, Currency, Percent, Checkbox, Date, DateTime, Time, Email, Phone, Url, Picklist, MultiselectPicklist, EncryptedText, AutoNumber, Location, Summary, Lookup, MasterDetail, ExternalLookup, IndirectLookup, Hierarchy (plus internal Address, File, Array, Integer, Long). Three orthogonal concerns are fused into that one enum:
- storage kind (Email, Phone, Url, Text are all the SOAP type
string), - validation (Email’s
@-and-domain check is hardcoded into the type), and - display (Phone’s US auto-format is hardcoded into the type).
Consequences that fall directly out of that fusion:
- Adding a field type requires a platform release. An org cannot define “VIN” or “Asset Tag” as a first-class type; it can only approximate with a Text field plus a validation rule.
- Precision enforcement leaks. Number
precision/scaleis enforced in the standard UI only; Apex and the API can write extra scale and it is stored — a documented gotcha. - Compound cost. A Geolocation field consumes 3 custom-field slots and the compound is read-only (write the subfields).
What CAOS does better
Section titled “What CAOS does better”- Composable/user-defined types without a release — a type is a metadata row (primitive + validators + format), so admins mint their own. (Genuinely better — no SF analog.)
- Storage-layer precision —
numeric(p,s)enforces scale at the column for every write path, closing the UI-only leak. (Genuinely better — provable in a test.) - Spatial geolocation — a single
Geolocationfield over one PostGISgeographycolumn gives real distance/containment queries (ST_DWithin,ST_Distance, sphere-based spatial index) instead of Salesforce’sDISTANCE()/GEOLOCATION()SOQL over a read-only compound of two subfields (SOQL location functions). It keeps the same single-field, lat/lon-subfield usability Salesforce’s Geolocation field has. (Genuinely better — at the cost of a PostGIS dependency, which is real: see the caution under Storage primitives.) Verified 2026-08-20 — live. - Self-serve indexing — Postgres indexes are addable without Support gating. (Better, with a cost: unmanaged indexes cause write amplification and bloat, so an index is a governed, lifecycle-tracked metadata component — see Costs and risks below.)
Parity, not “better”
Section titled “Parity, not “better””Text/long-text caps, picklist restricted/unrestricted + controlling/dependent semantics, global value sets, the name and address values they model as compound fields (matched as Values with several parts settles them — a composed type for an address, loose fields for a name — not by copying their compound), formula/roll-up/master-detail, FLS, history tracking, help text, required/unique/external-id/default — these must match Salesforce behavior (including byte-vs-char counting and per-object budget UX) so migrated data and admin muscle-memory transfer.
Costs and risks (do not overclaim)
Section titled “Costs and risks (do not overclaim)”- DDL churn. “One real column per field” means field creation is
ALTER TABLE. At Salesforce-scale field counts this is exactly why Salesforce uses a wide sparse/flex schema instead. CAOS accepts that cost by design: a field is a real typed column, not a slot in a sharedjsonbblob or a recycled column pool, because column-per-field is what makes native types, real indexes, FK integrity, and the query planner work — the things the platform is built to beat Salesforce on. The governor on the cost is the per-object ceiling of 1,000 fields/object (soft warning at 800), and the online-migration path below keepsALTER TABLEoff the hot request path. - Migration is not free. A validator tightening (e.g.,
lengthshrinks, or a type’s regex narrows) can reject existing rows; anumericscale change can force a table rewrite and lock a large table. Salesforce’s “convert with data loss” warnings become CAOS’s online-migration engineering problem: a storage-affecting change runs as a Class-B online migration (expand → backfill → contract) behind a single metadata-generation flip, not an in-placeALTER. A different cost, not zero. - Format round-trip. Storing “as entered” while displaying via a format means the stored and rendered values can differ. The rule is fixed, not left ambiguous — the stored value is canonical and is what reporting and export see, so any normalization is done by a save-time validator that rewrites the value before the write, never by the display format. The residual cost is discipline: skip that validator and reports see raw input, exactly as Semantics & evaluation prescribes.
- Freedom rots. Unbounded user-defined types accumulate the way unrestricted picklists do, so governance is structural rather than optional. User-defined field types and indexes are ordinary metadata components: they flow through the same retrieve → diff → deploy pipeline and the same permission-set-gated deploy approval as every other change. Each user-defined type counts against the per-object field budget (the 1,000/800 governor), and an index is a lifecycle-tracked component — the deploy planner rejects a redundant or budget-exceeding index rather than letting write-amplifying indexes pile up. The rope is finite by construction, so the model does not reproduce Salesforce’s data-quality problems with more of it.
Metadata & deploy representation
Section titled “Metadata & deploy representation”A field and a field type are each canonical JSON metadata components with the same envelope used across CAOS: key / label / type / body.
{ "key": "account.primary_phone", "label": "Primary Phone", "type": "field", "body": { "object": "account", "storageType": "Text", "fieldType": "std__phone", "required": false }}The field states its own storageType — always, and it must agree with the type’s primitive. It
states no validators and no format, so it takes std__phone’s: the chain ["std__phone"] and
the phone format. Stating its own validators would replace the type’s chain, not add to it.
{ "key": "std__percent", "label": "Percent", "type": "fieldType", "body": { "origin": "standard", "primitive": "Number", "params": { "precision": 5, "scale": 2 }, "validators": ["std__percent_0_100"], "format": "percent" }}type and key are the component’s identity and live on the envelope, not in the body — which
matters for a standard component, since the lock compares a deployed body against the shipped one.
Retrieve → diff → deploy. Field and field-type components retrieve as JSON, diff textually, and deploy as a unit. A deploy that changes a field’s storage primitive or tightens a validator is flagged as storage-affecting: it plans an online migration (validate existing rows against the new validator; rewrite the column if the primitive/precision changed) rather than a pure metadata swap. A deploy that changes only validators-that-loosen, formats, or labels is metadata-only.
Salesforce Metadata API analog. The corresponding element is CustomField (fullName = Object__c.Field__c), with a type drawn from the FieldType enum and a type-dependent element set: length/unique/caseSensitive/externalId (text), precision/scale (number/currency/percent), valueSet/valueSetName/restricted (picklist; global sets are the separate GlobalValueSet type), referenceTo/relationshipName/deleteConstraint (lookup/master-detail), summaryOperation/summarizedField (Summary), displayFormat (AutoNumber), displayLocationInDecimal/scale (Location). CAOS collapses the storage-carrying subset of these into primitive + validators + format, and keeps first-class value sets (the SF GlobalValueSet idea) as the default rather than the exception.
Sources
Section titled “Sources”- CustomField — Metadata API Developer Guide
- FieldType enum — Metadata API Developer Guide
- Field Types — Object Reference (confirms Encrypted 175 and formula text 3,900)
- Geolocation Compound Field — Object Reference — the Geolocation custom field type: a single compound field with latitude/longitude subfields, read-only compound, 3 field slots
- Location-Based SOQL Queries (
DISTANCE()/GEOLOCATION()) — SOQL and SOSL Reference —DISTANCE(loc1, loc2, 'mi'|'km')andGEOLOCATION(lat, lon), filterable inWHEREand sortable inORDER BY; the SF-parity baseline the PostGISgeographybacking matches and exceeds - 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 theGeolocationprimitive - Primitive Data Types — SOAP API Developer Guide (STRING_TOO_LONG behavior; standard-field length examples)
- Custom Field Types — Salesforce Help (client-rendered; source of the reported single-line caps)
- Rich Text Area Field Considerations — Salesforce Help
- Help KB
000382141(long/rich 131,072 + 1,638,400 pool, byte counting),000387302(Number precision ≤18, UI-only enforcement),000389980(help text 510) — client-rendered; surfaced via Salesforce search, corroborated where possible against the primary docs above.