Developer experience & the CLI
There are two ways a person changes an org’s shape, and they are on-ramps to the same operation rather than two different tools.
The first is the kernel developer surface every org exposes: the API, the CLI that wraps it, the on-disk source format the CLI reads and writes, and the VS Code tooling built on top. This surface is part of the kernel — it exists the moment an org exists, before any package is installed, because the engine that authenticates you and interprets metadata is the engine, not an app.
The second is the in-platform authoring builders — object manager, app builder, layout and record-page builders, the logic editors. Those are not compiled into the engine. They are metadata surfaces delivered by the platform administration package (and, for app-scoped authoring, by app packages), rendered by the same engine that renders every other screen, as How the platform works describes. A builder is a UI over the kernel’s write path — the same write path the CLI drives — not a second engine with private powers.
Both families reach one primitive: a single validate-and-commit operation. Metadata is validated against its schemas and referential integrity, then applied in one transaction or rejected whole. Nothing is half-applied, and nothing bypasses the checks. The CLI, the raw API, an installed package, and a click in a builder are four front doors onto that one door. Neither on-ramp is privileged — there is no capability the builders have that the CLI lacks, and none the CLI has that a builder’s package could not also surface, because both end at the kernel.
Two decisions make that true rather than aspirational.
The CLI has no vocabulary of its own. Every noun in the command surface is a concept the kernel already names — a component type, an environment, a log stream, a permission plane — and every verb is an operation the kernel already exposes. A command cannot be renamed for taste, because renaming it would mean renaming a kernel operation. This is the structural answer to CLI taxonomy churn: where an incumbent’s CLI topics are a client-side layer over the API (and were redesigned and removed as such — Salesforce’s force:source:*, force:mdapi:*, and force:org:* were withdrawn on 6 November 2024), CAOS’s nouns are kernel concepts, so there is no separate taxonomy left to redraw.
The engine validates; the surfaces only present. The check that rejects a bad formula in a builder, the check the CLI runs locally, and the check the deploy pipeline runs in its validate phase are one engine in the kernel. A package-delivered builder carries no validator of its own — it calls the kernel’s — so a builder cannot be a weaker citizen than the CLI, and the CLI cannot be a weaker citizen than a builder. There is no second implementation to fall behind.
The CLI is a client of the API, not a second engine
Section titled “The CLI is a client of the API, not a second engine”Every command resolves to one or more API calls. caos metadata deploy reads local source, diffs it against the target org, and calls the deploy API; caos data query calls the Data API; caos types generate calls the describe layer to read the org’s live schema. There is no capability the CLI has that the API lacks, and no path into an org that bypasses the API’s validation and access checks. A headless customer scripting against the raw API gets exactly the enforcement the CLI gets, because the CLI is an API client. Reach for the CLI for interactive and version-controlled authoring; reach for the API directly for programmatic and headless automation. The full command reference lives on the architecture site; the API families are specified there as well.
The CLI is a client of the control plane, not a wrapper around a REST API it re-shapes. Three properties follow, and they are what make the rest of the page work.
| Property | What it means | What it buys |
|---|---|---|
| Closed noun set | The nouns are enumerated and versioned as part of the CLI contract. A new capability attaches to an existing noun or forces a contract major. | No spelling drift between releases; a script written against contract 1 runs against every 1.x. |
| One spelling per operation | No aliases, no synonyms, no legacy forms kept alive “for compatibility.” | --help output is the whole surface; there is nothing to migrate off later. |
| Read/write is visible in the verb | 47 of the 72 registered verbs are read-only by construction. 22 write to an environment, 3 more end a credential, and 6 of those 25 cannot be undone from the CLI. | A reviewer reading a CI script knows what can change production by reading the verbs. |
caos --contract prints the contract version the binary implements; caos.project.json records the contract the project was authored against, and the CLI refuses to operate on a project written for a newer major rather than guessing. A checkout that still carries the retired caos.json descriptor is refused rather than read as a project, and the refusal names the rename: project check, project format and the test verbs answer conflict.legacy_descriptor (exit 13), while package build and the other verbs that load src/ answer internal.legacy_descriptor as a usage error (exit 2). Within a major, a command’s name, flags, and JSON shape are frozen; a removal requires a major bump published with a mechanical rewrite for every affected invocation.
The command surface
Section titled “The command surface”Grammar
Section titled “Grammar”caos <noun> <verb> [target] [--flags]Noun then verb, never the reverse, and never a colon-separated topic path. The noun is the thing; the verb is what happens to it; the target is which one. Each noun declares exactly one default verb that may be omitted, which is why caos audit and caos logs read the way they do — those are the default verbs of their nouns, not a second grammar.
Noun-verb wins for a mechanical reason rather than a stylistic one: tab completion and --help are useful only if the first token narrows the space. caos metadata <tab> offers ten verbs; caos deploy <tab> would offer every object in the platform.
The surface
Section titled “The surface”Verbs that write to an environment are marked ✎. Verbs that cannot be undone from the CLI — no compensating verb exists — are marked ⚠, and those are the ones --allow-destructive below exists to gate.
The two are not nested, which is why they are two markers rather than a scale, and why three verbs carry ✎⚠ together. Of the 72 verbs in this table, 47 are read-only, 22 write to an environment, and 6 are irreversible — but only 3 are both. The table and those counts were checked against the CLI’s registry on 2026-09-10. The other three irreversible ones, auth logout, auth revoke-token and auth rotate-token, end a credential server-side without changing anything in an org: nothing in an environment differs afterwards, and the token is still gone for good.
| Noun | Verbs | Default | What it covers |
|---|---|---|---|
auth |
login, logout ⚠, list, switch, whoami, tokens, revoke-token ⚠, rotate-token ⚠ |
list |
Browser or device-code sign-in; credentials in the OS keychain, never in the project. logout and revoke-token revoke server-side, and rotate-token replaces the stored secret and ends the old one, so all three write to the identity rather than to the environment |
env |
list, show, use, open, preflight |
list |
Selecting the target environment, opening it in a browser, and reporting whether each configured target can be used for live proving |
project |
init, generate, check, format |
— | Scaffolding — init into the current directory, generate into a new one, with create as an alias of generate; the full local gate (layout, hashing, purity, referential integrity) |
metadata |
retrieve, diff, validate, deploy ✎⚠, status, describe, component-get, component-validate, component-upsert ✎, component-delete ✎ |
— | The four pipeline phases, one verb each; the org’s active metadata generation; a describe of its objects and fields; and reading, validating, writing or deleting one component. apply is a retained alias for deploy and dispatches identically. component-delete is a logical delete that the prior generation still carries, so it is not in the irreversible subset |
types |
generate, check |
generate |
Emitting and freshness-checking the generated type surface |
test |
run, list, explain, run-integration ✎ |
run |
A project’s test cases. run executes golden data and pure test files offline; run-integration runs integration tests, each environment in an org it provisions for the run and deletes after |
data |
query, export, import ✎, delete ✎⚠ |
query |
Record reads and loads, under the caller’s own access. delete is irreversible because no undelete verb exists |
logs |
list, show |
list |
The execution-trace stream |
audit |
list, show |
list |
The metadata-audit stream |
history |
list, show |
list |
A record’s field-change history, FLS-scoped |
trace |
export, show |
show |
Reading or packaging one transaction |
jobs |
runs, show, replay ✎, discard ✎, pause ✎, resume ✎, enqueue ✎ |
runs |
Background work: the runs of each job definition, and the controls over them — replaying or discarding a dead-lettered run, pausing and resuming a job key, and enqueuing one run now |
access |
explain, check |
explain |
Which clause admits a user to a row or a field |
automation |
show, check |
show |
The object automation board — one controller per object/phase slot, enforced at deploy. check answers whether each automation is bound to a slot and compiles, whether a slot is contested, and whether the object’s save plan compiles at all — one bad declaration stops every automation on that object. Given a record it also reports which would fire, and withholds that verdict rather than guessing when the condition reads a field the caller cannot see. Verified 2026-09-24 — the kernel answers all of this; the CLI verb that surfaces it is the other half of the same change and lands separately, so until that half is in, what the published CLI ships is still the stub this row used to describe |
package |
list, build, publish ✎, promote ✎, install ✎, uninstall ✎, purge ✎⚠, issue-secret-key ✎, rotate-secret-key ✎, revoke-secret-key ✎ |
list |
Versioned component sets, their dependencies, their release state, and the secret keys that gate protected ones. promote releases a published version to customer tenants; like publish, it records a permanent registry fact without losing anything, so it is not in the irreversible subset. purge destroys the records an installed package holds; uninstall does not |
capability-override |
get, list, set ✎, clear ✎ |
get |
An org’s standing permission for a capability’s master to be replaced |
provisioning |
create-account ✎, list |
create-account |
Pre-org control-plane genesis, gated by the provisioning secret rather than by a bearer token |
caos trace replay is removed, not missing. Re-running a recorded transaction was never specified, and it would have performed that transaction’s writes a second time for a caller holding only read permission. The binary still recognizes the name so that it can refuse it by name and point at trace show — invoking it exits non-zero with that explanation rather than silently reinterpreting the argument as a correlation id.
This table is the registry’s closed noun set, which is the contract surface: adding a noun here forces a contract major. The binary ships more than this — nouns that exist but are deliberately unregistered, pending that decision. For the full generated inventory of everything the binary dispatches, see docs/COMMAND_SURFACE.md in the CLI repository, which is produced from oclif’s own command tree and therefore cannot omit a shipped command or invent one.
There is no env create. An environment is a canonical component, so it comes into existence by being deployed — caos metadata deploy with the environment component in the set. The CLI has no verb the model does not have, which is the whole point of deriving the surface from the kernel rather than from ergonomics. The package and provisioning verbs drive the same Package Manager and provisioning services the API and the setup builders reach.
Global flags
Section titled “Global flags”A command names its target in one of two flag dialects. The --org dialect takes -o/--org, an org alias or a full instance URL, plus --format; the metadata, types, data, logs, audit, history, trace, jobs, access and automation verbs use it. The --env dialect takes the set below; the auth, test, package, capability-override and provisioning verbs use it, as do project init, check and format. env takes neither, because it manages the stored targets themselves. Each flag means the same thing on every command that carries it, and docs/COMMAND_SURFACE.md in the CLI repository lists every command’s flags:
| Flag | Effect |
|---|---|
--env <key> |
Target environment, resolved as an alias the same way caos env use validates one. Defaults to the project’s selected environment. |
--format human|json|ndjson |
Output shape. One flag with a value, not one boolean per format. |
--since / --until |
Time bounds on the stream nouns. |
--follow |
On a stream noun: keep the connection open and emit entries as they arrive. |
--quiet |
Suppress progress detail on stderr. Never changes the result document. |
--no-color |
Disable ANSI styling. Implied when stdout is not a terminal. |
--profile <name> |
Named credential/environment pair, for people who work across tenants. |
Write-safety flags
Section titled “Write-safety flags”These are not global. --yes, --allow-destructive and --allow-paid-org are declared only by the verbs that can write; --dry-run also appears on a few reads that preview work, such as metadata retrieve, metadata validate and package build. So a command’s --help tells you what that command is able to do — a read-only verb like package list does not advertise --allow-destructive, and a purely local one like project format does not advertise --allow-paid-org.
| Flag | Effect |
|---|---|
--dry-run |
On a writing verb: compute and print the full effect, write nothing. |
--yes |
Clear routine confirmations so a writing verb runs unattended in CI or a script. Never authorizes an irreversible verb on its own. |
--allow-destructive |
Required, together with --yes, for the ⚠ verbs. data delete takes no --yes: it requires --confirm-id, repeating the --id exactly, in its place. |
--allow-paid-org |
Acknowledge a target the server reports as paid — a billed org under its account plan. This is the plan kind, not a tier: it does not distinguish a scratch org from a staging one, and it is not a production marker. Every writing verb aimed at an existing org demands it. |
The two are independent, which is why a verb can waive one and still face the other. --yes answers the routine question “did you mean to run this”; --allow-paid-org answers the separate question “did you mean to run it here”, and no amount of the first supplies the second.
Output
Section titled “Output”stdout carries the result. stderr carries everything else. Progress, warnings, spinners, and diagnostics go to stderr in both formats, so caos metadata diff --format json | jq never needs a redirect and never sees a progress line spliced into the document. --format json emits exactly one JSON document, on success and on failure alike; --format ndjson emits one object per line, so caos logs --follow --format ndjson pipes into a line-oriented consumer without buffering the whole stream.
The failure document is the canonical error envelope verbatim — code, class, message, origin, location, severity, retriable, fault, correlationId, details. There is no CLI-specific error shape, so a CI job and a browser client parse the identical structure, and the correlationId printed in a red terminal line is the same id the log verbs take.
Exit codes
Section titled “Exit codes”The exit code is the error class. A CI job branches on failure kind without parsing anything.
| Code | Meaning |
|---|---|
0 |
Success |
1 |
internal — the catch-all; never the caller’s fault |
2 |
Usage — malformed invocation, rejected locally, nothing sent to the platform |
10 |
validation |
11 |
permission |
12 |
not_found |
13 |
conflict |
14 |
computation |
15 |
limit |
16 |
deploy |
17 |
integration |
Codes 10–17 are the eight non-catch-all classes of the error taxonomy, in its documented order. Retriability is not a code — a conflict from a stale generation and a conflict from a duplicate key differ in exactly that respect — so the retry decision reads retriable from the envelope while the branch decision reads the code.
One invocation, one envelope, one code. A command that fails exits with exactly one envelope, and its class is the exit code. A run that produced many findings — a validate that blocked on thirteen components, an apply that failed one and could not reach three — does not produce many exit codes: the findings are details on the run’s single envelope, and the class of that envelope is set by the phase that stopped the run. Three cases sit outside that and are worth naming:
- A command rejected before anything was sent — bad flags, a missing manifest, a confirmation that cannot be given because standard input is not a terminal — is usage, exit
2. Nothing was attempted. - A command refused for a missing grant is
permission, exit11, with the permission named. - A target that could not be reached, and any failure the normalizer could not classify, is
internal, exit1, printed with its correlation id.
There is no second convention anywhere: the eleven values above are the entire set, and they change only when the error taxonomy changes.
Terminal versus CI
Section titled “Terminal versus CI”The CLI detects whether stdout is a terminal and changes three behaviors, none of which affect the result:
- Color and progress are on for a terminal, off otherwise.
- Confirmation prompts exist only in a terminal. In a non-terminal context a command that would have prompted fails immediately with exit 2, naming the flag that would have answered it. It never waits on stdin, and it never silently proceeds.
- Progress reporting for long operations becomes periodic single-line records on stderr instead of a repainting bar, so a CI log stays readable.
Everything else — flag parsing, defaults, the result document, the exit code — is identical. CAOS_FORMAT=json sets the default format for a whole job without touching each invocation.
Authoring: the on-disk project
Section titled “Authoring: the on-disk project”An org’s metadata is representable on disk as source — the state that makes it diffable, reviewable, and deployable like code. Today a project carries that source in two formats, and which verb reads which matters:
src/, metadata as files. One JSON file per component at<metatype>/<name>.json, nested under its object where it belongs to one.caos metadata retrievewrites this tree;caos metadata diff,validateanddeploy,caos package buildandcaos project checkread it. It is the treecaos.project.jsondeclares as its default package directory.components/, the engine projection. One file per component at a path computed from its key and type, with code-bearing bodies extracted to sibling.tsfiles.caos project formatand thetestverbs read only this tree;caos project checkreads it as well assrc/.
Converging the two into one format is planned, not shipped. Until then caos project check gates both trees. It reads src/ through the same loader caos package build uses and holds each body to its metatype, refusing with the code the later verb would use: validation.package_source_invalid, validation.package_member_duplicate or validation.body_schema. A declared file it still does not read, such as one in a second package directory, is named in a validation.unchecked_source refusal (exit 10) rather than passed over. The result counts the two trees separately, as components for the projection and sourceComponents for src/. caos project init and caos project generate both write the descriptor and a src/ tree, and init also seeds worked examples under components/; a fresh project from either passes project check.
caos.project.json # project descriptor — local dev config, not metadatasrc/ # metadata as files: what retrieve writes, deploy reads and check gates objects/ invoice/ object.json fields/ freight_total.json margin_pct.json layouts/record_page.json listViews/open_invoices.json permissionSets/ps_billing.json package/pkg_invoices.package.json # the package manifest, not a componentcomponents/ # the engine projection: what format and test read, and check gates objects/ invoice/ invoice.object.json fields/ margin_pct.field.json calc/ markup.calc_function.json # carries its own golden cases markup.expression.ts # extracted body automation/ invoice.automation.json invoice.rules.ts # extracted bodymanifest/ deploy.json destructive.jsontypes/ # generated, committed metadata.d.tsThe file is a projection of the component, and the projection is total
Section titled “The file is a projection of the component, and the projection is total”These rules describe the components/ projection, the format project format and the test verbs read and project check gates beside src/. Two rules define the mapping, and both are mechanical:
- A component’s path is a pure function of its
keyandtype.invoice.margin_pct+fieldresolves tocomponents/objects/invoice/fields/margin_pct.field.jsonand nowhere else. There is no path table to maintain and no per-type placement convention to remember, so a file cannot be in the wrong place —caos project checkrecomputes every path from its key and reports any file that has drifted. - Extraction is declared by the component schema, not chosen per type. When a type registers its component schema, it names which
bodykeys hold code and get extracted to a sibling.tsfile.body.expressionbecomes<stem>.expression.ts; the automation type’sbody.rulesbecomes<stem>.rules.ts, which is why an automation controller lives ininvoice.rules.tsand reads like the ordinary TypeScript it is. Every type with a code-bearing body decomposes, because the schema that makes the type deployable is the same schema that declares its extraction.
The reconstitution is provable rather than asserted. Component identity is the content-addressed hash of the normalized body, so caos project check normalizes the files, hashes the result, and compares against the retrieved component’s hash. A projection that lost or reordered anything produces a different hash and fails locally, before a deploy.
caos.project.json is the one file that is not metadata. It holds the project’s name and namespace, the kernel range it targets, its package directories and dependencies, the org targets it knows, and the CLI contract major it was authored against — local development configuration that has no effect on how any environment behaves. Anything that changes an environment’s behavior is a component; anything in caos.project.json changes only what happens on this machine.
Generated types
Section titled “Generated types”caos types generate --org <alias> describes the target org’s objects and fields and writes one file, types/metadata.d.ts by default (--out moves it):
/** * GENERATED by `caos types generate` — DO NOT EDIT BY HAND. * * Pinned to the org's active metadata generation: 42. * Re-run `caos types generate` after a deploy; `caos types check` fails on drift. */
export const METADATA_GENERATION = 42 as const
/** Invoice (invoice) — custom object. */export interface invoice { name: string; // custom sell_price?: number; // custom margin_pct?: number; // custom status?: string; // custom account_id: string; // custom → account}
/** Object API name → its row interface. */export interface OrgObjects { invoice: invoice;}What the shipped file carries comes from the org’s own describe:
- One interface per object, and an
OrgObjectsmap from each object’s API name to its interface. - Optionality from each field’s
required. - A primitive per field type. The number, currency and percent families are
number; checkbox and boolean areboolean; everything else, including picklists and references, isstring, with a reference’s target object named in a trailing comment. - Provenance —
standard,customorsystem— as a trailing comment.
A richer surface is planned, not shipped: branded ids, picklist values as unions, typed relationship traversal, read-only marking for formula and roll-up fields, and the calc-function signature and permission-key sets. Until it lands, record.account.owner_id is not a checked traversal, and a picklist value that is no longer active still type-checks.
Types are pinned to a metadata generation. The header and METADATA_GENERATION record the generation the file was cut from. caos types check --org <alias> describes the org again and compares. A committed file that no longer matches — because the org moved, the file was hand-edited, or it is missing — fails with conflict, exit 13. That catches the ordinary failure of generated bindings: code that type-checks locally against a schema the org no longer has.
Generated types are committed. Generation is byte-deterministic for a given generation id, so a stale commit shows up as a diff rather than as a mystery, and a pull request that widens a picklist or drops a field shows the schema change in review next to the code that consumes it.
Authoring in a builder versus locally
Section titled “Authoring in a builder versus locally”The in-platform builders and the local project are two on-ramps to one engine. Both exist because they are good at genuinely different things, and neither is a degraded version of the other. They write the same canonical component through the same kernel write path — the builders are surfaces the platform administration package and app packages deliver, not a private editor baked into the engine.
What an in-platform builder gives that a local editor cannot:
- The schema it checks against is the live one. No generate step, no pin, no possibility of staleness — the builder resolves
record.margin_pctagainst the environment’s active generation as you type. - Real values, not declared shapes. Picklist completion offers the values that exist; a lookup completes against records that exist; a formula preview evaluates against a real row.
- The contract is visible. The return type and tier the surface will accept are displayed and enforced as the logic is written, so a validation rule that could return a non-boolean is rejected at the keystroke rather than at deploy.
- Zero setup. An admin fixing a validation rule at 4pm needs a browser, not a toolchain.
What a local project gives that a builder cannot:
- Repository-scale operations. Renaming a field across four hundred references, reviewing a hundred-component change as one diff, bisecting a regression across commits.
- One atomic change across many components. A field, the formula that reads it, the layout that places it, and the permission set that grants it move as a single reviewable unit.
- Your own tooling. Editors, agents, generators, linters, and scripts operate on ordinary files.
- Offline work and true isolation. Pure-tier tests run in-process with no environment at all.
Neither surface is authoritative — the repository is, in every environment including production. A builder edit is captured back by reverse-integration: continuously in lower environments, review-gated in production, merged field-by-field against the last-captured baseline rather than last-write-wins. So the two are not a fork to be reconciled by discipline; the capture path is the reconciliation, and the only thing it ever holds back is a field two actors changed at once.
Determinism of the local loop
Section titled “Determinism of the local loop”Three properties make a local result trustworthy:
- Pure-tier evaluation is the same code path locally and in the kernel. The expression interpreter is one implementation over one exact-decimal value model; a calc function that returns
1.45 → 1.5locally returns it in production for the same reason, not by coincidence. - Every check is generation-pinned. Type checks, purity checks, and dependency analysis all run against a named generation, and a mismatch fails rather than warns.
- Nothing in the loop reads a clock or a network by default. A pure test is reproducible byte-for-byte; an effectful test declares the environment it needs.
Testing and the deploy gate
Section titled “Testing and the deploy gate”Three kinds of test, distinguished by what they need to run:
| Kind | Subject | Needs an environment? | Runs in |
|---|---|---|---|
| Unit | A calc function, a helper, a pure expression | No | The local interpreter, in-process |
| Characterization | A specific content-hashed revision’s observable behavior | No | The local interpreter, in-process |
| Save-order integration | A record-triggered automation across the full save pipeline | Yes — an ephemeral from_repo environment |
The kernel |
Unit tests are ordinary typed test files. Because the pure tier has no I/O, no clock, and no mutable reads, a calc-function test needs no environment, no fixture data, and no network — caos test run is offline and millisecond-scale — every case it evaluates runs against a component’s pure body, so there is no other tier for a flag to select.
Characterization tests are the golden input → expected output assertions the calc-function quality gate requires, stored with the revision and pinned to its content hash. In the project they are a cases array in the function’s own component body — caos project init scaffolds components/calc/markup.calc_function.json carrying two — so the assertions sit inside the content hash and travel with the code they describe. A fn@v2 that changes an output fails a prior assertion rather than shipping silently.
Save-order integration tests need the kernel, and they get an environment declared exactly like any other: origin: from_repo, lifecycle: ephemeral with a TTL, torn down automatically. The environment is a component in the repository, so the CI environment is reproducible from the commit under test rather than a long-lived shared environment that drifts.
A test case is a component
Section titled “A test case is a component”As of 2026-09-10 the test_case component type ships. The kernel validates each kind and resolves subject and environment through the reference gate at deploy; caos test run runs characterization and pure cases offline; caos test run-integration runs integration cases, each environment in an org it provisions for the run and deletes after it. Not built: selecting the tests in a diff’s blast radius (a pattern or --suite narrows a run by hand), and server-side enforcement of an environment’s ttl, so a run killed between provision and delete leaves its org behind.
All three kinds are the same component type — test_case — and it is a component rather than a loose file for one reason: the gate below is a query over the static dependency graph, and a test that is not in the graph cannot answer “is this component reached.” Making tests ordinary components means the pipeline that orders a deploy, computes a blast radius, and refuses a referenced delete is the same machinery that knows which test covers what.
It lives under tests/, at a path that is a pure function of its key and type, exactly like every component under components/. It is registered against what it exercises by a declared subject list of component keys — not inferred from a naming convention, not from directory position, not from what it imports at run time. A subject key that does not resolve is a deploy-class error, so renaming a component breaks its test’s declaration loudly and in the same change set.
A kind field determines two mechanical things — whether the case needs an environment, and where its assertions are stored:
kind |
Needs an environment | Assertions live in |
|---|---|---|
pure |
No | The extracted .test.ts beside the declaration |
characterization |
No | body.cases — input → expected data, packed into the subject at apply |
integration |
Yes — names the ephemeral from_repo environment it needs |
The extracted .test.ts |
characterization is the one whose storage differs: its assertions are data, not code, so at retrieve and apply they are packed into the subject component’s body and contribute to that component’s content hash. That is what pins a golden set to the exact revision it describes.
Deployment is not optional and there is no test-free target: a test case is metadata, so an environment either holds the tests that describe its behavior or it holds a change set that did not pass the gate. A test suite is a named, ordered set of test-case keys — the unit caos test run targets, the unit a package exports so an installing org can run the publisher’s tests against its own environment, and the unit a CI job names when it wants a subset.
The gate
Section titled “The gate”The gate at apply-to-an-environment has two mandatory parts and no coverage percentage.
Part one — static checks, unwaivable. Type-soundness of every component in the change set, purity of every pure-tier body, and the pure-may-only-call-pure rule, enforced at registration. These run in the pipeline’s validate phase and cannot be skipped by a flag, a role, or an emergency.
Part two — per-diff test reachability. Every declared test whose subject is in the change set must pass, and every component in the change set must be reached by at least one passing test. Reachability is computed from the same static dependency graph that orders the deploy, so the gate is a query over a graph the pipeline already builds, not a separate instrumentation pass.
Per-diff reachability replaces an org-wide coverage floor for a precise reason: a coverage average says nothing about the change being deployed. A change to the one untested component in a 90%-covered org would pass a floor; a fully-tested change in a 74%-covered org would fail it. Reachability asks the question an engineer actually cares about — is the thing I am changing exercised by a test? — and asks it about the diff. For pure logic it adds nothing on top of the static proof, which is why the pure tier’s gate is the characterization set; for effectful automation, which is not statically provable, reachability is the procedural safety net a percentage was a poor proxy for.
Static analysis is part of caos project check and part of the PR gate, not a separate product to adopt. Rules are declared in the repository alongside the components they judge, and a rule violation is an ordinary error envelope with a code — filtered, reported, and (unlike a part-one static check) waivable with a recorded actor and reason.
Version control and CI
Section titled “Version control and CI”Trunk-based, not branch-per-environment. An environment is a component in the repository. If environments were branches, the environment definitions themselves would fork, and the differences between test and prod would live in merge history instead of in a declared policy. On a trunk, what differs between environments is the environment component and its data policy, and every environment is built from the same commit.
- Short-lived branches, one pull request each, merged to trunk. Long-lived release branches are not used; unfinished work is gated in the product, not in version control.
- Every environment is at a named commit. Because component identity is content-addressed, “production is at
a4f1c…” is an exact statement about every component in it, verifiable by hashing. - Promotion is the same commit applied to a different target — never a cherry-pick, never a separately-built artifact.
What a pull request checks
Section titled “What a pull request checks”Six checks, all of them caos invocations, all exit-code-driven:
caos project check— path-from-key correctness, round-trip hash equality, type-soundness, purity and static analysis on the projection, and everysrc/body held to its metatype. Entirely local; no environment; seconds.caos types check --org <pr-env>— the committed types still match the target org’s current shape.caos metadata validate --org <pr-env>— phases 1–3 of the pipeline against an ephemeral environment provisioned from the branch: type-check, tier enforcement, auto-resolve ordering, a simulated apply. No writes.caos test run— the golden cases authored on the project’s pure-tier bodies, and each pure test case’s.test.ts. Offline; no environment. A golden case authored on an effectful body is reported as unsupported rather than skipped.caos test run-integration --yes— the save-order integration tests, each declared environment in an org provisioned for the run and deleted after it. A pattern or--suitenarrows the run to the tests whose subjects fall in the diff’s blast radius; the verb does not compute that set itself.caos metadata diff --format json— posted to the pull request as the human-readable component diff, so a reviewer sees “this field’s scale changed from 2 to 4,” not a raw text hunk.
Check 3 leaves behind a validated plan that stays valid while the target state it was validated against is unchanged — validity is keyed to the target’s content hash, not to a calendar window. So the merge does not re-do the work: if the target has not moved, the apply reuses the plan; if it has, the pipeline revalidates and says so.
How a deploy is triggered
Section titled “How a deploy is triggered”- Merge to trunk applies to the development environment automatically. No click.
- Promotion to test and production is an explicit
caos metadata deploy --org prod --yes --allow-destructivefrom a protected runner holding credentials no developer laptop has, and it runs only where prod’s environment policy permits automated writes. A production apply is a decision, not a consequence of merging:--yesclears the routine confirmations so the runner is unattended, and--allow-destructiveplus the permissive policy are what let the irreversible subset through. - The apply is the same command a developer runs locally, with a different
--org. There is no CI-only code path, which stops the class of bug where the pipeline and the local tool disagree about what a deploy means.
Because the apply is transactional for the DB-enclosable layers, a failed promotion leaves production exactly as it was, and recovery is re-applying a prior commit rather than reconstructing state.
Debugging
Section titled “Debugging”Every request, save, deploy, and CLI invocation carries a correlationId, and it is the same id across the error envelope and all three log streams. The debugging loop is built entirely on that one join key.
Read what happened. caos logs --correlation-id <correlationId> lists the org’s unified log for that transaction — metadata audit, data history and execution entries, joined on the id — and caos logs show <correlationId> prints every one of those entries in full. caos audit list --correlation-id <correlationId> narrows to the configuration changes. A record’s own field history is caos history list --object <object> --record-id <recordId>, keyed by the record rather than by the transaction.
Read it in full. caos trace show <correlationId> returns the execution trace for one transaction — the summary plus, where it was captured, the verbose per-statement detail. This is the verb to reach for when the list view has told you which transaction and you need to know what it did.
Hand it to someone. caos trace export <correlationId> writes a self-contained bundle of every stream for one correlation id — execution summary and verbose, metadata audit, and FLS-scoped field history, joined on the shared envelope — so it is walkable end to end offline. A reproduction is a file, not a set of instructions.
There is no re-execution verb, deliberately. caos trace replay was removed (CAOS-957) rather than left unbuilt. Re-running a recorded transaction was never specified, and the obvious implementation would have re-performed that transaction’s writes for a caller who holds only the diagnostics.trace read permission — a debugger that quietly mutates production is worse than no debugger. The binary still recognises the name so it can refuse it and name the two verbs above, rather than reinterpreting the argument as something else.
Two honest bounds on what the bundle can tell you:
- Statement-level fidelity requires the verbose trace, which is sampled by default because per-statement capture is genuinely expensive. The always-on summary carries the transaction’s shape and its pinned inputs; a line-by-line walkthrough needs verbose capture raised for a scope and a window first, and cannot be recovered after the fact for a transaction that was not sampled.
- A callout shows what it returned, not what it would return. A bug in how a response was handled is visible in the record exactly as it happened; a bug that only appears for a different response is not, because nothing re-drives the external system. That is the correct trade for a debugging surface that is safe to point at production data.
The agent surface
Section titled “The agent surface”Natural language is another on-ramp onto the same surface, not a parallel one — the same relationship the builders and the CLI have to the engine. A field picker, a saved snippet, and a natural-language request each generate the same readable code a person could have typed by hand, and what they produce can be opened and edited directly. The developer-facing consequence is narrow and concrete.
- Generation produces components, never a hidden intermediate. An assistant that drafts a validation rule emits the same canonical component a human would have written — diffed, reviewed, type-checked, tier-checked, and gated identically. Nothing generated bypasses a check that authored code passes.
- The read-only verbs are the agent’s tool surface.
metadata retrieve,metadata diff,access explain,automation show,logs list, andtrace showare the same read-only commands a person runs, with the same JSON contract, so an agent inspecting an environment uses documented operations under the caller’s own permissions — not a privileged side channel. - The writing verbs are not delegated by default. Every verb marked ✎ or ⚠ above is gated regardless of who or what invoked it, and the gate has two independent halves rather than one. The routine half is cleared by
--yesor a terminal confirmation; the environment half is a policy on the target and--yesnever supplies it, so an irreversible verb additionally needs--allow-destructiveagainst an environment whose policy permits automated writes. The two halves are separate on purpose: a verb may waive the routine prompt and still face the environment gate —package installandpackage uninstalldo exactly that, because a confirmation whose answer is always yes trains people to clear prompts without reading them. Every deploy lands in the metadata audit stream with its actor.
How this compares to Salesforce
Section titled “How this compares to Salesforce”The parts worth matching, CAOS matches: a Git-backed project with the repository as source of truth, noun-verb command grammar, structured JSON output with a stable envelope, --dry-run validation before apply, aliased multi-org authentication, decomposed VCS-friendly files, a first-class terminal test runner with machine-readable result formats, log tailing, and a unified static analyzer. Matching those is the floor.
Where the model diverges, it is because the surfaces sit on one engine rather than beside it:
- The CLI has no taxonomy of its own to churn. Nouns and verbs are kernel concepts, so a rename would be a kernel API break rather than a CLI redesign.
sfdx→sfwas possible because the CLI’s topic tree was a client-side layer; the equivalent move here has nothing to rename. - One engine format, and its correctness is provable. The
components/projection the local gate checks is a projection of the canonical component, not a second format converted at deploy, and content-addressed identity letscaos project checkprove the round-trip by hash equality rather than assert it. A component’s file location is computed from itskeyandtype, so misplacement is detectable rather than conventional, and decomposition is declared by every component schema instead of applying to a curated list of types. Until the two converge, thesrc/treemetadata deployreads is a second format beside it, gated by the sameproject check— see the on-disk project. - Generated types pinned to a metadata generation. Each object’s fields, their optionality and their primitive types come from the org’s real schema, and
types checkfails when the committed file no longer matches the org. Picklist unions, relationship traversals and read-only formula fields are planned — Verified 2026-09-24 — the emitter behindtypes generateproduces none of the three today. There is no first-party incumbent equivalent. - Conflict detection is a property of the model, not of the org type. Every environment carries component generations, so
metadata diffdetects conflicts in production, the environment where incumbent source tracking is typically absent. - The trace needs no advance capture. Every transaction carries a
correlationIdand an always-on execution summary, so a failure is investigable after the fact — no trace flag set beforehand, no checkpoint window, no one-log-at-a-time ceiling, no license. Reading it is safe against production data because the trace verbs only read. The bound above still applies: per-statement detail is sampled, so the summary is always there and the verbose walkthrough is not. - Environment-free unit tests. The pure tier has no I/O, so a calc-function test runs in-process in milliseconds rather than as an org round-trip.
- The gate asks about the diff. Unwaivable static checks plus per-diff test reachability replace an org-wide coverage average that can pass an untested change and fail a well-tested one.
- Static analysis is a pipeline phase, not a product to adopt. Rules live in the repository and violations are ordinary error envelopes with codes.
Costs and risks
Section titled “Costs and risks”The design’s honest trade-offs, stated rather than discovered:
- A closed noun set creates pressure to overload nouns. Every genuinely new capability must fit an existing noun or force a contract major. The failure mode is a noun that accretes unrelated verbs because adding one was cheaper than a major bump. Contract discipline has to be enforced by review.
- Committed generated types conflict on merge. Two branches that both touch the schema produce conflicting
types/diffs. Generation must be byte-deterministic and regeneration must be a one-command resolution, or the ergonomics invert. - Generation pinning fails builds for correct code. A checkout pinned to generation N fails
caos types checkthe moment the target activates N+1, even when nothing relevant changed, because the generation is part of the file. That is intended behavior and it is friction; the mitigation is that regeneration is one command and diff-visible. - Per-PR ephemeral environments cost real infrastructure. Environment creation is quota’d, so a large team’s pull-request fan-out is a capacity-planning problem, not a free consequence of the design.
- Per-diff reachability inherits the dependency graph’s correctness. It is the same graph that orders deploys and powers safe-delete; keeping it complete as component types are added is a standing obligation. An incomplete graph means the gate under-reports.
- One engine is a single point of failure. Sharing the validator across the builders, the CLI, and the pipeline removes the possibility of divergence and concentrates the blast radius: an engine regression touches every authoring surface at once. That is the correct trade, and it raises the bar for the engine’s own test suite.
- Trunk-based development requires product-level gating. Without a way to ship unfinished configuration in a disabled state, a trunk carries half-built work into every environment. The discipline is a prerequisite of the branching model, not an optional refinement.
Where this fits
Section titled “Where this fits”- Getting started — provision, sign in, and make a first change with the CLI, the API, or a package.
- How the platform works — the engine/UI separation these on-ramps sit on.
- The developer surface — the full map of what you author, including the setup surface that delivers the builders.
- Metadata & deploy — the canonical component model and the diff-validate-apply pipeline.
- Packaging — how metadata travels as a versioned unit and claims a capability.
- Pages & components — the UI metadata the builders author.
- The architecture site carries the settled reference for the CLI and the API families.