carlos / carloku Public

🤖 Fix wave: a command they can run, a claim that is true, and fewer antitheses

Five findings from the whole-branch review, one pass.

The Trash paragraph told a hosted customer to run `carlos add <host>` —
a box-local operator verb built around `-registry`, which a customer has
no box for. It now names `carlos instances create`, the verb the CLI
itself recommends in the same situation (deploy.go:262), and says plainly
that the console's own reply string still says otherwise so a reader who
has just seen that message knows why. The console string is a platform
bug, filed as carlosframework/platform#327.

"The platform does not send mail to anyone" stopped being true when
tenant email went live, and contradicted the console page on this same
site. Scoped to the narrow claim that is still true — inviting somebody
sends them no email — with the fallback the code actually has when
Keymail delivery fails.

Flattened 13 of the 25 X-rather-than-Y / X-not-Y sentences, keeping the
ones where the contrast is the point: "a lost secret means rotating, not
re-running", "inventory, not history", "one timeline rather than two",
"close to live, not exactly live", "make a stolen token boring, not
ration ordinary work". members.md 8 -> 3, limits.md 5 -> 2, logs.md
4 -> 2, corpus 25 -> 12. ", which" came down 13 -> 10 in the same edits.

check-docs.mjs blamed a `sync-docs` script this repo does not have —
carried in from the sibling, where the corpus is vendored. This one is
hand-authored, so all four messages now describe a hand-edited
docsnav.json that is malformed, empty, or out of step with src/docs/.

And the docs used hello.q4t.oncarlos.com while the landing page and
/get-started/ use hello.bdf — same site, two sqids. Both docs
occurrences now match the rest of the site.

npm run check exits 0 on a clean build; index.html and get-started/
stay byte-identical to 7123624.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 3251b48fd03df5b9c236bcaa242398aeba96febc parent 3d01a74
9 files changed, +53 −41
  • hack/check-docs.mjs +9 −7
  • src/docs/config.md +1 −1
  • src/docs/console.md +16 −5
  • src/docs/deploying.md +2 −3
  • src/docs/domains.md +1 −1
  • src/docs/limits.md +4 −4
  • src/docs/logs.md +3 −3
  • src/docs/members.md +15 −15
  • src/docs/sleeping.md +2 −2
diff --git a/hack/check-docs.mjs b/hack/check-docs.mjs
index e9e6a56..ef3ae9d 100644
--- a/hack/check-docs.mjs
+++ b/hack/check-docs.mjs
@@ -69,16 +69,18 @@ for (const rel of requiredSitePaths) {
// 2. Every nav entry has a built page and a built .md twin.
//
-// The floor below is the point of the whole gate: a loop over an empty
-// nav asserts nothing, so a sync-docs regression that wrote
-// {"sections": []} would print "docs ok — 0 pages" and exit 0 on a
-// corpus that had vanished. Same shape as the pages.length guard below.
+// docsnav.json is hand-maintained here — there is no generator to blame
+// or re-run, so every failure below is an edit someone made. The floor
+// after the loop is the point of the whole gate: a loop over an empty nav
+// asserts nothing, so a nav trimmed down to {"sections": []} would print
+// "docs ok — 0 pages" and exit 0 on a corpus that had vanished. Same shape
+// as the pages.length guard below.
const slugs = [];
const sections = Array.isArray(nav.sections) ? nav.sections : [];
if (!Array.isArray(nav.sections)) fail("docsnav.json has no sections array at all");
for (const section of sections) {
if (!Array.isArray(section.pages)) {
- fail(`docsnav.json section ${JSON.stringify(section.title ?? "?")} has no "pages" key — sync-docs has probably written a malformed file`);
+ fail(`docsnav.json section ${JSON.stringify(section.title ?? "?")} has no "pages" key — every section in src/_data/docsnav.json needs one, listing that section's pages`);
continue;
}
for (const entry of section.pages) {
@@ -90,7 +92,7 @@ for (const section of sections) {
}
}
if (slugs.length === 0) {
- fail("docsnav.json lists no pages — sync-docs has not run, or has failed; nothing in check 2 can fail against an empty nav");
+ fail("src/_data/docsnav.json lists no pages — every page in src/docs/ needs an entry there; nothing in check 2 can fail against an empty nav");
}
if (!exists(join(docs, "index.html"))) fail("no built /docs/ index");
@@ -156,7 +158,7 @@ const { slugify } = await import("../eleventy.config.js");
const fixture = JSON.parse(readFileSync("src/_data/docsanchors.json", "utf8"));
const cases = Object.entries(fixture).filter(([input]) => input !== "//");
if (cases.length < 5) {
- fail("anchor fixture has too few cases to pin anything — did sync-docs run?");
+ fail("src/_data/docsanchors.json has too few cases to pin anything — it has been emptied or truncated; restore it from the platform repo's docs/site/anchors.json");
}
for (const [input, want] of cases) {
const got = slugify(input);
diff --git a/src/docs/config.md b/src/docs/config.md
index afe82d8..9de659f 100644
--- a/src/docs/config.md
+++ b/src/docs/config.md
@@ -39,7 +39,7 @@ cannot itself open.
The consequence is the one thing people trip over: nothing can show you
the value again. `carlos secrets list` prints key names and stops there.
-Lose the value and you set a new one rather than recovering the old one.
+Lose the value and your only move is setting a new one.
One practical wrinkle. The value is on your command line when you type
it, so your shell history keeps a copy of it. Clear that out on a machine
diff --git a/src/docs/console.md b/src/docs/console.md
index edbdd85..7834a10 100644
--- a/src/docs/console.md
+++ b/src/docs/console.md
@@ -59,11 +59,22 @@ removing one and handing over ownership live on the Team page; minting,
rotating and revoking a credential a server holds live on Account settings.
Deletion is the other cluster. A deleted app goes to Trash and stays
-restorable for 30 days, keeping its releases, channels and history —
-restoring gives all of that back with no routes, so run
-`carlos add <host>` afterwards to serve it again. An owner can also destroy
-one immediately from the same page, which asks for the app's name typed out
-and then for your passkey.
+restorable for 30 days, keeping its releases, channels and history.
+Restoring gives all of that back with no routes, so the app comes back with
+nowhere to serve from until you make one:
+
+```sh
+carlos instances create -app hello -host hello.bdf.oncarlos.com
+```
+
+That is the same command [Deploying](/docs/deploying/) names when a channel
+has no instance following it. The console's own confirmation currently tells
+you to run `carlos add` instead — don't. That verb edits a box's route
+registry from the box, and your app doesn't come with a box you can log in
+to.
+
+An owner can also destroy an app immediately from the same page; that asks
+for the app's name typed out and then for your passkey.
And when the day's app-creation budget is spent, the console's Create app
form is the way through — it has no limit of its own. [Limits and getting
diff --git a/src/docs/deploying.md b/src/docs/deploying.md
index ab12f05..9d95de7 100644
--- a/src/docs/deploying.md
+++ b/src/docs/deploying.md
@@ -32,7 +32,7 @@ preferring over anything else you might reach for. A page that looks
right can be a cached copy of the old build:
```sh
-curl -sI https://hello.q4t.oncarlos.com | grep -i x-carlos-version
+curl -sI https://hello.bdf.oncarlos.com | grep -i x-carlos-version
```
## What you ship
@@ -49,8 +49,7 @@ this platform is:
GOOS=linux GOARCH=arm64 go build -o hello .
```
-A directory of files is a site rather than a binary, and takes
-`-kind static`:
+A directory of files ships as a site, with `-kind static`:
```sh
carlos deploy -app hello-site -kind static ./public
diff --git a/src/docs/domains.md b/src/docs/domains.md
index f2775fc..26bc375 100644
--- a/src/docs/domains.md
+++ b/src/docs/domains.md
@@ -1,7 +1,7 @@
# Custom domains
Your app already answers on a hostname the platform gave it, something
-like `hello.q4t.oncarlos.com`. Attaching your own domain is two moves:
+like `hello.bdf.oncarlos.com`. Attaching your own domain is two moves:
tell Carloku the hostname is yours, then point a DNS record at it. The
platform hostname keeps working afterwards, so you can do all of this
before you switch any traffic over.
diff --git a/src/docs/limits.md b/src/docs/limits.md
index 9c7f36d..27f13d5 100644
--- a/src/docs/limits.md
+++ b/src/docs/limits.md
@@ -29,11 +29,11 @@ get sixty a minute on their own budget, because a burst of signups creating
instances is the workload that API exists for.
Claiming an app name is the tight one: ten attempts a day per address, and
-attempts, not successes, so refused tries count. A claimed name persists
+a try that gets refused still spends one of them. A claimed name persists
whether or not you ever use it, which is why this ceiling runs over a day and
the others run over a minute. When you hit it, the message points at the
console's Create app form, which has no limit of its own. That is the
-intended way through, not a loophole.
+intended way through.
## Credentials a server holds
@@ -43,8 +43,8 @@ twenty other writes a minute, a hundred app claims a day. A CI fleet cutting
releases back to back therefore cannot starve the person who minted it, and
the person's noisy afternoon cannot stop CI.
-The counters key on the credential's name, not its secret, so rotating one
-does not hand it a fresh budget. [Members and service
+The counters key on the credential's name, so rotating its secret does not
+hand it a fresh budget. [Members and service
credentials](/docs/members/) covers the rest of how they work.
## Things that expire
diff --git a/src/docs/logs.md b/src/docs/logs.md
index 928a804..a47f8bd 100644
--- a/src/docs/logs.md
+++ b/src/docs/logs.md
@@ -55,10 +55,10 @@ site.
Seven days, and a size ceiling per app that drops the oldest hour first if
an app is unusually chatty. If you need a longer record than that, have the
-app ship its own copy somewhere; the platform's retention is for debugging
-this week, not for keeping an audit trail.
+app ship its own copy somewhere; the platform's retention is sized for
+debugging this week.
-An empty result is worth a second look rather than a bug report. Platform
+An empty result is usually honest, and worth a second look. Platform
events only happen when something happens — a wake, a restart, a failure —
so a quiet hour of a quiet app really does have nothing in it. Widen to
`-since 24h` before you conclude that logging is broken.
diff --git a/src/docs/members.md b/src/docs/members.md
index 69c98b1..f5a450d 100644
--- a/src/docs/members.md
+++ b/src/docs/members.md
@@ -9,10 +9,11 @@ they invite are different too.
## People
Invites go out from the Team page in the console, addressed to a Keymail
-address. The invitation lands in that person's Keymail Apps inbox rather than
-their email; the platform does not send mail to anyone. It works once and
-expires after seven days, and an owner can revoke a pending one before it is
-used.
+address. The invitation lands in that person's Keymail Apps inbox — the same
+sealed path a sign-in confirmation takes — so inviting somebody sends them no
+email. It works once and expires after seven days, and an owner can revoke a
+pending one before it is used. If the delivery itself fails the console hands
+you the link to pass on yourself; the invitation is real by then either way.
There are two roles. A member does the day-to-day work on the account's apps
— deploy, promote, roll back, config, domains, logs. An owner does all of
@@ -51,16 +52,15 @@ Pick the narrowest role that does the job. `publish` is the deploy loop:
claim an app, ship, promote, roll back, manage hostnames, retire. `operate`
is runtime work with no release authority — restart, config, logs. `instance`
is the older app-token shape, kept for what already depends on it. `admin` is
-the union of those plus ledgers, storage and placement, and it includes
-granting object-store access, which mints a real IAM user. That last one is a
-handover, not a convenience.
+the union of those plus ledgers, storage and placement. It also grants
+object-store access, and that mints a real IAM user — hand one out only when
+you mean to hand over.
-`-app hello` binds the credential to one app, which also means it cannot
-claim new ones. Leave it off for an account-wide credential. Either way the
-reach is fixed at mint: if you join another account next month, the
-credential you minted does not follow you there. It draws on its own rate
-budgets rather than yours, too — see [Limits](/docs/limits/) — and it never
-expires, so revoking is the control.
+`-app hello` binds the credential to one app; it cannot claim new ones.
+Leave it off for an account-wide credential. Either way the reach is fixed at
+mint: if you join another account next month, the credential you minted does
+not follow you there. Its rate budgets are its own, separate from yours — see
+[Limits](/docs/limits/) — and it never expires, so revoking is the control.
## Rotate replaces, revoke stops
@@ -73,7 +73,7 @@ carlos services rotate ci-runner
Rotation mints a fresh secret and prints it once, keeping the name, the role,
the scope and the history. The old secret is refused on the credential's
*next* call — work already in flight is unaffected. That makes rotation a
-hygiene move, not an incident response.
+hygiene move: something you schedule.
```sh
carlos services revoke ci-runner
@@ -95,7 +95,7 @@ carlos services ls
Live credentials only — name, role, scope, when it was minted, when its
current secret began, when it was last used, and who minted it. A revoked one
-is gone rather than listed as revoked, so this is inventory, not history.
+disappears from the list entirely, so this is inventory, not history.
Every verb here is owner-only, and closed to service credentials themselves:
no credential can mint, rotate or revoke any credential, not even its own.
diff --git a/src/docs/sleeping.md b/src/docs/sleeping.md
index 127aa51..505f9d2 100644
--- a/src/docs/sleeping.md
+++ b/src/docs/sleeping.md
@@ -50,8 +50,8 @@ together during a wake all join the same one instead of each starting their
own.
The exception is a wake that takes an unusual length of time. Past about
-thirty seconds the request is answered with a 503 and a `Retry-After` rather
-than held open forever, and a wake that fails outright ends the same way. In
+thirty seconds the request is answered with a 503 and a `Retry-After`, and a
+wake that fails outright ends the same way. In
both cases the reason is one line in the platform stream:
```sh