Per-command contract: flags, human table columns, and normalized JSON fields. Written before implementation so the commands are consistent by construction, not by luck.
A section or heading tagged with a release version ((v0.4), (v0.5), (v0.6)) describes a
planned command, not yet in the binary. See docs/roadmap.md for the shipping
sequence. Untagged sections are shipped.
Conventions used below
- JSON field names are ours, not the API's (D2).
PKnever appears in output. do/statusintegers never appear in output or input (D10).- Timestamps: API sends Unix seconds. We emit RFC-3339 in JSON, and relative ("3 days ago") in tables.
- Every command accepts the global flags.
- Every typed remote-mutation command accepts
-n, --dry-run. Semantics are defined once in Dry run. All argument values pass input hardening before any request is built. - Table columns are the default human view.
--jsonreturns the full object, and--fields a,bprojects it. An unknown field is a usage error (exit2), checked upfront (before any request, confirmation prompt, or dry-run plan), never data-dependent. A valid field on an empty result still prints[].--fieldsconflicts with--dry-run(usage error, exit2, checked upfront): a dry run prints the request plan, not data rows, so projecting row fields over it is meaningless.--jsonalone stays legal on a dry run (the plan is a JSON document). - Stable key set. Fields the API sends optionally are always present in our JSON,
nullwhen absent. Consumers never branch on key existence. Scope: resource, error, and creation-intent schemas (in a creation intent,nullmeans the field will not be sent, since a create has nothing to preserve, so no ambiguity). Patch maps (changesin dry-run plans) are sparse by definition: presence means the field will be sent, andnullinside one is an explicit clear (folder_id: null= move to root,via6: nullis not representable, since the API has no clear, see rule). Conflating the two would make merge updates unrepresentable. - Empty result:
[], exit0. Lists keep API order, andrule listsorts byorder. - Write output.
create/updateprint the normalized resource, anddeleteprints nothing. Upstream'smessagegoes to stderr as an info line. Identical in--json. Where the resource comes from is fixed per write, see Write output sources. - Errors are a JSON envelope on stderr in JSON mode, single-line
error: ...text otherwise. Multi-line upstream text collapses to one line in human mode, but the JSON envelope keeps it verbatim. Exit codes are identical in both modes.
Reused by rule, folder, service, and profile default. Defined once, here.
| Flag | Values | Maps to |
|---|---|---|
--action |
block | bypass | spoof | redirect |
do = 0 | 1 | 2 | 3 |
--via |
IP / CNAME (spoof), proxy PK (redirect) | via |
--via6 |
IPv6, spoof only | via_v6 |
--enabled / --disabled |
status = 1 / 0 |
All four parse as optional, with no clap-level defaults. A parser default would silently send
status=1 on every update (folder update 2 --name New must not re-enable a disabled folder).
Defaults are applied per command, after dispatch:
| Command | --action |
--enabled/--disabled |
Omitted flags |
|---|---|---|---|
rule create / rule import |
required | default enabled | via/group omitted from the wire |
rule update |
optional | optional | not sent, PUT /rules merges (verified) |
folder create |
optional, omit do for an action-less folder |
default enabled | do omitted from the wire |
folder update |
optional | optional | not sent |
service set |
required | default enabled | do/status/via always sent, and via_v6 merges (omitted = preserved, verified) |
profile default set |
required | default enabled | — (spec: both required) |
Rejected combinations: --via without --action spoof|redirect (including on updates, since the
action context is needed to validate it), and --via6 without --action spoof.
Shared syntax != shared capability. --via6 is accepted by rules and services only.
Service via_v6 write and read-back verified live
(write-verification). Folders and the profile default have no
documented via_v6 field, and D16 forbids building on undocumented ones:
--via6 on folder create/update or profile default set is exit 2.
"Print the normalized resource" needs a defined source, since several responses cannot support it alone. Sources: R (the response) or RB (an authoritative read-back). No write prints request-derived data as if the server confirmed it. Several responses are summaries that cannot prove what landed. Multi-target commands always print an array, one element per target, whatever the count, since cardinality never changes the shape.
| Write | Source | Why |
|---|---|---|
profile create |
R | full object in body.profiles[] |
profile update |
RB | the response can echo stale disable_ttl |
profile option set |
RB | no verified write response |
profile default set |
R | returns body.default |
rule create/update/import/restore |
RB | the response is a hostname-less one-entry summary. The mandatory verification read-back (rule) is authoritative and already paid for |
folder create |
R | full folder object |
folder update |
R | full folder object in the response (verified live, the spec's empty schema is wrong) |
filter enable/disable/set |
RB | the response is only a family-keyed {do,status,lvl} map ([] when empty). No titles, levels, or descriptions. Re-fetch filter list and print the affected families |
service set |
RB | neither response nor request carries category/locations/warning, but GET .../services does |
device create/update |
R | device object flat at body |
access add |
RB | the ack is body: [] plus a count message. The read-back window is the latest 50 IPs, hence the 50-IP cap (access) |
Validation, client-side, before the request:
--viarequired when--action spoofor--action redirect, rejected otherwise.- With
--action redirect,--viamust be a known proxy PK. Validate againstGET /proxiesand, on failure, print the nearest matches. (LONis not a proxy,LHRis.) - With
--action spoof,--viamust parse as an IP or a hostname. --enabled/--disabledis orthogonal to--action.--action block --disabledis legal: a disabled block rule.
Typed remote-mutation commands accept -n, --dry-run (the clig.dev standard flag): every
typed remote mutation, with rule import and rule restore joining in v0.4. Local-state commands
(auth login/logout, config set) do not take it, and cdctl api is excluded. The escape
hatch is gated by D9 (-X + --yes) instead.
-
Resolve and validate everything, persist nothing. Names resolve to ids,
--viais checked againstGET /proxies, option values against their type, import quota and cross-folder collisions against the fetched rule set. Read-only API calls are allowed. Forbidden: any mutating request, any config or credential change, any file creation. A--force-delete-firstdry run prints the manifest path it would write and does not create it. -
Output is data on stdout, schema-stable like every other JSON output (semver-governed, D2). The plan is normalized intent, not wire bytes: action names and booleans per D2/D10. Encoding integers (
do,status) never appear here either (identity integers like a folder id do). Wire encoding (form ordering, bracket keys, scalar-params-first) is D11's contract, proven by v0.1's request-body tests and visible under--debug, and the plan does not restate it.requestslists the withheld mutations, in send order. Validation GETs execute normally and are not listed:{ "requests": [ { "method": "POST", "path": "/profiles/pr1/rules", "intent": { "hostnames": ["a.com", "b.com"], "action": "block", "via": null, "via6": null, "enabled": true, "folder_id": null } } ] }Creates plan the normalized creation intent: exactly the fields the CLI will send, plus CLI-owned defaults (e.g.
enabled: truewhere a command defaults it), but never server-generated fields (ids,order, timestamps, resolver addresses, clone results), which do not exist until the write happens and which normal write output is forbidden to fabricate. Each family's key set is fixed and part of the contract.nullmeans the field will not be sent (a create has nothing to preserve, sonullis unambiguous here, unlike in patches):Create Intent keys profile create{name, clone},clone= the resolved source profile id, elsenullrule create{hostnames, action, via, via6, enabled, folder_id},enabled: trueis the CLI-owned defaultfolder create{name, action, via, enabled}, action-less folder =>action: nulldevice create{name, profile_id, type, clients, analytics},profile_idresolved, flataccess add{device_id, ips}service set,profile default set,profile option set, andfilter enable/disable/setsend complete state, with one verified exception: servicevia_v6merges upstream, so it appears in the intent only when--via6was given (service). Their plans are full intents, never patches:{service, action, via, via6, enabled},{action, via, enabled},{name, enabled, value},{levels: {"<level-or-family>": true|false}}. Merge-style updates (rule update,folder update,device update,profile update, ...) plan a patch instead: a sparsechangesmap holding exactly the fields the run would send: presence is meaningful, omitted fields are preserved by the server's verified merge, andnullis an explicit clear (folder_id: null<---root):{ "requests": [ { "method": "PUT", "path": "/profiles/pr1/rules", "intent": { "hostnames": ["x.com"], "changes": {"enabled": false} } } ] }Each patch family's
changesvocabulary is likewise contractual:Patch Legal changeskeysrule updateaction, via, via6, enabled, folder_id,folder_id: null= root,via6: nullunrepresentable (rule)folder updatename, action, via, enabledprofile updatename, disabled_untildevice updatename, profile_id, analytics, statusDeletes plan the resolved target. A DELETE sends no body, so the intent identifies what would be removed (normalized, like read output) plus what the deletion cascades to:
{ "requests": [ { "method": "DELETE", "path": "/profiles/pr1/groups/2", "intent": {"id": 2, "name": "Ads", "rules": 4} } ] }Delete Intent keys rule delete{hostname}, one request per hostname, one entry eachfolder delete{id, name, rules},rules= contained rule count, deleted with the folderprofile delete{id, name}device delete{id, name}access remove{device_id, ips}rule import/rule restoreprint a domain plan instead, discriminated byoperation:{ "operation": "import", "profile_id": "pr1", "folder_id": 2, "add": ["<rule>"], "converge": ["<rule>"], "delete": ["<rule>"], "quota": { "current": 120, "peak": 680, "final": 620, "cap": 10000 }, "manifest": null }{ "operation": "restore", "profile_id": "pr1", "source": "./cdctl-restore-pr1-1720713600.json", "add": ["<rule>"], "converge": ["<rule>"], "skipped": ["<rule>"], "quota": { "current": 120, "peak": 170, "final": 170, "cap": 10000 } }Rule entries use the normalized rule schema and carry their own
folder_id. Restore is not folder-scoped, so no top-levelfolder_idappears on it, but import's is the scope (null= root). Restore classifies manifest entries by full-state diff, exactly like import:skippedholds normalized rules already matching the entire desired tuple, and anything else converges or is added.quota.peakis the largest concurrent total the plan can reach: add-first--replacepeaks before its deletions, delete-first peaks lower, and the cap check runs againstpeak.finalis the end state.manifestis the would-be path under--force-delete-first, elsenull. Human mode renders the same fields as text (D4). -
Exit
0on a valid plan. Validation failures exit with their normal codes. A hallucinated parameter fails locally, with the same error the real run would produce, before it reaches the API. An import with cross-folder collisions exits6here exactly as the real run would. -
Confirmation is skipped. Nothing mutates, so no prompt, no
--yes, no--confirm=<name>.
Agents hallucinate inputs humans never type: embedded query fragments in ids, pre-encoded
strings, invisible characters. Reject the known shapes with exit 2 before any request is
built:
- ASCII control characters (below
0x20, plus0x7F) in any argument value, plus the C1 controls (U+0080-U+009F), matching the output-escaping stance. - In values placed into URL path segments (hostnames on
rule delete, service names, filter level names, option names, resolved folder/profile/device ids): additionally reject?,#, and%: embedded query fragments and pre-encoded input (a passed-in%2Awould double-encode). The error hint states that values are taken literally, never pre-encoded. Wildcard*stays legal in hostnames, since it is rule grammar, percent-encoded at the HTTP layer. - Non-ASCII hostnames on
rule create/update/deleteargv are rejected with exit2and a hint to supply the punycode (xn--) form.
Percent-encoding into paths stays the load-bearing defense. Rejection exists to turn a confusing
404 (exit 3) into a precise exit 2 with a usable hint. Form-bound values (names, --value)
need only the control-character check, since form encoding transmits the rest safely.
cdctl api is exempt: its path and -F pairs are forwarded raw by design, and the escape hatch must
not reshape what it carries. It is gated by D9 instead.
| Command | Flags |
|---|---|
profile list |
— |
profile get <id|name> |
— |
profile create <name> (v0.5) |
--clone <PK> |
profile update <id> (v0.5) |
--name <s>, --disable-until <time>, --enable |
profile delete <id> (v0.5) |
--confirm=<name>, --yes alone is never enough |
profile option list (v0.5) |
— |
profile option set <name> (v0.5) |
--enabled/--disabled, --value <v> |
profile default get (v0.5) |
— |
profile default set (v0.5) |
action flags |
Table: NAME, ID, RULES, UPDATED. RULES shows the enabled count
JSON: {id, name, enabled_rules, enabled_filters, enabled_services, folders, options, default_action, enabled, disabled_until, updated}
The counts come from profile.*.count, which tracks enabled items only. The list endpoints
return more (observed 16 vs 18). The enabled_ JSON names carry that semantic. The table keeps just
RULES so the misleading pair stays out of the default view.
default_action is profile.da. The spec types it [] when unset, but live it is always an
object, even on a freshly created profile. Tolerate both. Normalize [] -> the implicit default
(bypass, enabled).
There is no boolean disable. The API field is disable_ttl, a deadline: disabled until a
unix timestamp, 0 re-enables. Hence --disable-until <time> / --enable, and no disable on
create. Readback shape-shifts (disable: null <-> disable_ttl: <ts>). Derive
enabled/disabled_until from it. The PUT response can echo stale state. Re-read after writing.
profile get is a client-side filter over GET /profiles. The API has no GET /profiles/{id}.
Human mode: key/value lines over the full JSON field set, not the list table.
profile option list (v0.5): table OPTION, TITLE, TYPE, DEFAULT.
JSON {name, title, description, type, default, info_url}. type is an open enum: live values
are toggle, field, and dropdown (the third is absent from the API docs). Render unknown types
as-is, never error. default is raw JSON: an integer for toggles/fields, an object map
({"0.9": "Minimal"}) or a bare label array (ecs_subnet) for dropdowns. Never assume a shape.
profile option set <name> --enabled|--disabled [--value <v>] (v0.5): the API write takes a
required status plus an optional value. A single positional value cannot express
enable/disable/select unambiguously. Validate --value per type against the live catalogue
(field -> number, dropdown -> a key of its map). {name, value, enabled} via read-back (no verified write
response exists).
profile default get/set (v0.5): one row, ACTION, VIA, ENABLED. JSON {action, via, enabled}.
| Command | Flags |
|---|---|
rule list |
--folder <id|name> |
rule create <hostname>... |
action flags, --folder <id|name> |
rule update <hostname>... |
action flags, --folder, --root |
rule delete <hostname>... |
--yes |
rule import <file|-> (v0.4) |
action flags, --folder, --replace, --force-delete-first |
rule restore <manifest> (v0.4) |
--force (accept a profile mismatch) |
Table: HOSTNAME, ACTION, VIA, ENABLED, FOLDER
JSON: {hostname, action, via, via6, enabled, folder, folder_id, order}
hostname<-PK. May be a wildcard (*.example.com).folder_id<-group.0normalizes tonull(a "no folder" sentinel, not folder zero).folderis the resolved display name, informational only. Folder names are not unique, so anything that must identify a folder exactly (manifests, scripts) usesfolder_id.rule update --rootmoves rules back out of a folder:group=0onPUT, verified live. Mutually exclusive with--folder.rule listomits the folder segment. Passingfolder_id=0404s. See read-verification section 3. That segment-less listing returns root rules only: a rule inside a folder is invisible on it.rule listwith no--foldertherefore aggregates the root listing with one listing per folder (GET /profiles/{id}/groupsfor the folder ids) into one client-side result, sorted byorder, not a single request.- A missing
action.dois an error, not a default. Never coerce to0(= BLOCK). rule updatesends only the flags given:PUT /rulesmerges, preserving omitted fields (verified live), sorule update x.com --disabledneeds no--action.rule updatenever creates a rule:PUT /rulesdoes not upsert (probed live: an unknown hostname 400sCustom Rule does not exist, write-verification). Everyupdate/deletetarget is resolved against a fresh profile-wide listing before anything is written, never a bare existence check. An exact case-sensitive match wins outright: the server matchesPUT/DELETEtargets case-sensitively too, and case variants can coexist as distinct rules (write-verification). Else, a unique case-insensitive match is accepted, with aninfo:line naming the substitution (human mode only, since the JSON document already carries the stored PK, and JSON-mode stderr stays envelope-only). Else, 2+ case-insensitive matches with no exact one is a usage error (exit2) naming every variant, with a hint to retype the target with its exact stored case fromrule list. Else, the target doesn't exist at all: exit3,rule.not_found, naming every missing hostname, not just the first, rather than surfacing whatever generic error the failedPUTwould otherwise produce. Two different inputs that resolve to the same stored PK is a usage error too (exit2, naming both inputs and the PK). An explicit ambiguity beats a silent duplicate write against one rule.via6cannot be cleared while the spoof action persists, probed: omission preserves it, empty string and0are rejected atomically, and only an action flip clears it (an unprotected windowcdctlnever enters implicitly, write-verification).rule updatetherefore offers no clear-via6operation, and a patch ofvia6: nullis unrepresentable. The one spelling that requests it —--via6=(the attached empty value, unambiguous across POSIX and Windows shells) — is rejected as an attempted unsupported clear: exit2, hint carrying the delete + recreate remedy, never forwarded upstream (where it 400s anyway, fixtureerr_via6_clear.json).- Argv hostnames are canonicalized (one trailing dot stripped) and deduplicated before any
request: same rule as
rule import's file lines, applied tocreate/update/deletepositional hostnames too.createalso lowercases: canonical creation, so every latercdctl-issued lowercase target keeps matching the rule it just made.update/deletedo not: the server stores and matches those targets case-sensitively, so lowercasing here would make a foreign client's mixed-case rule unreachable. The resolution above is what still lets a lowercase input reach such a rule. - Multi-target cap: 500 hostnames per
rule create/rule updateinvocation: the import chunk size, safely inside the server's silent ~1001-form-var ceiling (D11). More is exit2with a hint torule import(resumable, quota-aware). Whatever the count, read back and verify every target landed in the desired state before printing it. The write response is a one-entry summary that cannot reveal a dropped hostname. The read-back is the same profile-wide aggregationrule listdoes (root plus every folder), never just the root listing. A rule that landed in a folder must still be found. A spoofvia/via6compares case-insensitively against the desired state, since DNS names are case-insensitive and the API is unversioned, so the comparison does not lean on the server's case-preserving storage continuing (write-verification). A redirectviais a proxy PK, an exact identifier, and compares case-sensitively. - Partial failure is per-target, inside the error envelope. When a multi-target
create/update/deletefails partway, or verification finds a gap, the D4details(kind: "multi_target") carries ordered per-target entries and a shell-neutralretry_argvarray covering only the missing targets. It must exclude already-landed hostnames, since a duplicate in aPOSTfails its whole chunk. Exit by D4's aggregation rule:8only when every failed target is retryable, else the shared terminal code (or1when mixed). stdout stays empty.
rule delete must percent-encode the hostname into the path, wildcards included.
Input: one hostname per line. # comments and blanks skipped. Hosts-file format accepted
(0.0.0.0 ads.example.com -> ads.example.com). Hostnames are canonicalized (lowercased, trailing
dot stripped) and deduplicated within the file. A line that is not a plausible hostname fails the
whole import with its line number before anything is written. --dry-run prints the
add/converge/delete plan and exits 0.
- Scope. An import targets exactly one folder:
--folder <f>, or the root rules when omitted. The diff, convergence, and--replacedeletions operate within that scope only: rules in other folders are never read into the plan, never converged, never deleted. There is no whole-profile replace in v1. Run once per folder. - Quota first, and profile-wide. Profiles cap at 10,000 rules across all folders
(enforced: a crossing batch fails whole). Fetch the whole profile's rules once: the root
listing (
GET /rules, segment omitted) unioned with oneGET /rules/{folder_id}per folder (ids fromGET /groups). Entries carrygroup, so the folder subset for the diff falls out of the same aggregate fetch. Compute the budget from that full set, never fromprofile.rule.count(it counts enabled rules only) and never from the folder subset: 9,900 rules in other folders are invisible to a scoped fetch yet count against the cap. Under--force-delete-first, recomputepeakandfinalonce the deletion set is known, before the first DELETE. The cap check runs againstpeak(dry run defines both). Fail fast with the shortfall. Large blocklists belong in Control D's native filters, and the error says so. - Diff -> converge -> add. The scope's subset comes from the profile-wide fetch above. Rules in
other folders inform the quota but never enter the plan. Hostnames already in the desired state
are skipped. Hostnames present with a different action, state,
via, orvia6are converged withPUT(chunked, the merge is verified). Skipping them as "duplicates" would mean import never reaches the state the file declares. New hostnames are added withPOST. A duplicate in aPOSTchunk atomically fails the chunk, which is why the diff is not optional. - Cross-folder collisions fail fast, exit
6. Hostnames are profile-unique.DELETEaddresses a rule with no folder segment andPUTmoves one between folders, so a file hostname that already lives in a different folder cannot converge in scope without mutating that other folder, and the contract promises other folders stay untouched. Refuse the whole import before any request: the error's D4details(kind: "collisions") lists every collision as{hostname, folder_id, folder}, nothing is written (the profile-wide fetch makes this decidable up front, and--dry-runreports the identical failure). - Unconvergeable states fail fast too, exit
6. A file/manifest rule withvia6: nullwhose live counterpart hasvia_v6set cannot be converged. The API has no clear operation for it (write-verification). Decidable from the same pre-mutation diff: refuse withdetails(kind: "unconvergeable", entries{hostname, reason: "via6-clear", current_via6, desired_via6}, the live value included, so a caller decides without another fetch). The hint separates the two real choices. They are not equivalent. (a) Accept the livevia6, this revises the desired state, it clears nothing:--via6 <value>only when every conflict shares one live value (--via6is command-wide, a hostname file has no per-line fields), else separate per-value imports, individualrule updates, orrule restore(per-rulevia6in the manifest). (b) Destructive clear, the only route tovia6: null: delete + recreate, which the hint spells out with the resolved--profile <id>, the folder, and the full desired action,via, and state (recreating in root or with partial flags would be worse than the mismatch), plus--yesand the protection-gap warning (the rules are absent between the two commands).cdctlnever takes route (b) implicitly. A future--moveflag may opt in. An implicit cross-folder move never happens. (What a cross-folder duplicatePOSTwould do upstream is unprobed, and irrelevant: the plan refuses first.) --replaceadds first, deletes last. Deleting up front would leave the profile unprotected if anything then fails. So: converge and add (all verified), and only then delete the in-scope rules missing from the file,⚠️ one request per deletion (no bulk delete). Deletions state the exact count and go through therule deleteconfirmation tier (prompt on TTY,--yesin scripts). A failure at any point leaves a superset of the previous rules, never a gap. Re-running converges. When old + new cannot fit inside the quota together, fail with the shortfall: the only recourse is--force-delete-first.--force-delete-first(only with--replace) accepts the unprotected window explicitly. Before the first DELETE it writes every doomed rule to a restore manifest —./cdctl-restore-<profile-id>-<timestamp>.json(the opaque id, not the profile name, which would need filename sanitization) — mode0600, refuses to overwrite, fsynced before the first delete, path printed, never auto-deleted. The manifest is versioned JSON:{"version": 1, "profile_id": "...", "rules": [<normalized rule objects>]}. Rules carryfolder_id, so restore stays exact when folder names collide, and heterogeneous actions round-trip (a plain hostname list cannot).rule restore <manifest>(v0.4) is a separate command: no positional file and no--action(each rule carries its own), so it doesn't collide with the import grammar. It refuses a manifest whoseprofile_iddiffers from the target profile unless--forceis given, and refuses unknownversionvalues. Recreates action, state,via,via6, and folder exactly. Idempotent by full-state diff, not by hostname: a manifest entry is skipped only when the live rule already matches the entire desired tuple. A hostname present with any difference converges viaPUT(the verified merge, since a user may have recreated, moved, or edited it since the manifest was written). A missing hostname is created viaPOST, neverPUT, sincePUTdoes not upsert (write-verification: an unknown hostname 400sCustom Rule does not existand nothing lands). Chunked at 500 with the same per-chunk and final full-state verification as import.- Chunks of 500, scalar params first, verify the full desired state. After each chunk,
re-fetch and assert the chunk's hostnames are present with the intended action, enabled state,
via,via6, and folder. Membership alone can miss a dropped trailing scalar (a truncatedstatus=0silently defaults to enabled). A raw 200 is meaningless (the server silently drops form variables past ~1001), and a total-count check is insufficient. A concurrent add elsewhere can mask a dropped hostname. After the last chunk, run one final full-scope verification so a concurrent mutation of an earlier chunk cannot escape detection. On mismatch, abort with a resumable report (write-verification). - On mid-import failure: report chunks written, chunks remaining, and the exact re-runnable command. Re-running is safe. The diff step makes import idempotent and convergent.
| Command | Flags |
|---|---|
folder list |
— |
folder create <name> |
action flags (optional, omitting --action creates an action-less folder, verified live) |
folder update <id|name> |
--name, action flags |
folder delete <id|name> |
--yes |
Table: NAME, ID, RULES, ACTION, ENABLED
JSON: {id, name, action, via, enabled, rules}
idis an integer here, unlike every other resource. Folder names are not unique. Resolving a name to multiple folders is an error, not a coin-flip. The{folder}path segment must be that integer: a name in the path 404s upstream (This folder does not exist, verified live), which is why name resolution is strictly client-side.folder updatesends only the flags given:PUT /groups/{id}merges (verified live: rename-only preserves action and status, status-only preserves the rest, and an action-less folder survives a rename without gaining ado, though the spec wrongly marksdo+statusrequired, write-verification).- Folders may have no action at all (
{"action":{"status":1}}with nodo), soactionis thennull, which is legal and distinct fromblock. - controld-go sleeps 2s after creation, defensive, not evidence: no lag was observed live, and
the create response already returns the folder id. Don't sleep, don't poll. If lag is ever
demonstrated, poll
GET /groupsfor the returned id with a bounded timeout, never by name (names are not unique).
| Command | Flags |
|---|---|
filter list |
--external |
filter enable <name> |
— |
filter disable <name> |
— |
filter set <name>=<on|off>... |
(batch, the one JSON write) |
Table: FAMILY, TITLE, ENABLED, ACTIVE, LEVELS
JSON: {family, title, description, enabled, active_level, levels: [{name, title}], third_party}
- One object per family (
family<-PK).active_levelis the enabled level's name.nullwhen the family is disabled or has no level system.enabledis the family's own status and is what distinguishes those two cases. levelslists the legal selections and is always[]when the family has none: most families (noai,dating, ...) and every third-party filter. Arrays are nevernull. The stable-keynullconvention covers scalars.- What
enable/disable/settake: for a leveled family, a level name fromlevels[](ads_small,ads_medium,ads,porn_strict: the suffixes follow no derivable rule, never construct names). A level-less family is addressed by its family id. It has no other name.⚠️ Level-less writes are unprobed (Open). Leveled writes are verified on both endpoints. - Level names are valid on both write endpoints (verified live). Enabling a level of an
already-enabled family swaps atomically, since levels never stack, so
filter enableneeds no disable-first step. - The write response is a map keyed by family (
{"porn": {do, status, lvl}}), and[]when zero filters remain enabled: the object<->array flip is by emptiness (write-verification). filter listmust make the legal level names obvious. This is the most confusing part of the API surface, and clearing it up is the CLI's job.- Third-party filter PKs are
x--prefixed. Setthird_party: true. PUT /profiles/{id}/filtersis the onlyapplication/jsonwrite in the API.
| Command | Flags |
|---|---|
service list |
--category <c> |
service set <service> |
action flags |
service categories |
— |
service catalog <category> |
— |
Table: SERVICE, NAME, CATEGORY, ACTION, ENABLED
JSON: {service, name, category, action, via, via6, enabled, unlock_location, locations, warning}
unlock_location is always present. warning and locations are optional and absent on some entries. service catalog requires a
real category PK (audio career finance gaming hosting news recreation shop social tools vendors video). A bad one 404s with Invalid category. Validate client-side and suggest.
service set --disabled keeps the rule listed (disabled): the API has no way to remove a service
rule. A bad service name is 400 Invalid service was provided. Validate client-side and suggest.
via_v6 on services behaves exactly like rules
(write-verification): omitted = preserved (the PUT merges
it), --via6= is the rejected clear attempt (exit 2), and only an action transition clears it.
Since services cannot be deleted, the destructive clear is the explicit two-step flip
(service set <s> --action bypass, then re-set spoof with --via only). The hint spells it out
with its unprotected-window warning, never implicit. Read-back verification asserts via6 only
when --via6 was given.
service categories: table CATEGORY, NAME, SERVICES. JSON {category, name, description, count}.
service catalog <c>: table SERVICE, NAME, UNLOCK. JSON
{service, name, category, unlock_location, locations, warning}, no action: this is the global
catalogue, not the profile's rules.
| Command | Flags |
|---|---|
device list |
— |
device get <id|name> |
— |
device create <name> (v0.6) |
--profile <p> (required), --type <t>, --clients <n>, --analytics <none|some|full> |
device update <id|name> |
--name <s>, --enforce <PK|name> (profile to enforce), --status <active|soft-disabled|hard-disabled>, --analytics <none|some|full>, -n |
device delete <id> (v0.6) |
--confirm=<name>, --yes alone is never enough |
device types (v0.6) |
— |
Table: NAME, ID, PROFILE, STATUS, CLIENTS, CTRLD
JSON: {id, name, profile: {id, name}, status, analytics, clients, learn_ip, icon, ctrld: {version, last_fetch} | null, resolvers: {doh, dot, v4, v6}}
device get prints the same fields as key/value lines (ctrld_* and resolver_* flattened).
idis the API'sdevice_id: the identifier its own paths (/devices/{device_id}),resolvers.uid, and analytics key on.PKis observed equal to it in every response but never documented as such, so normalization treatsPK != device_idas an upstream shape error (exit8) rather than silently picking one.statusis four states, not a boolean:pending | active | soft-disabled | hard-disabled(API ints 0-3, never exposed, D10).pending= created, has never made a DNS query (not yet protected, not paused). The two disabled modes differ materially: soft serves plain unfiltered DNS, hard serves none.analytics<- the API's optionalstatsints 0-2, named after its ownanalytics levelscatalogue (No/Some/Full Analytics):none | some | full.nullmeans the API did not report a level, which is distinct from"none"(reported as off).profileis the enforced profile as{id, name}. The undocumented second profile (profile2, api-contract) is not surfaced (D16).resolverscarries the device's resolver identities verbatim:doh,dot, and the dedicatedv4/v6addresses.v4/v6are[]when the API omits them, never absent. The documented optionallegacy_ipv4,restricted,desc, andddns/ddns_extfields are not surfaced yet (additive).ctrld,icon,analytics, andclientsare optional:nullin JSON,-in tables.ctrldis the daemon's undocumented self-report on that endpoint, so itsversionandlast_fetch(RFC-3339) are each nullable too. Itsstatusint has no documented meaning and is not surfaced.clients<-client_count, which the response schema does not declare.learn_ipis a boolean (0/1upstream; any other code is a shape error).device getis a client-side filter overGET /devices. AGET /devices/{id}exists live but is undocumented (D16).
device update patches only the given fields (at least one required) and never trusts the
PUT echo: the response can carry stale state (the pre-switch profile, probed live), so success
is a fresh GET /devices whose written fields all match; a mismatch is device.unverified,
exit 8 (the patch is idempotent, re-running converges). Notes:
- The enforced profile is written with
--enforce, not--profile: the global--profileis the profile-scope flag, anddevice updaterejects it outright (exit2) rather than silently dropping part of what was typed. Names cap at 32 characters (API limit, checked before any request). - There is no
--enabled/--disabledalias (a boolean cannot say which disable it means), andpendingis not offered as a--statusvalue: the API accepts writing any state on apendingdevice (probed live), but un-using a device is not a state a user can mean. - Writes are PUT-only:
statusis not accepted onPOST /devices.
Writes (v0.6): --status active on a pending device is rejected client-side (the flip
happens on the first DNS query, not via the API). --type maps to the API's icon field (the
icon key is the device type); --clients to client_count. Live, only name and --profile
are enforced; a duplicate name is a conflict (exit 6). device types returns a nested dict
(os/browser/tv/router -> icons), flattened to TYPE, ICON, NAME.
| Command | Flags |
|---|---|
access list |
--device <id> (required) |
access add <ip>... |
--device <id> |
access remove <ip>... |
--device <id>, --yes |
proxy list |
--country <cc> |
access table: IP, ADDED, ISP. JSON: {ip, added, country, city, isp, asn, as_name}
(geo fields null until learned. Response key is body.ips, blank in the spec, verified live.)
proxy table: CODE, CITY, COUNTRY. JSON: {code, city, country, country_name, lat, lon}
--deviceis a query param, not a path segment.DELETE /accessis a DELETE with a body.GET /accessis capped at the latest 50 IPs, with no pagination. Say so in--help. When 50 come back, print a note to stderr that the list may be truncated.access add/access removetake at most 50 IPs per invocation (exit2above): the read-back window is those latest 50, so a larger add cannot be verified. The form-var ceiling is also unprobed forips[], though the cap keeps it unreachable (D11). Afteraccess add, read back and assert every IP is present. Added IPs are the newest, so they sit inside the window.access removesends oneDELETEper IP, mirroringrule delete. Read-back cannot prove a removal (an older IP may sit outside the 50-entry window), and the batch ack isbody: []plus a human count that must never be parsed. So the per-request ack is the per-target outcome. A mixed batch reports each IP's result through the same D4detailscontract.retry_argvcovers only the failures.- Proxy
code(PK) is the legal--viavalue for--action redirect.proxy listis therefore the discovery command for redirect targets. Cross-reference it fromrule create --help. - Proxy key sets vary between entries. Some fields are optional.
orgis deferred to a later release (D15):cdctltargets personal accounts. The--orgflag arrives with the org commands. Context-keyed config keeps them additive. Reachable today viacdctl api.
| Command | Table | JSON |
|---|---|---|
account get |
EMAIL, VERIFIED, 2FA, REGION |
{id, email, verified, created, twofa, region, proxy_access}, GET /users, body flat, no controller key |
billing products |
PRODUCT, TYPE, EXPIRY |
{id, name, type, expiry, proxy_access} |
billing subscriptions |
SUBSCRIPTION, PRODUCT, STATE, NEXT BILL |
{id, product: {id, name, type}, state, method, amount, currency, started, ended, next_bill} |
billing payments |
— | Not in v1. No probe account has payment history, so no schema can be verified, and a typed passthrough is exactly what D2 forbids. Use cdctl api /billing/payments. The typed command ships once a real sanitized payload fixes the schema. |
analytics levels |
LEVEL, TITLE |
{level, title}, upstream PK is an int (0/1/2) |
analytics regions |
REGION, TITLE, COUNTRY |
{region, title, country}, upstream PK is a string (america/europe/asia) |
network |
POP, CITY, COUNTRY, DNS, API, PROXY |
{pops: [{pop, city, country, lat, lon, up: {dns, api, proxy}}], time, current_pop}, body carries time/current_pop siblings. up.* are booleans (API sends 0/1) |
ip |
IP, TYPE, ORG, COUNTRY, POP |
{ip, type, org, asn, country, handler, pop}, body flat, no controller key |
No
analytics query. No public query-log API exists. Do not add the command.
| Command | stdout |
|---|---|
auth status |
{authenticated, email, region, token_source}, token_source: env | config. Human mode: key/value lines. No token is the D4 envelope (auth.missing_token, exit 4). authenticated: false is never printed |
auth login / auth logout |
nothing, confirmation goes to stderr. An explicit --json/--fields is a usage error (exit 2) like the artifact rows below. Ambient CONTROLD_OUTPUT=json is ignored |
config get <k> / config set <k> <v> / config path |
the raw value (nothing when unset) / nothing / the file path, respectively, never JSON. An explicit --json/--fields is a usage error (exit 2) on all three, ambient CONTROLD_OUTPUT=json ignored. config list -> {context: {value, source}, token: {set, source}, default_profile: {value, source}}, source: env | config | default | null, the token's value never prints |
completions <shell> / reference |
the artifact itself (script / Markdown), not JSON. An explicit --json/--fields is a usage error (exit 2), ambient CONTROLD_OUTPUT=json is ignored. An env-configured agent can still install completions |
api ... |
the upstream body verbatim, byte-for-byte with no added newline (the binary /mobileconfig response survives piping), unstable by design (D9). Errors still classify to standard exit codes, with the literal noun resource in slugs (resource.not_found). The passthrough cannot know what it touched. An explicit --json/--fields is a usage error (exit 2) like the artifact rows, neither can be honored on a verbatim body. Ambient CONTROLD_OUTPUT=json shapes only error rendering. A 2xx whose body carries no error marker is success, whatever the body's shape: typed writes demand success: true, but the passthrough cannot impose the envelope (the binary /mobileconfig case). Confirming a raw write's effect means re-fetching state |
One exact encoding per input form, nothing is sniffed (D16):
| Input | Wire |
|---|---|
-F key=value (repeatable) |
application/x-www-form-urlencoded body. Keys are sent literally: write -F 'hostnames[]=a' -F 'hostnames[]=b' yourself. Values are form-encoded, keys are never rewritten. Requires a non-GET -X (exit 2 otherwise, no documented GET takes a form body) |
--input - |
stdin sent verbatim with Content-Type: application/json: the API's one JSON write is PUT .../filters. No validation, no reshaping. Requires a non-GET -X <method> (exit 2 otherwise, a GET never carries a body here) |
-F and --input - |
mutually exclusive: exit 2 |
| Query parameters | ride in the path: cdctl api '/access?device_id=abc', taken verbatim. D9 origin rules still apply |
DELETE with a body |
-X DELETE -F 'ips[]=1.2.3.4' -F device_id=abc, covers DELETE /access |
--profile, --folder, --device, and positional ids all accept a name or an id.
- Exact id match wins.
- Else exact (case-insensitive) name match (ASCII case folding: non-ASCII characters must match exactly).
- Multiple matches -> error, exit
2. Never guess. Folder names in particular are not unique. - No match -> exit
3.
Resolution costs an extra GET /profiles (or /devices, /groups). Cache it for the process
lifetime. Do not cache across runs.
Confirmation matrix (D8)
| Operation | TTY | Non-TTY |
|---|---|---|
rule delete, access remove |
prompt | needs --yes, else exit 7 |
folder delete (deletes contained rules) |
prompt, state the rule count | needs --yes |
profile delete, device delete |
--confirm=<name> |
--confirm=<name>, --yes alone is not enough |
org update |
--confirm=<org> + "this is billable" |
same |
api with a non-GET -X |
needs --yes, else exit 7 (confirmation.required), never prompts, the escape hatch is gated, not conversational (D9) |
same |
--yes is ignored when the target is implicit: the profile came from the config file's
default_profile. --profile and CONTROLD_PROFILE both count as explicit (D8),
so env-configured agents keep --yes. Deleting the wrong profile because it was the default is the
failure mode worth designing out.