Verified behavior in reference/. Each entry records its own reasoning and the cost accepted.
Control D's DNS daemon is already ctrld. Ours is a different tool and will sit
beside it on PATH.
- ❌
controld: a trailing-dmeans daemon in Unix (sshd,systemd,cloudflared,ctrld). Two daemon-looking binaries neither a human nor an agent can tell apart. Also squats the name Control D would want. - ❌
ctrld-cli: names the daemon as its parent (butctrldalready has a CLI), and prefix-collides withctrldin tab-completion. - ✅
cdctl:*ctl(kubectl, systemctl) reads as client tool. No prefix collision.
Cost accepted: cryptic, unguessable. Discoverability rests on docs. Lead the README with the
ctrld disambiguation.
Top-level verbs that read as daemon ops (status, start, log) stay reserved: colliding with
ctrld's surface would recreate the confusion this decision exists to avoid.
The API is unversioned ("breaking changes... without warning"), returns 400 for auth failures, encodes
actions as bare ints, and has three envelope shapes. Never pass that through. Unwrap, map ints to
names, rename PK, normalize timestamps to RFC-3339.
Cost accepted: new API fields need a release. D9 (cdctl api) is the pressure valve that makes this affordable.
--json is explicit. Piping changes rendering (color, pager), never representation, as gh and
kubectl do. Agents use --json or CONTROLD_OUTPUT=json.
JSON output is always pretty-printed (2-space indent, stable key order, one trailing
newline), identical on a TTY, in a pipe, and in error documents on stderr. Compact output can
arrive later as a --compact flag. A formatting flag is additive.
Lists print in full: no default --limit. Silent truncation corrupts diff and sync flows.
Agents trim with --fields or jq. A limit flag stays additive if demand appears.
--json is boolean. Field selection is a separate --fields <a,b> (implies --json). An
optional-value --json [fields] swallows the next positional in clap. No --jq in v1: adding a
flag later is non-breaking, and shipping one means embedding a jq engine (jaq). Pipe to jq until
demand proves the dependency.
No machine-readable command manifest in v1. --help, cdctl reference (Markdown), and
AGENTS.md cover human and agent discovery without publishing another schema. Keep command metadata
centralized and derivable so a post-v1 release can generate a versioned machine-readable spec from
the same source as clap and reference. Add it only for a concrete consumer, such as a policy
validator, MCP adapter, or tool generator. Never maintain a second handwritten command contract.
stdout carries only data. Errors -> stderr. The envelope below is what JSON mode emits.
Human mode renders the same fields as a single error: ... line. Exit codes are identical in both.
One field, retryable, removes a class of agent bugs. Its sibling retry_after carries the
parsed Retry-After in seconds (null when the response has none, or the header is
unparseable, in which case --debug says so). Agents never re-derive backoff.
Human mode stays one line, whatever upstream leaks: CR/LF/TAB collapse to spaces (whitespace
runs collapse to one, since stripping a tab would glue words together), and every other C0/DEL control
(ANSI escapes included) is stripped — a message never gets to rewrite the terminal. The verbatim text survives in JSON upstream.message. --debug renders it
JSON-escaped after token redaction: verbatim semantically, never raw terminal bytes.
upstream is null or {code, http_status, message}: null on client-side errors (bad
flag combination, unreadable file, plan refusals) and at the top level of aggregate errors,
whose target-specific upstream data sits with each target. Members are individually nullable (the
0-byte 500 yields {"code": null, "http_status": 500, "message": null}).
details is null on ordinary errors and a discriminated object on aggregate ones. The key
is always present, and inside each variant every key is too, null where not applicable. The
contract is the complete envelope, not fragments:
{
"error": {
"code": "write.partial_failure", // stable aggregate slug
"message": "1 of 2 rules failed", // ours
"upstream": null, // per-target upstream sits inside targets
"retryable": true, // the aggregation verdict (true <=> exit 8)
"retry_after": null,
"details": {
"kind": "multi_target",
"targets": [ // ordered; code is the stable CLI slug, NEVER the numeric upstream code
{
"target": "a.com",
"outcome": "landed",
"code": null,
"retryable": null,
"upstream": null
},
{
"target": "b.com",
"outcome": "failed",
"code": "upstream.error",
"retryable": true,
"upstream": {
"code": 50001,
"http_status": 500,
"message": "..."
}
}
],
"retry_argv": [ // resolved ids, never names/defaults
"cdctl", "rule", "create", "b.com",
"--action", "block",
"--profile", "123456abcdefg"
]
},
"hint": "Re-run retry_argv; it covers only the failed targets."
}
}
{
"error": {
"code": "rule.collision",
"message": "2 hostnames exist in other folders",
"upstream": null,
"retryable": false,
"retry_after": null,
"details": {
"kind": "collisions", // nothing landed, nothing to retry — no retry_argv
"collisions": [
{"hostname": "x.example", "folder_id": 3, "folder": "Work"}
]
},
"hint": "Remove them from the file, or delete/move the live rules first."
}
}
{
"error": {
"code": "rule.unconvergeable",
"message": "1 rule cannot reach the desired state",
"upstream": null,
"retryable": false, // the API cannot express the transition
"retry_after": null,
"details": {
"kind": "unconvergeable", // every entry key always present
"rules": [
{
"hostname": "v6.example",
"reason": "via6-clear",
"current_via6": "2001:db8::1",
"desired_via6": null
}
]
},
"hint": "command-specific — see rule-import semantics in commands.md"
}
}The unconvergeable variant covers desired states the API cannot reach
(write-verification). reason is a stable slug and the
current_*/desired_* members are null when a future reason doesn't use them.
outcome is "landed" | "failed" | "skipped" (skipped = not attempted after an abort).
retry_argv is built so the retry cannot go astray:
- An argv array, not a string: JSON stays shell-neutral. No single quoting scheme works for
POSIX, PowerShell, and
cmd.exe, and agents should not parse shell syntax. Human mode may render a convenience string beside it. - Resolved opaque ids, never names or ambient defaults:
--profile <id>(plus--device <id>where applicable) is always present, even when the original run used a name or the config default. A changed default must not redirect the retry (the D8 hazard). - Deletes carry
--yesso the retry stays non-interactive. Credentials never appear.
Aggregation: exit 8 only when every failed target is retryable. A batch containing any
terminal failure exits with the shared terminal code, or 1 when mixed. The per-target fields
stay authoritative either way. stdout stays empty. JSON mode emits exactly one document on
stderr.
Only five distinct codes are known (40001, 40002, 40003, 40201, 40401), and 40003 alone
carries a still-growing list of unrelated meanings, since every probe session adds to the observed
table in error-codes. A lookup table would be worthless.
Control D document the structure: "first 3 digits match the HTTP status." So classify on the prefix: it's total, and a code we've never seen still resolves correctly.
The trap: 400 is overloaded. Auth runs before routing, so a bad token is 400/40001, not 401.
Special-case it. Full table: reference/error-codes.
0 success (incl. empty results) |
1 generic |
2 usage (free from clap) |
3 not found |
4 auth (matches gh) |
5 forbidden / plan / scope |
6 conflict |
7 confirmation required |
8 retryable — rate limit, 5xx, network |
130 SIGINT |
141 SIGPIPE (Unix) |
An earlier draft split rate-limit/5xx/network into three codes. That's diagnostic, not actionable: an agent does the same thing for all three. An exit status is one byte and can't carry a message. The reason belongs in the error envelope.
The contract: exit 8 is retryable, everything else is terminal. Codes 9-19 reserved, append-only.
141 is Unix's SIGPIPE death: the default disposition is restored at startup, so
cdctl reference | head dies quietly the traditional way. Rust's default (a write-error panic,
exit 101) would leak an undocumented code. Sockets are unaffected (MSG_NOSIGNAL).
Scope violations fail loudly (exit 5): never silently filter results, as flyctl does.
CONTROLD_API_TOKEN or cdctl auth login --token-stdin. No --token flag: argv is world-readable
via ps. Config at $XDG_CONFIG_HOME/cdctl/config.toml, mode 0600, token wrapped in secrecy.
The dir matches the binary (gh -> gh/), and D1's no-squatting argument applies to controld/ too.
No keyring: breaks headless/CI/agent use and drags dbus into musl builds. Agents are a primary
audience, so it's the wrong trade. (gh/aws/stripe all do env + file.)
The persistence contract (v1):
# $XDG_CONFIG_HOME/cdctl/config.toml (Windows: %APPDATA%\cdctl\config.toml)
current_context = "personal" # optional — "personal" is the default
[contexts.personal]
token = "api.xxxx" # plaintext — `secrecy` guards logs and debug output, NOT the disk
default_profile = "Home" # optional
[contexts.acme] # an org context slots in later (D15) with no migration
token = "api.xxxx"
org = "<org-id>"Precedence: CONTROLD_API_TOKEN > active context's token > error. Writes go through a
same-directory tempfile + atomic rename. The directory is created 0700, a symlinked config file is
refused, and an existing group/world-readable file draws a warning. Windows relies on %APPDATA%'s
user-scoped ACLs: no Unix mode bits are attempted. The token is not encrypted at rest. The file
mode is the guard, and the docs must never imply otherwise.
Tokens are dashboard-issued only — no token API exists — so auth login cannot do an OAuth flow.
Secret managers integrate with zero code. Env beats config, so 1Password's
op run injects CONTROLD_API_TOKEN from an op:// reference per invocation, and
op read ... | cdctl auth login --token-stdin covers persistent setup. The same works for any
manager that can export an env var. This is the designed path: no keyring, no plugin API.
Resolving the token at the top of run() makes completions and reference fail with "not
authenticated," so you can't install completions without a token. Resolve inside the handlers that
make requests.
The four operations the spec marks security: [] (service categories, service catalog,
network, ip) are attempted without a token when none is configured: no Authorization
header at all. If the API answers 40001 anyway, it classifies as auth (exit 4) like anywhere else.
With a token configured, it is sent.
Prompt on TTY. Fail loudly (exit 7) in non-TTY without --yes. Severe ops (profile/device delete) need --confirm=<name>. --yes is ignored when the target is implicit. Explicit means
the profile came from --profile or CONTROLD_PROFILE, both deliberate, per-invocation or
per-session choices. Only the config file's default_profile is implicit: it's ambient state the
caller may have forgotten, and deleting into the wrong profile because it was the default is the
failure mode worth designing out. Agents set the env once and --yes works normally.
--force never bypasses a prompt: bypassing is --yes's job alone. A --force* flag accepts one
named risk (--force-delete-first covers the protection gap), and future flags keep that split.
The escape hatch. Without it the CLI becomes the blocker when the API moves, and users fall back to
curl with the token in argv.
But a raw passthrough defeats agent allowlists: permit cdctl and you've permitted every DELETE.
So: a distinct verb (sandboxes can deny Bash(cdctl api:*) while allowing Bash(cdctl:*)),
GET by default, non-GET requires -X and --yes, raw output documented as unstable.
Origin rules: the token never leaves the pinned origin. cdctl api accepts relative paths
only: absolute URLs, scheme-relative forms, and authority/userinfo components are rejected at
parse time. Redirects are followed same-origin only. Authorization is never forwarded across
origins. The base-URL override (CONTROLD_API_URL) serves tests and path-routing gateways: it
must be an http(s) URL with a host (rejected at construction otherwise), a path prefix on it is
part of the pinned target (preserved on every request, with dot-segment escapes rejected), and
when it points anywhere but the real origin, cdctl refuses to attach a stored token unless
CONTROLD_UNSAFE_BASE_URL=1 is also set. Non-2xx responses classify through the standard error
rules. GET retries apply, writes never retry.
Encoding is explicit, never sniffed (D16). Repeated -F k=v builds a form body with literal
keys (hostnames[]= works as typed). --input - sends stdin verbatim as JSON. Both body
forms require a non-GET -X <method> (a GET never carries a body here), and the two are mutually
exclusive. Query strings ride in the path. Grammar:
commands.
Microsoft benchmarked flags vs JSON payloads for LLM callers (July 2026): flags won 5/5 on correctness and used 4-11x fewer tokens. Models emit valid JSON that then dies on shell escaping.
Never JSON-in-argv. --input - for stdin.
One flag group, reused by rule, folder, service, profile default:
--action block|bypass|spoof|redirect # not --do 0
--via <ip|cname|PROXY>
--enabled | --disabled # not --status 1
--enabled/--disabled is orthogonal to --action. The CLI's biggest ergonomic win over curl.
Send what the spec declares: form for the 18 writes, JSON for PUT .../filters. Arrays as
hostnames[]=a&hostnames[]=b.
The "contradiction" never existed: bracket and indexed both work. The bare repeat fails, and
that's what Go/Python emit by default. Root cause: the backend is PHP (it leaked a print_r()
dump into an error). Details: reference/write-verification.
Consequences:
- Upstream messages can be multi-line: never parse them.
- The server parses at most ~1001 form variables and silently drops the rest: a 1000-hostname
batch returns 200 and stores 999, or stores all 1000 with a defaulted
status. - So: chunk imports at 500, put scalar params first, and verify the full desired state:
re-fetch and assert every written hostname is present with the intended action, enabled state,
via,via6, and folder. The write response is a one-entry summary that can't reveal partials, and a bare count check can be masked by concurrent changes. - Over-sized batches and batches crossing the 10,000 rules/profile cap fail atomically.
- The ceiling binds every variadic write, not just import. Typed multi-target commands are
capped client-side: 500 hostnames (
rule create/update), 50 IPs (access add/remove, the read-back window is 50 and theips[]ceiling is unprobed). - Every multi-target write yields a per-target outcome: read-back where the window allows
(rules,
access add), one request per target where it doesn't (rule delete,access remove, the 50-entryGET /accesswindow cannot prove an older IP's deletion). - No bulk delete exists. Percent-encode hostnames into DELETE paths (
*->%2A).
No headers, no 429 ever observed (~10 req/s x 60). Can't pre-limit against an unverified number.
Honour Retry-After if present, exponential backoff with jitter, auto-retry idempotent GETs only,
never writes.
The retry loop is ours, not reqwest's. The client is built with retry::never(). cdctl owns a
GET-only loop: Retry-After (integer and HTTP-date), full-jitter exponential backoff, caps of
3 attempts / 30 s elapsed, --no-retry disables. reqwest 0.13's built-in retry replays immediately —
no backoff, no Retry-After — and its default policy is not GET-only, so it cannot implement this
contract. Tests assert every write endpoint receives exactly one request under 429/5xx/timeout.
Every request runs under a timeout (30 s total, 10 s connect, --timeout overrides) because a
hang is worse than a fast failure. Each retry attempt logs to stderr. A silent backoff is
indistinguishable from a hang. A write that times out may still have landed: it is never
retried, the error says so, and the hint directs to re-fetch state (multi-target writes resolve
this themselves via read-back).
clap + clap_complete, reqwest (async, rustls, the form feature, no longer a default
feature, and every write needs it), tokio, serde/serde_json, thiserror, comfy-table,
etcetera (XDG on macOS too), secrecy, toml, httpdate (Retry-After HTTP-dates),
fastrand (backoff jitter), tempfile (atomic config writes). anstream + owo-colors arrive
with the first colored output.
Dev: wiremock, assert_cmd, insta, predicates, nix (the SIGINT test).
Latest stable versions at implementation time. Exact pins live in Cargo.toml. Edition 2024,
MSRV 1.85, the first release with edition-2024 support. Development uses latest stable.
reqwest::blocking is not tokio-free (it spawns a runtime thread), so "blocking to avoid tokio" is a
myth, hence async.
retry::never()). They replay immediately with no
backoff and no Retry-After, and the default policy is not GET-only: a retried POST /rules
duplicates rules, a retried DELETE double-deletes. D12's loop is hand-rolled and owned by cdctl.
cargo-dist (linux gnu/musl and macOS on aarch64/x86_64, Windows x86_64; shell and powershell
installers) + Homebrew tap. release-plz -> crates.io.
Two traps, each sprung exactly once, at first release:
release-plzand dist both create the GitHub Release by default and collide.release-plzowns crates.io and the tag, withgit_release_enable = false. dist owns the Release.- The tool is
dist, but installcargo-dist: thedistcrate name is an unrelated placeholder.
The platform verifier reads OS certs from disk, so the static musl binary fails TLS in a certless
(scratch) container: document /etc/ssl/certs as required, or bundle webpki roots.
Org endpoints are out of scope for v1: a business feature, untestable on a personal account (all five 404), and untested commands are worse than none. The community can add them.
Two things ship in v1 so orgs stay purely additive:
orgis one new top-level noun: cannot collide.- Config is context-keyed from the start: no migration needed.
The --org flag is deferred with the rest. Adding an optional flag later is not a breaking
change, and a personal account cannot verify the X-Force-Org-Id header's live effect. Shipping
it untested would break this decision's own rule. It arrives with the org noun, tested.
Gate: adding orgs must require no breaking change to any command, flag, output field, or exit code.
Org endpoints remain reachable today via cdctl api.
Undocumented behavior isn't a contract on an API that breaks without warning.
| Behavior | Verdict |
|---|---|
Content-Type ignored / body sniffed |
Don't rely on it |
Indexed hostnames[0]= |
Don't use — hostnames[] is documented and works |
POST .../groups/import |
Dropped — undocumented and 500s |
Analytics host, ipflt, cflt |
Out of scope |
One apparent exception, which isn't: GET /profiles/{id}/rules (folder segment omitted) is
documented (in the folder_id param prose), just absent from the spec's paths. We must use it
because the other documented option (folder_id=0) is broken.
cdctl api remains the escape hatch for anything else.
Six releases, each independently useful. Anything not yet typed is reachable via cdctl api
(D9) from day one, so deferral never locks anyone out:
| Release | Group |
|---|---|
| v0.1 | Core + cdctl api + profile list/get + rule/folder CRUD + release machinery |
| v0.2 | device list/get — read-only, pulled ahead of the flagship to give the device read a typed contract |
| v0.3 | device update — the fleet write consumers asked for, probed live before implementation |
| v0.4 | rule import + rule restore — the flagship, isolated so v0.1 ships sooner |
| v0.5 | Protection: profile writes/options/default, filter *, service * |
| v0.6 | Fleet & account: device create/delete/types, access *, proxy, analytics, account, billing, network, ip |
| 1.0 | = the full mapped surface, org (D15) and billing payments (D2) stay deferred |
What makes this safe is the same mechanism as D15's org deferral:
- Every global contract (exit codes, output schema, error envelope, shared flags, config format) ships complete in v0.1 and is treated as frozen from v0.1, 0.x semver notwithstanding. Later groups only add nouns and verbs.
- Folders ride with rules in v0.1 because rules are folder-scoped.
- Group-specific live probes gate their own group (dropdown/level-less filters -> v0.5), not earlier releases.
Cost accepted: six release cycles instead of one. Users of deferred families type raw
cdctl api paths for a while. The delivery table above must stay in sync with
roadmap.
No lib.rs. Our public API is the CLI contract (exit codes, JSON schema, per D2 and D5), not Rust
types. A lib target on crates.io would create semver obligations nobody asked for (fd does the
same, while bat's lib is deliberate and ours would be accidental). If a reusable client ever emerges, it
becomes a separate controld-client crate in a workspace, a non-breaking migration.
Layout follows cargo's own binary: src/cli.rs holds the root parser (global flags + one enum
variant per noun). src/commands/<noun>.rs holds that noun's Subcommand enum and its
handlers, colocated. Errors are thiserror-typed all the way up (no anyhow): the D4 envelope
and nine exit codes are structure, and erasing them at the binary boundary would forfeit it.
Cost accepted: integration tests can't import crate internals: they drive the binary
(assert_cmd + wiremock), and fixture deserialization is tested in-module.
The MCP niche has incumbents, including a Control D one. cdctl differentiates on a CLI agents
call directly. A wrapper can be third-party — the D3 machine-readable spec, if it ever ships, is
its integration point.
Cost accepted: MCP-first users go elsewhere.
- Proxy-code validation: masked by the 402 plan gate, needs a Full Control account.
- Rate limits: never observed.
- The
[]-shaped default rule: never reproduced. Spec says it can happen. Tolerate both. lock_statusvalues (PUT /profiles/{id}): unprobed, low-stakes. (iconon PUT andprofile_id2read-back: answered 2026-08, write-verification.)dropdownoption writes (PUT /options/{name}withvalue): unprobed, verify before v0.5.billing paymentsschema: needs one real sanitized payload, the typed command waits on it.- Level-less filter writes (
PUT /filters/filter/{family}for families withoutlevels[], e.g.noai): unprobed, verify before v0.5. - The
ips[]form-variable ceiling (POST /access): unprobed, the 50-IP cap keeps it unreachable. - Percent-encoded bracket keys ✅ resolved — form bodies are hand-built:
keys verbatim, values percent-encoded. Every write (typed and
cdctl api -F) sends the literalhostnames[]=form live verification proved, so the probe of the%5B%5Dform is moot. (The original note claimedcdctl api -Falready sent literal keys, but it did not: reqwest's form encoder emittedhostnames%5B%5D=there too, and an earliercdctl api -Fwire test had pinned the encoded form under a test name that said otherwise.)
{ "error": { "code": "auth.invalid_token", // stable, CLI-owned — agents match on this "message": "Invalid session...", // ours, rendered "upstream": { // theirs, verbatim "code": 40001, "http_status": 400, "message": "Invalid session, please login again." }, "retryable": false, "retry_after": null, // seconds from Retry-After; null when absent or unparseable "details": null, // structured payload on aggregate errors "hint": "Run `cdctl auth login`." } }