| 1 | # Password gate — a shared password in front of an instance |
| 2 | |
| 3 | **Date:** 2026-09-07 · **Status:** approved in conversation (Paul, 2026-09-07: |
| 4 | "yes, full spec, plan, and overnight implementation, but also make sure I can |
| 5 | set a password or disable password protection via the web console settings |
| 6 | too"). · **Requested by:** Paul. |
| 7 | |
| 8 | **Builds on:** the instance-record converge (`internal/provision/reconcile.go`, |
| 9 | the `Hibernate` precedent), the edge's cache-before-activator ordering |
| 10 | (`internal/edge/edge.go` ServeHTTP), the mesh key's HMAC discipline |
| 11 | (`internal/edge/mesh.go`), the fleet status page's stylesheet |
| 12 | (`internal/status/assets/status.css`), and the console's form conventions |
| 13 | (`internal/console/fleets.go` handleAppPlace). |
| 14 | |
| 15 | ## 1. What this is for |
| 16 | |
| 17 | A member ships a static site or a prototype and wants it reachable by the |
| 18 | people they give a password to, and nobody else. No accounts, no invitations, |
| 19 | no identity provider: one shared password per host, a neutral page that asks |
| 20 | for it, and a cookie that remembers the answer for a week. |
| 21 | |
| 22 | It is not access control for the app's own users. An app with real sign-in |
| 23 | keeps doing that itself. This is a curtain the platform holds in front of the |
| 24 | whole hostname, so the app behind it does not have to know the curtain exists. |
| 25 | |
| 26 | ## 2. Where it lives, and why |
| 27 | |
| 28 | The edge is the only thing that fronts an instance, and it decides what to do |
| 29 | with a request from the registry row the host resolves to. So the gate is a |
| 30 | **route property**, and it travels the path hibernation already travels: |
| 31 | |
| 32 | carlos gate / console Settings |
| 33 | → console PATCH on the instance record (bucket, CAS-guarded, audited) |
| 34 | → the host's provisioning reconcile converges it into a registry column |
| 35 | → the edge reads it at reload and enforces it per request |
| 36 | |
| 37 | Three consequences fall out of that placement, and each is a reason to place |
| 38 | it there rather than anywhere else: |
| 39 | |
| 40 | - **Custom domains are covered for free.** An alias maps to the primary's |
| 41 | `upstream` entry verbatim (`edge.go`, `upstream.alias`), so a gate keyed on |
| 42 | `up.route` fronts `docs.example.com` the moment it fronts the platform |
| 43 | hostname. |
| 44 | - **Static sites are covered.** A static site is an instance record of kind |
| 45 | `static`; its route is served by the edge's static handler, which sits |
| 46 | behind the same gate step as a proxied instance. |
| 47 | - **A gated host never wakes for a stranger.** The gate step runs before the |
| 48 | activator, so unauthenticated traffic — crawlers, scanners, someone guessing |
| 49 | a hostname — is answered by the login page without spinning the instance up. |
| 50 | |
| 51 | What it is deliberately NOT: an app-level feature flag (`carlos features` is |
| 52 | served to the app and never read by the edge), an env var (`carlos env` is |
| 53 | rendered on the host and never read by the edge), or a manifest policy (the |
| 54 | manifest is read at instance create; a gate is toggled on a running host). |
| 55 | |
| 56 | ## 3. The record and the column |
| 57 | |
| 58 | ### 3.1 `provision.Record.Gate` |
| 59 | |
| 60 | ```go |
| 61 | // Gate is the bcrypt hash of the host's shared password ("" = open, the |
| 62 | // value every record written before this field existed decodes to). It is |
| 63 | // the ONLY place the secret material lives in the bucket; the plaintext |
| 64 | // crosses the console once, at the door that hashes it, and is never |
| 65 | // stored, logged or echoed. Converged into routes.gate by reconcile |
| 66 | // (§3.3) and read by the edge (§4). Additive: omitempty, and an old |
| 67 | // reader that does not know the field drops it on the floor — see §9 |
| 68 | // for why that means boxes roll before the console. |
| 69 | Gate string `json:"gate,omitempty"` |
| 70 | ``` |
| 71 | |
| 72 | Validation in `ValidateShape`: if `Gate != ""` it must parse as a bcrypt |
| 73 | hash (`bcrypt.Cost(hash)` succeeds). Any kind may carry one — `instance`, |
| 74 | `static`, `service` all front something a person can browse to. No kind |
| 75 | refuses it. |
| 76 | |
| 77 | ### 3.2 The registry column |
| 78 | |
| 79 | `ALTER TABLE routes ADD COLUMN gate TEXT NOT NULL DEFAULT ''`, appended to |
| 80 | the migration list; `Route.Gate string`; added to `routeCols`, `scanRoute`, |
| 81 | the `Upsert` VALUES and its `ON CONFLICT` set. The conflict rule is the |
| 82 | **preserve-when-empty** one `caps`, `tunnel` and `unit` use — |
| 83 | `gate=CASE WHEN excluded.gate = '' THEN routes.gate ELSE excluded.gate END` |
| 84 | — so a full-row `Upsert` from a writer that does not know the column |
| 85 | (reconcile's mint path via `routeFor`, an old `carlos add`) cannot silently |
| 86 | open a gated host. `TestUpsertRoundTripsAllColumns` counts the column. |
| 87 | |
| 88 | Narrow setter, the shape of `SetSteering`: |
| 89 | |
| 90 | ```go |
| 91 | // SetGate stamps or clears a route's password gate. "" opens the host. |
| 92 | func (r *Registry) SetGate(host, hash string) error |
| 93 | ``` |
| 94 | |
| 95 | **`Fingerprint()` must include `gate`.** The edge reloads when the registry |
| 96 | fingerprint changes, and the fingerprint hashes a fixed column list. A gate |
| 97 | set or cleared with the column left out of that list would take effect only |
| 98 | on the next unrelated reload — an operator would "turn the password off" |
| 99 | and watch the page keep asking for it. `Route.Gate`'s doc comment says it IS |
| 100 | in `Fingerprint()`, the inverse of the `Caps`/`Unit`/`Steering` comments. |
| 101 | |
| 102 | ### 3.3 Converge |
| 103 | |
| 104 | In reconcile's "existing, provisioned, same app/account" arm, beside the |
| 105 | hibernate converge: |
| 106 | |
| 107 | ```go |
| 108 | if existing.Gate != rec.Gate { |
| 109 | // dry-run / SetGate / log "converged %s gate %s -> %s" with the two |
| 110 | // states rendered as "open"/"set", never the hash. |
| 111 | } |
| 112 | ``` |
| 113 | |
| 114 | Unlike hibernate this is not a pointer with an absent-means-silent rule: |
| 115 | absent decodes to `""`, and `""` means open. A record that does not name a |
| 116 | gate is an open host, which is also what every record in every production |
| 117 | bucket says today. The converge log line prints `open`/`set`, never the |
| 118 | hash — it is not a secret in the strong sense (§4.3), but it has no business |
| 119 | in a log. |
| 120 | |
| 121 | ## 4. The edge |
| 122 | |
| 123 | ### 4.1 The step, and its place in the chain |
| 124 | |
| 125 | The hash rides `up.route.Gate` — the `upstream` entry already carries the |
| 126 | whole row, and an alias copies the entry, so nothing new is folded. |
| 127 | `ServeHTTP` gains one step, placed **after** `lookupUpstreamPath` resolves |
| 128 | `up` and the usage meter wraps `w`, and **before** the upgrade branch: |
| 129 | |
| 130 | ```go |
| 131 | if up.route.Gate != "" && !e.gate.Admit(w, r, up.route.Host, up.route.Gate) { |
| 132 | return |
| 133 | } |
| 134 | ``` |
| 135 | |
| 136 | The placement is the whole design and each neighbour is load-bearing: |
| 137 | |
| 138 | - **Before the upgrade branch**, so a WebSocket handshake is gated. The |
| 139 | handshake is a GET that carries cookies; the check reads the cookie and |
| 140 | either lets the handshake through untouched (the writer stack is exactly |
| 141 | what it was) or answers 401 before any hijack. |
| 142 | - **Before the cache check**, so a cache hit is only ever served to a |
| 143 | request that already passed. The shared LRU keys on host and path, not on |
| 144 | cookies; a gate behind the cache would let one visitor's authenticated |
| 145 | fetch of `/` warm the cache for the world. |
| 146 | - **Before the activator**, for the reason in §2. |
| 147 | - **After the meter**, so the 401 page's bytes are billed to the route like |
| 148 | any other response the route caused. It is the tenant's page. |
| 149 | - **After the steering probe, the tunnel hostname and the hop acceptance**, |
| 150 | which do not resolve a route and have nothing to gate. A backhauled |
| 151 | request is gated on the box that owns the row, where `ServeHTTP` runs |
| 152 | after `acceptHop`; the sending edge has no row and makes no decision. |
| 153 | |
| 154 | `Admit` returns true when the request carries a valid cookie, after |
| 155 | **removing the gate cookie from the request** so the app never sees it. It |
| 156 | returns false when it has already written a response: the login page, the |
| 157 | redirect after a successful POST, or the too-many-attempts page. |
| 158 | |
| 159 | ### 4.2 The reserved path |
| 160 | |
| 161 | `POST /.carlos/gate` on a gated host is the login form's action. Fields: |
| 162 | `password`, `next`. `next` is accepted only if it starts with a single `/` |
| 163 | (not `//`, not a scheme, no control bytes), else it becomes `/`; that is |
| 164 | the entire open-redirect surface and it is closed at the door. |
| 165 | |
| 166 | **The edge routes a login POST by `next`, not by its own path** (review |
| 167 | finding, 2026-09-07). A hostname that routes by path prefix resolves each |
| 168 | request's first segment to a target route; `/.carlos/gate` has no such |
| 169 | segment and would fall through to the front host's own route — so a gated |
| 170 | prefix target could never be logged into, and the password would be |
| 171 | proxied to whatever app fronts the hostname. So, for exactly `POST |
| 172 | /.carlos/gate`, the edge parses the form (bounded, 4 KiB) and resolves the |
| 173 | upstream against the path in `next`, which is the path the visitor was |
| 174 | refused on. On a host with no prefix map this resolves to the same route |
| 175 | the request path would have; on a prefix host it resolves to the target |
| 176 | the visitor was trying to reach, whose hash judges the password and whose |
| 177 | host signs the cookie (§4.3). |
| 178 | |
| 179 | **A login POST to an ungated route is a 404, never proxied.** The page is |
| 180 | only ever rendered by a gated route, so a POST that reaches an ungated one |
| 181 | is a stray or a probe; forwarding a form field named `password` to an app |
| 182 | that never asked for it is the one outcome to rule out. On every other |
| 183 | method the path is ordinary and reaches the app. |
| 184 | |
| 185 | Outcomes: |
| 186 | |
| 187 | - Right password → `Set-Cookie`, `303 See Other` to `next`. |
| 188 | - Wrong password → the login page with the error line, status 401. |
| 189 | - Over the attempt limit → the too-many-attempts page, status 429, with |
| 190 | `Retry-After: 60`, and the password is **not** compared. |
| 191 | |
| 192 | The limit is a per-client, per-host token bucket held in memory on the edge: |
| 193 | 10 attempts per minute, keyed on the request's remote address and the route |
| 194 | host. bcrypt at cost 10 already costs tens of milliseconds a comparison; the |
| 195 | bucket exists so the CPU is spent on people and not on a script. A |
| 196 | backhauled request's remote address is the sibling edge's, which folds every |
| 197 | hop from that edge into one bucket; accepted for now, because the fold only |
| 198 | ever makes the limit stricter, and named here so it is not rediscovered. |
| 199 | |
| 200 | ### 4.3 The cookie |
| 201 | |
| 202 | Name `carlos_gate`; `Path=/`; `HttpOnly`; `Secure`; `SameSite=Lax`; |
| 203 | `Max-Age` seven days. Value: |
| 204 | |
| 205 | v1.<expiry unix seconds>.<base64url(HMAC-SHA256(key, host "\n" expiry))> |
| 206 | |
| 207 | with `key = HMAC-SHA256("carlos-gate-cookie-v1", hash)` — derived from the |
| 208 | stored bcrypt hash and nothing else. Verification is one HMAC and a |
| 209 | constant-time compare (`hmac.Equal`), no bcrypt, no lookup. |
| 210 | |
| 211 | Deriving the key from the hash is what makes the design stateless and gives |
| 212 | it its one rotation rule: **change or clear the password and every cookie |
| 213 | minted under the old one stops verifying at once**, on every box, with no |
| 214 | key object to mint, mirror or rotate. The mesh key exists because the edges |
| 215 | have to agree on a secret nobody else holds; here the secret every edge |
| 216 | already holds — the hash, in its own registry — is enough. |
| 217 | |
| 218 | The trust it implies, stated so it is a decision and not a surprise: anyone |
| 219 | who can read the hash can mint a cookie without knowing the password. The |
| 220 | hash is readable by root on the box and by a bucket reader. Both already |
| 221 | hold the tenant's sealed secrets and the edge's TLS keys; a gate cookie |
| 222 | adds nothing to what they could do. It is why §5.4 keeps the hash out of |
| 223 | every API answer, where the reader is a member, not the platform. |
| 224 | |
| 225 | `host` in the MAC is `up.route.Host`, the row's host, not the request's — |
| 226 | so a path-routed prefix and its target agree on which key signs. Browsers |
| 227 | scope the cookie to the hostname they were served from anyway, so an alias |
| 228 | and its primary each log in once. |
| 229 | |
| 230 | ### 4.4 The page |
| 231 | |
| 232 | A new package, `internal/gate`, owning `Admit`, the cookie, the limiter and |
| 233 | the page. One embedded `html/template` (`login.html`) with the stylesheet |
| 234 | inline — a single response, no asset path to reserve — using the palette |
| 235 | and dark-mode flip of `internal/status/assets/status.css`, which is the |
| 236 | platform's neutral look. It is not the console's template set: the console |
| 237 | is a different binary, and the edge takes no dependency on it. |
| 238 | |
| 239 | Response headers on every page the gate writes: `Cache-Control: no-store`, |
| 240 | `X-Robots-Tag: noindex, nofollow`. `HEAD` gets the status and headers with |
| 241 | no body. The 401 is deliberate over a 200-with-a-form: an uptime monitor, a |
| 242 | crawler or a `curl` sees at a glance that it was not let in. |
| 243 | |
| 244 | The page, entire — hostname and error rendered from the template, nothing |
| 245 | else dynamic: |
| 246 | |
| 247 | > **Password required** |
| 248 | > `<host>` is private. Enter the password to continue. |
| 249 | > [Password] [Continue] |
| 250 | > |
| 251 | > error line, wrong password: *That password is not right.* |
| 252 | > error line, over the limit: *Too many attempts. Try again in a minute.* |
| 253 | |
| 254 | No product name, no logo, no link out. The member is fronting their own |
| 255 | thing; the page belongs to their hostname. |
| 256 | |
| 257 | ## 5. The doors |
| 258 | |
| 259 | ### 5.1 One set of rules, two doors |
| 260 | |
| 261 | The password rules live in `internal/gate` and both doors call them: |
| 262 | |
| 263 | ```go |
| 264 | // CheckPassword is the door's rule: 8..72 bytes (72 is bcrypt's input |
| 265 | // limit; longer is refused rather than silently truncated). |
| 266 | func CheckPassword(p string) error |
| 267 | // Hash is bcrypt at cost 10. |
| 268 | func Hash(p string) (string, error) |
| 269 | ``` |
| 270 | |
| 271 | The API PATCH applies them inside its existing read-mutate-validate-write |
| 272 | flow (a PATCH may carry other fields in the same body, so it keeps its one |
| 273 | write). The Settings form handler has the same read, write and audit |
| 274 | sequence of its own, against one field. Shared, and enforced at both: |
| 275 | |
| 276 | - `CheckPassword` before anything is hashed; |
| 277 | - the record must exist (404, byte-identical to the family's other 404s); |
| 278 | - CAS loss is a 409, as for every other record write; |
| 279 | - audit detail names `gate` among the changed fields (PATCH) or reads |
| 280 | `set password gate on <host>` / `cleared password gate on <host>` with a |
| 281 | `(console)` suffix (form). The password is never in the detail, the log, |
| 282 | or an error message. |
| 283 | |
| 284 | ### 5.2 The API PATCH |
| 285 | |
| 286 | `handleAPIInstanceUpdate`'s body gains `Gate *string \`json:"gate"\``. Absent |
| 287 | leaves the gate alone; `"gate": ""` clears; `"gate": "<password>"` sets. The |
| 288 | handler routes a present `Gate` through `setInstanceGate` and appends `gate` |
| 289 | to the audited `changed` list. Everything else about the PATCH is unchanged, |
| 290 | including the shape rail and the all-nil 400 (which now names `gate` too). |
| 291 | |
| 292 | The plaintext crosses the wire once, to the console, over TLS, the same as |
| 293 | a sign-in. The alternative — hashing in the CLI and sending the hash — would |
| 294 | make the CLI and the Settings form two hashers to keep in step, for no gain |
| 295 | a TLS client would notice. |
| 296 | |
| 297 | ### 5.3 The CLI |
| 298 | |
| 299 | `carlos gate`, a new top-level verb shaped like `carlos features`: API mode |
| 300 | only, sub-verbs as positional words that never reach `fs.Parse`. |
| 301 | |
| 302 | carlos gate set -app <a> [-host <h>] [-password <p>] |
| 303 | carlos gate clear -app <a> [-host <h>] |
| 304 | carlos gate list -app <a> |
| 305 | |
| 306 | `set` without `-password` reads one line from stdin, so a password can be |
| 307 | piped or typed without landing in the shell's history or `ps` output; an |
| 308 | empty line is refused. When stdin is a terminal the verb prints `Password:` |
| 309 | to stderr first; what is typed echoes, deliberately, because a no-echo |
| 310 | prompt needs `golang.org/x/term`, which the module does not depend on, and |
| 311 | a platform-specific termios call is exactly what the cross-compile gate |
| 312 | exists to keep out. The session is resolved before the read, so a |
| 313 | logged-out user is told before typing a secret. Without `-host`, `set` and |
| 314 | `clear` apply to **every** instance of the app, one PATCH each, and print |
| 315 | each host as it goes; a failure part way is reported per host and the verb |
| 316 | exits non-zero, with nothing rolled back — the record is per host and the |
| 317 | operator re-runs. `list` prints host and one of three states: |
| 318 | `open`, `password set`, or `unknown — console predates gates` when the |
| 319 | reply carries no `gated` key at all (§9). |
| 320 | |
| 321 | `-account`, `-account-id`, `-console` as every other API verb. |
| 322 | |
| 323 | ### 5.4 The hash never leaves the console |
| 324 | |
| 325 | Every response that carries a record goes out with the hash shadowed. The |
| 326 | list and GET already answer with `instanceListRecord`, which embeds |
| 327 | `provision.Record`; it gains two fields, and the PATCH, create and steering |
| 328 | replies — which write a bare record today — answer with a small view built |
| 329 | the same way: |
| 330 | |
| 331 | ```go |
| 332 | // On instanceListRecord and on apiRecordView (the write replies): |
| 333 | Gate string `json:"gate,omitempty"` // shadows the embedded field; always "" |
| 334 | Gated bool `json:"gated"` // what a member may know |
| 335 | ``` |
| 336 | |
| 337 | An outer field shadows the embedded one for `encoding/json`, so the hash |
| 338 | marshals as nothing and `Gated` says what the reader needs. A test pins |
| 339 | every writer: a record with a hash serialises with `"gated":true` and no |
| 340 | `"gate"` key, from each of the four handlers. |
| 341 | |
| 342 | ### 5.5 The web console |
| 343 | |
| 344 | The app's **Settings** page (`app_settings.html`, `handleAppSettings`) gains a |
| 345 | "Password gate" section under the existing app-level sections. One row per |
| 346 | instance record: the host, its state, and a form — |
| 347 | |
| 348 | <host> Open [password ........] [Set password] |
| 349 | <host> Password set [password ........] [Change] [Turn off] |
| 350 | |
| 351 | — `POST /a/{sqid}/apps/{app}/gate` with `csrf`, `host`, `action` (`set` or |
| 352 | `clear`), `password`. The handler is `handleAppGate`, shaped exactly like |
| 353 | `handleAppPlace`: `accountApp`, constant-time CSRF check, then |
| 354 | `setInstanceGate`, then `setFlash` and a 303 back to Settings. Role: any |
| 355 | **member**, matching the API PATCH's door (`apiAccountApp`), because the CLI |
| 356 | already lets a member do this and a form that refuses what the CLI allows |
| 357 | is a rule that lives in the wrong place. The password field is |
| 358 | `autocomplete="new-password"` so browsers do not offer to save it against |
| 359 | the console. |
| 360 | |
| 361 | Flash copy: *Password set on <host>.* / *Password turned off on <host>.* / |
| 362 | the refusal's message. |
| 363 | |
| 364 | The section reads its rows from `provision.ListRecords` for the app; an app |
| 365 | with no records shows one line — *No instances yet. A password gate is set |
| 366 | per instance.* |
| 367 | |
| 368 | ## 6. Docs |
| 369 | |
| 370 | - `docs/site/cli.md` gains `### carlos gate` under "Your apps", after |
| 371 | `### carlos features`: one paragraph and a fenced example, in the page's |
| 372 | voice. `internal/docsite`'s test fails the build without it. |
| 373 | - The `### carlos instances` paragraph gets one sentence pointing at `gate`. |
| 374 | - The console Settings section needs no doc; it is on the page. |
| 375 | |
| 376 | ## 7. Tests |
| 377 | |
| 378 | Registry: the column round-trips (`TestUpsertRoundTripsAllColumns`), |
| 379 | `SetGate` sets and clears, `Upsert` with an empty gate preserves a set one, |
| 380 | `Fingerprint` changes when the gate does. |
| 381 | |
| 382 | Provision: `ValidateShape` accepts a bcrypt hash, refuses a non-hash; |
| 383 | reconcile converges set→clear and clear→set and logs `open`/`set` only; |
| 384 | an absent field leaves an open host open and closes a set one (it is a |
| 385 | string, not a pointer — the test says so). |
| 386 | |
| 387 | Edge (`internal/edge`, alongside the cache and activator tests): |
| 388 | - an ungated route is byte-for-byte unchanged (no cookie read, no header); |
| 389 | - a gated route with no cookie gets 401, `no-store`, `noindex`, and **the |
| 390 | activator is never called**, **the cache is never consulted**; |
| 391 | - a right POST sets the cookie and 303s to `next`; a bad `next` becomes `/`; |
| 392 | - a valid cookie passes, and the upstream sees no `carlos_gate` cookie; |
| 393 | - an expired cookie, a cookie for another host, a cookie minted under the |
| 394 | previous hash — all 401; |
| 395 | - a WebSocket handshake without the cookie is 401, with it reaches the |
| 396 | proxy; |
| 397 | - an alias of a gated primary is gated; |
| 398 | - the eleventh attempt in a minute is 429 without a bcrypt compare (a |
| 399 | counting fake for the comparer); |
| 400 | - a `HEAD` gets 401 and no body. |
| 401 | |
| 402 | Gate package: cookie mint/verify round trip, key changes with hash, MAC is |
| 403 | host-bound, expiry honoured, limiter refills. |
| 404 | |
| 405 | Console: PATCH round-trip sets and clears, refuses short and over-long, |
| 406 | refuses clear+password, audits with no password in the detail; every record |
| 407 | response has `gated` and no `gate` key; the Settings form sets and clears |
| 408 | behind CSRF and a session, and a non-member gets the family's 404. |
| 409 | |
| 410 | CLI: `gate set` fans out to every instance and stops with a non-zero exit |
| 411 | naming the host that failed; `list` renders both states. |
| 412 | |
| 413 | ## 8. Not built, on purpose |
| 414 | |
| 415 | - **Basic auth for `curl`.** A cookie flow serves browsers; anyone scripting |
| 416 | against a gated prototype can POST once and keep the jar. |
| 417 | - **Per-path exemptions** (`/healthz` open, the rest gated). The platform's |
| 418 | own health probe goes straight to the upstream and never meets the gate; |
| 419 | an external monitor can carry the cookie. |
| 420 | - **Per-user passwords, logout, "remember me" longer than a week.** Every |
| 421 | one of these is the start of an identity product; the app is where that |
| 422 | lives. |
| 423 | - **A gate on the app manifest** (every instance at once, forever). The |
| 424 | CLI's fan-out gives the same day-one result without a second source of |
| 425 | truth to reconcile. |
| 426 | - **`carlos route --gate` for operator routes with no record.** Every member |
| 427 | route is a record. An operator route is platform property, and the |
| 428 | first person to want one gated can add the flag beside `--hibernate`. |
| 429 | - **Reporting "enforced" as distinct from "set".** `gate list` reports the |
| 430 | record; the box's converge line is the confirmation. Cross-box truth is |
| 431 | `internal/fleetapi`'s problem (#145). |
| 432 | |
| 433 | ## 9. Rollout |
| 434 | |
| 435 | The column is additive and defaults to open, so nothing changes on a box |
| 436 | that rolls with no records carrying a gate. The order that matters is |
| 437 | **boxes before console**: a console that writes `gate` into a record before |
| 438 | the boxes roll produces a record the old reconcile decodes without the |
| 439 | field — and a member who just set a password on a host that is, for the |
| 440 | length of the gap, still open. Roll the box binary (edge and host agent are |
| 441 | one binary) across the fleet, read a converge line, then roll the console. |
| 442 | |
| 443 | The CLI is inert against an old console: a PATCH carrying `gate` to a |
| 444 | console that does not know the field is a 400 (`nothing to update`), which |
| 445 | the verb reports as the console being older than the verb. |
| 446 | |