Client contracts: authentication, API behaviour and compatibility¶
Status: accepted by the owner on 25 October 2026 at 21:15 CET, in the owner's workflow
session: "A: accept now". Option A accepted the client contracts as they stood at daa4393, as
assessed in the acceptance assessment.
This document specifies what any Bronzeward client, the
bronzewardctl command-line client and the web interface alike, must do against the existing
management API, for
Specify client authentication, API behavior and compatibility
in 04 - Operator experience: CLI and web UI.
It implements no client, adds no route and changes no role, credential policy or server
behaviour. Each design decision it needs is listed in §13
with its options and how it was decided: five by the lead, the other six by the owner. The
owner's choices of CLI language, frontend framework, reference identity provider and browser
support matrix are recorded in §15.
It builds on the operator journeys of operator experience, proposed for
Design the operator journeys for bronzewardctl and the web UI.
References of the form journeys §8 and gap IDs G1-G12 point at that document. Its
G13-G16 appear here as G21-G24, and its G17 is this document's G18; this document
adds G13-G17, G19 and G20.
Facts and proposals are kept apart. A statement about existing behaviour cites the contract
section or the source line it comes from, read at revision 3cbe953 (internal/api/api.go:382
means that file and line), except the code
#277 adds or changes, the identity read
(internal/api/identity.go, internal/api/routes.go:97, internal/api/api.go:339), which is
read with it applied, and the expected-epoch check
#293 added, cited in G21's disposition, §8.4
item 3, D10 and A6, which is read with that change applied. A requirement this document proposes is
marked Proposed; must
in a Proposed item is a requirement on the clients once the proposal is accepted, not a claim
about code that exists. A behaviour the API does not support today is listed in
§4 and is never presented as available. Statements that rest on outside
standards or on reasoning without a citation are listed in §15.
Contents¶
- 1. Scope and exclusions
- 2. The API a client meets
- 3. Action, endpoint and role matrix
- 4. API gap audit
- 5. Endpoint discovery and context selection
- 6. TLS trust
- 7. Compatibility and versioning
- 8. Request handling
- 9. Authentication
- 10. Browser security
- 11. Protected input
- 12. Shared conformance cases
- 13. Alternatives and open choices
- 14. Proposed API-gap issues and downstream refinement
- 15. Assumptions and limits
1. Scope and exclusions¶
In scope, as the issue states: an action-to-endpoint and role matrix with a gap audit against the current routes and schemas; endpoint discovery and context selection, TLS trust, client and server compatibility, errors, pagination, ETags, idempotency across ambiguous responses, epoch changes and event resumption; OIDC sign-in, renewal and sign-out for the CLI and the browser, token handling and automation token input; protected ingestion input and review handling; and conformance cases both clients share.
Kept unchanged, as the issue requires:
- Bronzeward is an OAuth 2.0 resource server that verifies each request on its own and keeps no server session (persistence and API §10.1).
- Five capability roles, human roles from identity-provider groups, automation limited to
viewer,authorandpublisher(design §13.7;internal/auth/tokens.go:372-391). - Human-only routes refuse automation whatever its roles (
internal/api/api.go:382-383). - Automation tokens are issued, rotated and listed, and revoked without revoking their identity,
only by the server-side command; no route does any of that
(persistence and API §10.2). An
identity revocation through the API revokes a service identity's token with it (same section;
internal/auth/identity.go:66-69, reached frominternal/api/revocations.go:113). The comment atcmd/bronzeward/token.go:36-37still claims no route revokes a token; it predates that and is stale. The revocation is theidentity revokeaction of §3.2, a recovery-admin act, not token administration. Neither client offers token administration.
Excluded: a server session or backend-for-frontend, direct provider or Talos access from a client, a new credential policy, the decisions of #265 and #266, and any client implementation. #162 concerns Talos access and does not supply this client sign-in (talosctl-oidc research, follow-ups and limits); its live observations of one identity provider are used below only as evidence about token behaviour.
2. The API a client meets¶
The facts every later section relies on, read from the code and the contracts.
| Topic | Fact | Source |
|---|---|---|
| Base path | Routes are METHOD /api/v1<pattern>; a request no route takes is 404 not-found, after authentication, or 409 recovery-mode-active before a recovery start's entry commits |
internal/api/api.go:230-236, internal/api/api.go:247-254 |
| Anonymous surface | GET and HEAD /livez answer 204 with no body and no authentication; every other request authenticates first, an unknown path and OPTIONS included |
internal/server/server.go:10-24, internal/api/api.go:260-272 |
| Transport | The server listens on plain http; TLS terminates in front of it | internal/server/server.go:34-42; persistence and API §9.1 |
| Timeouts | Reading a request, body included, 30 s; headers 10 s; idle 2 min; no write timeout (the event stream outlives any fixed bound) | internal/server/server.go:27-42 |
| Request body | Every POST and PUT sends one JSON object ({} when the route takes no member) with Content-Type: application/json, at most 1 MiB, unknown members refused, each violation 400 invalid-request; a DELETE with a body is 400, while its Content-Type is not checked (clients send none) |
internal/api/body.go:36-72, internal/api/drafts.go:44; persistence and API §9.1 |
| Success body | application/json |
internal/api/reads.go:35, internal/api/idempotency.go:85 |
| Errors | application/problem+json with type urn:bronzeward:problem:<code>, title, status, instance (the request ID, also logged), optional detail and extension members; detail never names a value |
internal/api/problem.go:76-98; persistence and API §9.4 |
| Request ID | Every API answer, authenticated or not, carries Bronzeward-Request-ID, equal to the instance of a problem the request answers; a replayed stored problem keeps the instance of the request that stored it, while the header names the replaying request; liveness carries none |
persistence and API §9.1 |
| Credential | Authorization: Bearer <token>; a value starting bwt_ is an automation token, anything else an OIDC access token |
internal/auth/auth.go:132-141, internal/auth/tokens.go:21 |
| Epoch headers | A response to an authenticated request carries Bronzeward-Epoch, and Bronzeward-Recovery-Mode: true while recovery mode was in effect, as the request read them: in the read that decided the answer where one did, otherwise in the installation-state read made once it authenticated; a committed recovery entry or exit names the epoch and mode it leaves in place, and an entry committing after the read can make a refusal name the epoch it replaced; 401, 403 identity-revoked, liveness and a 503 from authentication or from that installation-state read carry neither |
internal/api/api.go:267-283, internal/api/api.go:325-333, internal/api/problem.go:68-71; persistence and API §9.1 |
| Pagination | limit 1-500, default 50; opaque cursor; answer {"items": [...], "next": "..."} with next omitted on the last page; a cursor carries its epoch; one from another epoch, or one that does not parse, is 400 cursor-invalid; any other query key is 400 |
internal/api/acts.go:33, internal/api/acts.go:135-160, internal/api/reads.go:59-63, internal/api/reads.go:92-112 |
| ETags | Strong "<revision>-<token>", token replaced on every write; served on drafts and source heads; If-Match required on draft-scoped writes, publication and ingestion start; missing 428 precondition-required, mismatch 412 precondition-failed; no If-None-Match |
internal/api/routes.go:98-119, internal/api/api.go:407-416; persistence and API §4.1 |
| Idempotency | Every POST, PUT, DELETE needs one Idempotency-Key of 16-128 [A-Za-z0-9_-] (428 idempotency-key-required if absent); scoped per principal; same request replays with Idempotent-Replayed: true; another request under the key is 422 idempotency-key-reused; a record from an earlier epoch is 409 conflict; with no record, a Bronzeward-Expected-Epoch naming another epoch is 412 epoch-mismatch (§8.4 item 3, A6) |
internal/api/api.go:394-405, internal/api/idempotency.go:41-43, internal/api/idempotency.go:60-92, internal/api/idempotency.go:98-106; persistence and API §7.2 |
| Unknown commit | A commit whose outcome the server could not learn is 500 internal-error, "retrying with the same Idempotency-Key answers it" |
internal/api/tx.go:312; persistence and API §9.4 |
| Asynchronous work | Ingestion start, mark, continuation, takeover and publication answer 202 with an operation; plans and approvals 201 |
persistence and API §8, §9.2 |
| Events | GET /operations/{id}/events is a cursor-paged JSON list, or Server-Sent Events when Accept contains text/event-stream; event ID <epoch>:<number>; resume with Last-Event-ID; an ID from another epoch gets event: reset and a replay from the start; after 15 s with nothing sent the stream sends the comment line : keep-alive, with no ID and no event (persistence and API §8.3); the stream ends when the operation is terminal or at the opening token's expiry; unresolved is not terminal |
internal/api/operations.go:183-353; persistence and API §8.3 |
| Caching | Every API answer, authenticated or not, problems and the event stream included, sends Cache-Control: no-store; liveness sends no cache header |
persistence and API §9.1 |
| Browser support | No CORS headers, no Origin check, no cookie, no CSRF token anywhere in the server |
search of internal/ and cmd/ for Access-Control, Origin, Cookie; persistence and API §10.1 "no server session" |
| Identity read | GET /identity answers who the caller is, which roles it holds and the epoch, to any authenticated credential, one with no role included |
internal/api/routes.go:97, internal/api/api.go:339; persistence and API §9.2 |
| Version read | GET /server answers the answering process's version, API revision, features, start mode and read-only settings, to any role; refused before a recovery start's entry commits |
persistence and API §9.1, §9.2 |
3. Action, endpoint and role matrix¶
Every route of internal/api/routes.go:78-136 with the client action that uses it. No route
answers 501 today: each sets a read or an effect (internal/api/api.go:366-374). "Human"
means the route refuses automation with 403 forbidden and humanOnly: true
(internal/api/api.go:382-383). "Keyed" means the body carries unextracted input, fingerprinted
with the provider's digest key instead of SHA-256 (internal/api/routes.go:20-23). In the
"Start mode" column, "normal" means a process started for recovery refuses the route even after
its entry (internal/api/api.go:350-353), and "recovery" means a normal start refuses it
(internal/api/api.go:356-359).
3.1 Reads¶
Every read takes any role (internal/api/routes.go:60, internal/api/routes.go:65) except the
protected review and the identity read, which serves any authenticated credential, one that holds
no role included (internal/api/api.go:339).
| Client action | Route | Paged | ETag served | Notes |
|---|---|---|---|---|
| cluster list / show | GET /clusters, /clusters/{id} |
list | no | |
| machine list / show | GET /machines, /machines/{id} |
list | no | filter cluster; pendingConvergence (G24) (internal/api/reads.go:199-218) |
| machine observations | GET /machines/{id}/observations |
yes | no | |
| machine timeline | GET /machines/{id}/timeline |
yes, by revision, oldest or newest first | no | JSON only, no stream (G9); order, and tail, the forward cursor from the newest entry, API revision 3 (persistence and API §9.2) |
| fragment, profile, assignment list / show / history / revision | GET /{kind}s, /{kind}s/{id}, /{kind}s/{id}/revisions, /{kind}-revisions/{id} |
lists | head item only | internal/api/routes.go:83-87 |
| draft list / show | GET /drafts, /drafts/{id} |
list | item | the item's ETag is the one draft writes need |
| import list / status | GET /ingestions, /ingestions/{id} |
list | no | filters state, machine (internal/api/ingestions.go:280-299) |
| import review | GET /ingestions/{id}/review |
no | no | author, human; no-store; only while paused (internal/api/review.go:30-52) |
| release list / show | GET /releases, /releases/{id} |
list | no | |
| release review, provenance | GET /releases/{id}/machines/{m}/review |
no | no | redacted |
| plan list / show | GET /plans, /plans/{id} |
list | no | filters state, machine (internal/api/plans.go:429-451) |
| approval show | GET /approvals/{id} |
no | none, by design | approvals are immutable |
| operation list / show | GET /operations, /operations/{id} |
list | no | filters plan, machine, state, kind (internal/api/operations.go:95-108) |
| operation watch | GET /operations/{id}/events |
JSON pages, or SSE | no | §8.6 |
| act list | GET /acts |
yes | no | |
| recovery status | GET /recovery |
no | no | |
| dependency list / show / releases / alerts | GET /dependencies (class filter), /dependencies/{id}, /dependencies/{id}/releases, /dependencies/{id}/alerts, /dependency-alerts |
lists | no | |
whoami, roles in the web header |
GET /identity |
no | no | the caller's kind, issuer and subject or service, principal, roles, expiry and epoch; takes no query (internal/api/identity.go:22-59) |
| version check, server settings, start mode | GET /server |
no | no | the answering process's version, API revision, features, start mode and read-only settings (persistence and API §9.2); refused before a recovery start's entry |
3.2 Writes¶
| Client action | Route | Roles | Human | If-Match |
Success | Keyed | Start mode |
|---|---|---|---|---|---|---|---|
cluster add |
POST /clusters |
author | yes | 201 | |||
machine add |
POST /machines |
author | yes | 201 | |||
machine set-endpoint |
POST /machines/{id}/talos-endpoints |
author | yes | 201 | |||
import start |
POST /ingestions |
author | yes | draft's | 202 | document |
|
import mark |
POST /ingestions/{id}/marks |
author | yes | 202 | marks |
||
import continue / takeover |
POST /ingestions/{id}/continuations / takeovers |
author | yes | 202 | |||
import abandon |
POST /ingestions/{id}/abandonments |
author | yes | 200 | |||
draft create |
POST /drafts |
author | 201, ETag | ||||
fragment set / rm |
PUT / DELETE /drafts/{id}/fragments/{name} |
author | yes | 200, ETag | document (PUT) |
||
profile set / rm |
PUT / DELETE /drafts/{id}/profiles/{name} |
author | yes | 200, ETag | |||
assignment set / rm |
PUT / DELETE /drafts/{id}/assignments/{machine} |
author | yes | 200, ETag | |||
draft discard |
POST /drafts/{id}/discard |
author | yes | 200 | |||
publish |
POST /drafts/{id}/publications |
publisher | yes | 202 | |||
plan create, drift revert |
POST /plans |
publisher | 201 | normal | |||
plan cancel |
POST /plans/{id}/cancellations |
publisher (its own plan), approver, recovery-admin | 200 | ||||
approve |
POST /plans/{id}/approvals |
approver | yes | 201 | normal | ||
approval revoke |
POST /approvals/{id}/revocations |
approver, recovery-admin | 201 | ||||
machine freeze |
POST /machines/{id}/freezes |
author, publisher, approver, recovery-admin | 201 | ||||
machine unfreeze |
POST /machines/{id}/unfreezes |
approver | 201 | normal | |||
recovery enter |
POST /recovery/entries |
recovery-admin | yes | 201 | recovery | ||
recovery exit |
POST /recovery/exits |
recovery-admin | yes | 201 | |||
recovery mark / release |
POST /recovery/scopes/{machine}/marks / releases |
recovery-admin | yes | 201 | |||
recovery scope-account |
POST /recovery/accountings |
recovery-admin | yes | 201 | |||
recovery account |
POST /operations/{id}/attempts/{attempt}/accountings |
recovery-admin | yes | 201 | |||
recovery takeover / resolve |
POST /operations/{id}/takeovers / resolutions |
recovery-admin | yes | 201 | |||
identity revoke |
POST /identity-revocations |
recovery-admin | yes | 201 |
Sources: internal/api/routes.go:98-135; the success status each handler returns (for example
internal/api/inventory.go:172, internal/api/drafts.go:303, internal/api/ingestions.go:436,
internal/api/cancellations.go:144, internal/api/publications.go:76); and
persistence and API §9.2. The publisher's
cancellation of only its own plan is checked in the handler
(internal/api/cancellations.go:61-67). The ingestion start's If-Match is the target draft's
ETag (internal/api/ingestions.go:153-154).
3.3 Client actions with no route¶
| Client action | Why no route | Contract |
|---|---|---|
login, logout |
identity-provider and client concerns; the server keeps no session | §9 |
context |
client configuration | §5 |
| automation token issue, rotate, list | server-side command only | not offered by either client |
| dispatch, apply | the controller dispatches approved plans (design §12.7; §13.7 item 2) | not offered |
4. API gap audit¶
The seventeen gaps the journeys found, checked against the code above, plus seven this audit adds. G21-G24 are the journeys' G13-G16 (expected epoch, start mode, principal display name, convergence status), numbered last because they were added to the journeys last; the journeys' G17 is G18 here. Each has a disposition: an API issue proposed in §14, a contract correction to an existing specification, also in §14, a later design issue out of this milestone, also in §14, a client rule this document states, a client composite over existing routes, an accepted limitation, or a decision listed in §13. The owner accepted every disposition, including "accepted", with this document. Nothing here is presented to a client as supported.
| ID | Gap | Evidence | Disposition |
|---|---|---|---|
| G1 | No identity, role or capability read; roles appear only in a 403 body |
internal/api/api.go:380-392; before #277, no route in internal/api/routes.go:78-136 |
closed by API issue A1 (#277): GET /identity serves the caller's identity, roles and epoch (internal/api/identity.go:22-31), a credential with no role included (internal/api/api.go:339) |
| G2 | No server version or compatibility read; only anonymous /livez |
before #278: internal/server/server.go:19-22 |
closed by API issue A2 (#278): GET /server serves the version and the API revision (persistence and API §9.1, §9.2) |
| G3 | An operation does not name its ingestion; only the ingestion side links them: its read serves operation, and the start, mark and takeover 202 bodies name both |
before #280, the operation's subject was a draft or a plan and machine, never an ingestion (internal/api/reads.go:358-373, internal/api/reads.go:423-425); the ingestion side: internal/api/ingestions.go:235, internal/api/ingestions.go:256-260, internal/api/ingestions.go:217-218, internal/api/marks.go:106, internal/api/ingestions.go:355 |
closed by API issue A4 (#280): an ingest operation's subject names its ingestion (internal/api/reads.go:346, internal/api/reads.go:396-398) |
| G4 | A draft does not name the release it produced | the draft read selects no release (internal/api/reads.go:231) |
accepted limitation: the publish operation's result.release answers it |
| G5 | No render of a draft before publication | no such route in internal/api/routes.go:78-136 |
later design issue L1: a compilation design change, not a client contract |
| G6 | Provenance covers secret-reference occurrences only, not "which fragment set this field" | compilation §8.1 attributes stand-ins to reference occurrences | later design issue L1, as G5 |
| G7 | Drift content is not served | the machine's drift and observation reads name the observation, its time and a matched release, and no configuration (internal/api/drift.go:150-155, internal/api/inventory.go:251-257, internal/api/observations.go:16-17) |
later design issue L2: serving observed configuration is a redaction design question; until then clients use a drift-adoption import with review |
| G8 | Machines have no human-readable name | the machine resource has no name member (internal/api/inventory.go:260-275) |
accepted limitation for this milestone; clients show endpoint and node ID |
| G9 | No list filters on machines, plans, releases, drafts or acts; no machine timeline stream | internal/api/acts.go:135-145 refuses any key but limit, cursor and the route's filters; before #280, filters existed only on operations and dependencies |
API issue A4 for the filters the journeys need first: plans by state and machine, machines by cluster; accepted limitation for the release, draft and act filters, which clients apply over the pages they read; timeline polling accepted. A4's filters are served (#280): plans by state and machine (internal/api/plans.go:429-451), machines by cluster (internal/api/reads.go:199-218) |
| G10 | No ingestion list | before #280: internal/api/routes.go:90 |
closed by API issue A4 (#280): GET /ingestions, filtered by state and machine (internal/api/ingestions.go:280-299) |
| G11 | Recovery marks, releases and accountings have no list route | internal/api/routes.go:128-135 |
accepted limitation: GET /recovery and the machine timeline |
| G12 | No browser access path: no CORS, and every request including a preflight OPTIONS must authenticate |
internal/server/server.go:10-13, internal/api/api.go:260-272; §10.1 |
decision D1, decided (a): same-origin hosting, no API change |
| G13 | Authenticated reads set no Cache-Control; a browser may keep them in its HTTP cache |
internal/api/review.go:31, internal/api/operations.go:281 were the only Cache-Control writes |
API issue A3; served: §2 caching row |
| G14 | No anonymous way to learn the issuer, audience and client registration a deployment expects | §2 anonymous row | decision D3, decided (b): a static client-configuration file next to the web files (§5 item 4) |
| G15 | The contract's prose describes the reset stream event, but its ingest and publish event-type lists omit it, so a client built from the lists does not expect it |
internal/api/operations.go:299-301; persistence and API §8.3 |
contract correction C1 (documentation only); applied |
| G16 | No request-ID response header: a client learns the request ID only from a problem's instance |
internal/api/problem.go:84-90; no other header set |
API issue A3; served: §2 request-ID row |
| G17 | A request with an unsupported method on a known path is answered by the catch-all 404, not 405 |
internal/api/api.go:234-236 (inferred from ServeMux method patterns; untested) |
accepted; clients treat 404 not-found on a known route as "not supported by this server" only together with A2's revision read (§7) |
| G18 | The settle floor and transport deadlines that bound recovery accounting are deployment settings no route serves before an accounting decision, which is when a client needs them to compute when the end observation may begin (the journeys' G17). After one, a successful accounting's timeline facts carry settleFloor and anchor, and the settle-floor refusal states the floor and its anchor only, so no route serves the maximum transport deadline at all. Separately, on a normal start a plan-creation refusal states the maximum transport deadline at any time, before a decision too; a recovery start refuses plan creation before that check |
execution and recovery §5.2; examples/bronzeward.yaml:29-44; after a decision: internal/api/settle.go:160-161, internal/api/recovery_accountings.go:209-211, internal/api/recovery_accountings.go:230-231, internal/api/settle.go:148-150; the maximum: internal/api/plans.go:113-114, internal/api/routes.go:120, internal/api/api.go:350-352 |
folded into A2, closed by #278: GET /server serves the settle floor, the maximum transport deadline and the plan defaults, its transport deadline included, before a decision too, and on a recovery start once its entry has committed (persistence and API §9.2) |
| G19 | The fixture issuer mints tokens but offers no authorization, token, device or revocation endpoint, so no interactive sign-in can be tested against it | fixtures/oidc/issuer/issuer.go:1-6 ("no login, no clients, no refresh"), fixtures/oidc/issuer/issuer.go:113-126 (discovery and key set only), fixtures/oidc/main.go:25-29 |
validation-fixture issue A5; served (#281): the pinned test identity provider of the fixtures' README and its sign-in self-test (internal/api/idp_test.go). Provider residuals, recorded in the Zitadel test-provider record: no refresh-token reuse detection; any origin reflected with credentials, no per-app allowlist; a revoked session's access token still accepted by the server's local verification until exp plus its 60 s leeway (internal/auth/auth.go:95), so at most 6 minutes; end-session leaving the refresh token usable, so the proposed web sign-out revokes it (§9.7 item 2) |
| G20 | 503 answers carry no Retry-After, and the contract gives no retry guidance for dependency-unavailable, transient-conflict or epoch-superseded |
search of internal/ for Retry-After; persistence and API §9.4 |
client rule in §8.2; no API change proposed |
| G21 | Before #293, a human's mutation took no expected-epoch precondition: only Idempotency-Key and If-Match were checked, and the epoch was reported only in an answer. After a write's answer is lost, and either a restore removes its record or the first attempt never committed, the same request with the same key executed afresh in the new epoch, because the in-transaction lookup finds no record (persistence and API §12.4); the read before repeating of §8.4 item 3 left a race. A service principal is checked through its token: it carries its epoch, and the write's own transaction refuses one from another epoch with 401 |
before #293: internal/api/api.go:396-418, internal/api/tx.go:190-198; service check: internal/auth/automation.go:51-52, internal/api/idempotency.go:28-30, internal/api/idempotency.go:47-49, internal/api/api.go:305-311 |
closed by API issue A6 (#293), D10 option (a): a mutation may carry Bronzeward-Expected-Epoch, one non-empty header (internal/api/api.go:426-433), and a lookup that finds no record refuses another epoch 412 epoch-mismatch, naming the current one and committing no act or idempotency record (what the route committed before that lookup stays, persistence and API §7.2); only a lookup under the key's lock refuses, so a key with a record still answers it, AMB-07's 409 included (internal/api/idempotency.go:52-58, internal/api/tx.go:71-81, persistence and API §7.2) |
| G22 | No read of the process's start mode. GET /recovery serves epoch, recovery mode, entry, exit and scopes, and the headers carry only Bronzeward-Epoch and Bronzeward-Recovery-Mode. Past the role check, a recovery start shows only in an answer: a 409 recovery-mode-active without a scope member, to any request but the entry before the entry commits and to plan creation, approval or unfreeze after it, or any recovery-entry answer except its not-a-recovery-start refusal. A normal start shows only in that refusal: a plain 409 conflict with neither an epoch nor a request member, while every other 409 conflict the entry answers carries epoch. The scope refusals that share the recovery-mode-active code carry scope |
internal/api/recovery_exits.go:123-129, internal/api/api.go:326-332; process refusals: internal/api/api.go:242-244, internal/api/api.go:345-359, internal/api/recovery_exits.go:211-213, internal/api/routes.go:120-127; entry: internal/api/recovery_entries.go:91-92, internal/api/recovery_entries.go:106-108, internal/api/recovery_entries.go:133-135, internal/api/idempotency.go:65-67; scope refusals: for example internal/api/plans.go:226-227 |
closed after the entry by API issue A2 (#278): GET /server serves startMode (persistence and API §9.2). Before a recovery start's entry commits that read is refused like every other request, so the client reading of §8.5 stays for that window. That reading rests on the absent scope member, which the contract said every recovery-mode-active carried until contract correction C2, now applied |
| G23 | No principal display name: bodies carry principal identifiers only, and no route maps one to a subject or name | internal/api/acts.go:19-30, internal/api/releases.go:53, internal/api/plans.go:195, internal/api/approvals.go:43, internal/api/recovery_entries.go:79; no such route in internal/api/routes.go:78-136 |
API issue A7, served as GET /principals from API revision 4 (persistence and API §9.2) by #335; until a client's views resolve identifiers with it, they show shortened principal identifiers |
| G24 | No convergence status: the machine body serves desired and applied.release, and compares the artifact digests only to match an observation, never serving them, so a client cannot tell whether the Desired release's digest differs from the Applied one, which is what pending convergence means. After an adoption the Applied digest is the baseline's, so equal or different release IDs do not settle it |
before #280: internal/api/reads.go:155-166; execution and recovery §1, §6.3 |
closed by API issue A4 (#280): the machine's pendingConvergence compares the Desired artifact's digest with the Applied digest, true too for a Desired with no Applied, and serves neither digest (internal/api/reads.go:155-171) |
Not gaps, by design: no dispatch or apply route, no token administration route, no secret browser, and no WebSocket (polling and SSE only, persistence and API §8.3).
5. Endpoint discovery and context selection¶
Proposed. A context names one Bronzeward installation and how to reach and sign in to it. It holds no secret.
| Field | Meaning | Required |
|---|---|---|
name |
the operator's label | yes |
server |
base URL; the client appends /api/v1. A path prefix is allowed for deployments that route by path |
yes |
certificateAuthority |
path to a PEM bundle that replaces the system trust store for server (§6) |
no |
credential |
oidc or automation |
yes |
oidc.issuer, oidc.clientID, oidc.scopes |
the identity provider and the public client registered for this client (§9.1) | with oidc |
oidc.audience |
the value the server's auth.oidc.audience holds, used only for the client's own pre-check (§9.6) |
with oidc |
oidc.certificateAuthority |
PEM bundle for the issuer, if not the system store | no |
oidc.tokenFile |
path to the owner-only token file of the D2 (b) opt-in; its presence is the opt-in (§9.2 item 3) | no |
automation.tokenFile |
path to the automation token (§9.4) | with automation, unless given per invocation |
lastEpoch |
the last Bronzeward-Epoch seen; not secret (§8.5) |
written by the client |
- The CLI keeps contexts in a configuration file under the user's configuration directory, with
one current context. Precedence:
--context, thenBRONZEWARD_CONTEXT, then the current context. The file holds no token, key or secret; tokens live in the store of §9.2. - Every command that talks to a server shows the context's name and server in its error and wait output, so an operator with two installations never acts on the wrong one unseen. A write in an interactive session prints the context before it sends.
- The web interface serves exactly one installation, the one at its own origin (§10.1). Switching installations is navigating to another origin, which starts with an empty state; nothing read under one origin is shown under another.
- Today the issuer, client ID and audience are configured by hand (G14). Under decision D3 (b),
a static client-configuration file served next to the web files,
bronzeward-client.jsonat the web root, names them: a JSON object with the string membersissuer,audience,cliClientIdandwebClientId, the last two the client IDs of the two registrations of §9.1 item 2, and an optional string memberscopes: space-separated scopes requested afteropenid offline_access, as the CLI'soidc.scopes, for a provider that puts the audience into a token only when a scope asks for it (Zitadel's project audience scope is one). Absent is none; repeatingopenidoroffline_accessis a configuration error, as is any other member's wrong type (lead decision under the owner's 2026-10-07 grant, 26 October 2026, recorded in #332). The web client reads it from its own origin and uses the web client's ID, and the CLI imports it withcontext add --from <url>under the TLS rules of §6 and stores the CLI client's ID, and asoidc.scopesopenid offline_accessfollowed by the file's scopes unless--scopesnames others. Entering them by hand stays possible. The server is never asked for them through an authenticated route: the client needs them before it can authenticate. - A context's
serveris checked once when it is added:GET /livezmust answer204(internal/server/server.go:19-21), which shows only that something answers; it proves neither the API nor its version.
6. TLS trust¶
Proposed.
serverandoidc.issuermust behttps. Plainhttpis accepted only to a loopback address. This is the loopback half of the server's own rule for its issuer, which also admits hosts the deployment lists (internal/config/config.go:386-402); a client has no such list, because it cannot know which network protects a host. The development setup (examples/bronzeward.yaml:7,examples/bronzeward.yaml:14) works and nothing else does.- Certificates are verified against the system trust store, or against the context's
certificateAuthoritybundle, which then replaces it for that host. Host name verification is always on. TLS 1.2 is the minimum. - No option disables verification, in configuration or on the command line, and no environment variable does. An operator with a private CA adds the bundle to the context.
- A verification failure names the host, the subject and issuer of the presented certificate and
the trust source used, and sends nothing. What a TLS or transport error shows that the remote
side chose, such as a certificate's DNS names, which may hold any ASCII, is shown with each
control character and each byte that is not UTF-8 escaped (
\x1b), so it cannot write to the terminal. The escaping holds toward the server, toward an https proxy and toward the identity provider, in discovery, renewal and revocation, and for the server's own text in an error: the reason phrase of every status shown and each member of a problem document (internal/client/printable.go). It holds outside errors too, for every remote string a command shows (item 8 states that rule, with no list). The cases named first were: aDeprecationheader, the subject and issuer of a signed-in or renewed token, of the tokenwhoamireads and of the token a 401 is read for, the device grant'suser_codeand the server's version; a start modeGET /serverdoes not document,normalorrecovery, is shown as unknown. A server or issuer URL that holds a character the escaping would change is refused before any request uses it, as is such an endpoint or redirect the identity provider names. Toward the identity provider and the hostcontext add --fromreads, which may send a token in any of them, an error shows nothing those sent beyond the status code with its standard text and an error code RFC 6749, RFC 7009, RFC 8628 or OpenID Connect Core 1.0 defines: no reason phrase, body,error_description, header or value decoded from the answer, and of a redirect target or a named endpoint only its scheme and host. A transport failure is named by its type (refused, reset, timed out, name not resolved, TLS alert). A trust refusal by--fromor an https proxy still names the host, subject, issuer and trust source, fixed before anything is sent; a refused certificate's reason, on every path, is its host-name mismatch, which lists its names, or a fixed text (internal/client/remote.go). The standard library's log, which net/http writes a peer's text to, is discarded. The server's own text keeps the rule above. - Certificate pinning is not offered: a bundle per context limits trust enough for this milestone, and pinning a leaf breaks on every renewal.
- The browser uses the browser's own trust and has no override.
- Redirects and proxies. Neither client follows a redirect from the API, so
Authorizationand a request body never reach another URL and nothing is resent. The CLI reports a3xxwith its target. The web client issues every API call withfetch'sredirect: 'error', because a browser otherwise follows a307/308with the same method and body. The page then sees only a network error, which it cannot tell apart from any other transport failure (assumption S7): it shows a generic transport-failure message that names a refused redirect as one possible cause, never a target. For a write that error is an ambiguous outcome and is handled as in §8.4 item 2, since the page cannot tell whether the request reached the server. Toward the identity provider the CLI follows redirects only tohttps(or loopback) URLs, and none on a token revocation (§9.7 item 1). The CLI honours the standard proxy environment variables, says so in verbose output, and never sendsAuthorizationto a proxy in clear text: anhttpsURL goes through a proxy only by a tunnel. The server's own issuer fetches follow a comparable rule (persistence and API §10.1). - Remote text in human output. The CLI escapes every string that it shows in table, record
or sentence output (
-o table, the default) and that did not come from the CLI itself. That covers text from the server, the identity provider and a proxy, and text read from a file that the CLI did not write. It escapes the string before it formats it into a line, as follows: - Each control character (C0, DEL, C1) and each byte that is not UTF-8 becomes
\xNN, as in item 4. The tab and the line feed are included: they become\x09and\x0a. A value that holds either could otherwise move a column or forge a row. Only the separators that the CLI writes itself stay raw. Each bidirectional control (U+061C, U+200E, U+200F, U+202A to U+202E and U+2066 to U+2069) becomes\uNNNN, in item 4 too: it changes the order in which a terminal draws the text around it, so a value could read as another. A backslash in remote text stays as it is, as in item 4. A value that holds the text\x0ashows as that text. It looks like an escape but cannot act on the terminal, and escaping every backslash would change each value that holds one for no gain in safety. - The rule has no list of fields. An ID, a state, a name, a title, a principal, a reason, a
release identifier and a cell the CLI builds from such values are all remote text. The API
schemas give these members as plain strings with no format (
api/openapi.yaml,ClusterBodyandDraftBody:id,title,stateand the like), and the input rules differ from field to field:ClusterInput.namebans control characters, whileDraftInput.titlelimits only its length. A member with an enumeration or adate-timeformat, such asServerBody.startMode, is no exception: the format binds a correct server, not the bytes that arrive. A number or an array is not text, and the rule applies to the strings inside it. - A value that the CLI replaces or hides before it shows it is not shown as received, so the
rule has nothing to escape in it. An unrecognised start mode appears as
unknown(item 4), and the identity provider's error text appears only as the status, the known code or the transport cause that item 4 allows. - A string that the CLI builds into an error from a remote value, such as a cluster or draft ID in a refusal it words itself, follows the same rule. The client's own errors already follow item 4.
context listandcontext addshow names, servers and credential kinds from the contexts file. That file can come from someone else, so its text is remote text.- A document or a diff is split on the document's own line feeds. This is the one exception to
the line feed escape, because those line feeds are the document's lines. Each line is then
escaped, the tab included, and shown whole. A person sees a tab as
\x09and a carriage return as\x0d. The line itself is never rewrapped. - Every error passes one place before the CLI prints it. That place escapes all of the text, line feeds included, so an error from outside the CLI (a keyring or a library) cannot start a line of its own. The one exception is a multi-line error that the CLI builds itself, such as the outcome-unknown block: each of its lines is escaped and the CLI joins them.
- Everything the CLI writes to the terminal passes one last guard: standard error always (errors,
notices, prompts and the
-vlines, such as the proxy URL from the environment), and standard output in-o table. The guard writes every control character but the line feed as an escape. It cannot tell a line feed in a value from the CLI's own, so each place that prints a value escapes it first. A flag error shows its rejected value in the same escape form, not the quoted form of the Go parser. - A value that a message puts between double quotation marks, such as a draft title in
draft showor a context name in an error, also shows its own double quote as\x22. Without that, a value could close the quotes early and make its text read as the words that follow it. The Go%qverb did this for the draft title before item 8 replaced it. A backslash stays single, as above. - Where the CLI shows a value as JSON text that it encodes itself (a timeline entry, an operation result or error), the JSON encoder leaves DEL and the C1 range raw. The text is escaped again by this rule.
- A writer between the table writer and the terminal may escape ESC, the C1 range and bytes that are not UTF-8 as a second guard. It is not the rule, because it cannot tell a field's line feed or tab from the CLI's own separators.
- Before this item,
draft showwrote the title with%q(escaped) anddraft listwrote it raw, andcluster listwrote a cluster's name raw while the server's version was escaped. The rule removes that difference.
Required check. One test fills each remote source with a value that holds ESC, a tab, a
line feed, a carriage return, DEL, a C1 character, a bidirectional control and a byte that is
not UTF-8. The sources
are all the remote text that item 8 and item 4 name: every string member of every response type
in internal/client/apitypes, the response headers the CLI shows (Deprecation), the
identity provider's and the proxy's text, every member of the contexts file, and every remote
value that the CLI puts into an error it words itself. The response-type sweep is one part of
it. The implementation issue lists the sources, and a source that the list lacks is a defect of
the list. A source that the CLI replaces or hides (the start mode and the identity provider's
error text, above) is checked for its prescribed output instead: the test requires unknown,
or the status, code or transport cause, and fails if any injected byte appears. The test runs
each command that shows a source in -o table, and each such error branch. It fails in
any of these cases:
- The output holds a raw control byte outside the CLI's own separators.
- Outside documents, diffs and JSON text that the CLI encodes, a plain value does not appear in
its escaped form: \x1b for ESC, \x09 for the tab, \x0a for the line feed, \x0d for the
carriage return, \x7f for DEL, \x85 for a C1 character such as U+0085 and \u202e for
a bidirectional control such as U+202E. A replacement
such as ? fails. A byte that is not UTF-8 and survives decoding appears as \xNN with the
byte's own value. Documents, diffs and JSON text have their own forms, which the two last
cases below and the rules above state.
- The output holds an invalid UTF-8 sequence. The test injects a control character and a byte
that is not UTF-8 into every remote value that it can reach, the raw JSON members
OperationBody.Error and OperationBody.Result included, and checks this for every command
in -o table. Where decoding has already replaced the byte with U+FFFD, the check still
holds. It has no exemption by source.
- Outside documents and diffs, a line has a different number of columns, or the output a
different number of lines, from the same command run with plain values. This catches a tab
that becomes padding, which leaves no raw tab and no extra row.
- In a document or a diff, the lines are not those of the input split on its line feeds, each
escaped. The expected lines come from the input, not from the plain-value run.
- In JSON text that the CLI encodes itself, C0 characters do not appear in the JSON escape
(\t, \u001b), or DEL and the C1 range do not appear as \xNN.
A new response type, a new command or a new error branch passes this test before it ships.
JSON output. -o json passes the server's document on, with each DEL, C1 and
bidirectional control character written as \uNNNN. The JSON encoder escapes the C0 range
and leaves these raw. The escape decodes to the same text, so the document keeps its meaning.
The same test runs every command in -o json and fails on a raw character of these kinds,
on a raw C0 character that is not a line feed, or on a document that is not valid JSON.
Limit. -o yaml passes the values through the YAML encoder. This item does not cover it.
Alternatives and reasons. (a) Escape only fields that hold free text, and show IDs and enumerations raw. The schemas give most of those members as plain strings, and a server that does not follow a format it declares still reaches the terminal. The list of fields also grows with every command, which is the drift that left the server's version escaped and a cluster's name raw. (b) Keep the list of remote texts in item 4, and rely on the server to refuse control characters in its inputs. The CLI would then trust the server for resource text and distrust it for the version string. A control character that one input route does not refuse, or that arrived before a rule existed, still reaches the terminal.
7. Compatibility and versioning¶
Facts: the API is /api/v1; within it fields and routes are only added, never removed or changed
in meaning; a route due for removal answers with a Deprecation header for at least one minor
release first; a breaking change is a new prefix; unknown request fields are refused
(persistence and API §9.1). Since
#278, GET /server reports the answering
process's version, its API revision, a whole number raised by one with each additive change within
v1 and listed in §9.1's revision table, and the optional features it serves (G2,
persistence and API §9.2). Before a recovery start's
entry commits the read is refused with 409 recovery-mode-active, as every request but the entry
is.
Proposed.
- A client sends only fields the server it talks to is known to accept. Because the server
refuses unknown request fields, a newer client sending a field an older server lacks fails with
400 invalid-request, which is safe but useless; the client feature-gates such fields on the served API revision. - A client ignores unknown response fields, and never maps an unknown enumeration value to a
known state or to success. It shows a start mode as
unknown(§6 item 4) and any other such value as the text it received. In the CLI's human output, §6 item 8 escapes that text. - A client declares the minimum API revision it needs. It reads
GET /serveron context use and refuses, with both revisions named, a server below that minimum, before any write.GET /serveranswered409 recovery-mode-activewithout ascopemember is a recovery start before its entry (§8.5), since the read is refused that way only then: the client allows only the entry, whose request it already knows from revision 1, and reads the revision once the entry has committed. The revision describes the process that answered; another instance behind the same endpoint may differ, so a refusal names the revision that answer served. - A
Deprecationheader on any response is shown once per command or page load, naming the route. - Each client prints its own version and the server's, with its API revision, in
bronzewardctl versionand in the web footer; the server's as "unknown" while the read is refused.
8. Request handling¶
8.1 Requests¶
Proposed. Every request sends Authorization and Accept: application/json (or
text/event-stream for a stream). Every POST and PUT sends a JSON object, {} when the route
takes no member, with Content-Type: application/json; a DELETE sends neither
(§2 request body row). Every write
sends exactly one Idempotency-Key (§8.4) and, where
§3.2 lists it, exactly one If-Match; every write but the recovery entry also sends
exactly one Bronzeward-Expected-Epoch (§8.4 item 3). A body is at most 1 MiB
(internal/api/body.go:36); a client refuses a larger input before sending and says so, rather
than relying on the server's 400.
8.2 Errors¶
Proposed client behaviour per problem code, from the code table of
persistence and API §9.4 and internal/api/problem.go:11-36. The
client always shows title, detail when present, and instance, each escaped as
§6 item 4 states, and never shows a request body. Exit codes are the journeys'
(journeys §4).
| Status, code | Client behaviour |
|---|---|
400 invalid-request |
a malformed request: operator input, a client defect or an incompatible server. Operator input includes a mark that is not a valid path on the start and fragment routes (internal/api/ingestions.go:69-73, internal/api/fragments.go:52-56; the index is in detail, there is no position member), an empty document (internal/api/tx.go:119-120), an invalid name (internal/api/drafts.go:190-191) and the recovery entry's body checks (internal/api/recovery_entries.go:49-71). Show detail and stop; never repeat |
400 cursor-invalid |
discard the cursor and restart the list. Run the epoch-change handling of §8.5 only when Bronzeward-Epoch changed: the same code also answers a cursor that does not parse (internal/api/acts.go:154-156) |
401 unauthenticated |
renew once (§9.5); repeat a read; for a write follow §8.4; a second 401 stops with the failure text of §9.6 |
403 forbidden |
show the route's roles, and "humans only" when humanOnly is true; no retry. For the plan cancellation's creator check, the form that carries a plan member, show its detail instead: its roles are the route's, publisher included, which the caller already holds (internal/api/cancellations.go:64-72, internal/api/routes.go:123). After an ambiguous attempt, the route-level role check's refusal, like the process 409s, is decided before the idempotency lookup (internal/api/api.go:339-359, internal/api/api.go:380-391; for example a renewal dropped a role, §9.6), so it says nothing about the earlier attempt: the outcome stays unknown (§8.4 item 4). The plan cancellation's creator check, a 403 forbidden that carries a plan member, refuses after the lookup instead (internal/api/cancellations.go:61-73, internal/api/tx.go:198-202): it means the key has no record, in this epoch or an earlier one, so the earlier attempt did not commit or a restore removed it |
403 identity-revoked |
stop; clear the context's stored credential, meaning the tokens the client itself keeps in its credential store, its memory or its D2 (b) token file, never an operator-supplied automation token file, which it leaves untouched and reports as no longer accepted; do not start a new sign-in by itself (§9.6) |
404 not-found |
the resource does not exist in this epoch; after a restore a handle issued later answers 404 (persistence and API §12.3) |
409 conflict, stale-input, scope-busy, machine-identity-mismatch, ingestion-abandoned, recovery-mode-entered, recovery-mode-active |
show the named resource and state; read it; never repeat automatically, with one exception. A 409 recovery-mode-active without a scope member, and a 409 conflict to the recovery entry without epoch and request members, are process refusals that show the start mode (§8.5). A 409 conflict that carries request and epoch is an earlier-epoch idempotency record: it is read and reported, not repeated, under either key (§8.4 item 7). The one exception: a 409 conflict on a draft fragment write (PUT /drafts/{id}/fragments/{name}) that answers a repeat, carries an ingestion member but no draft member, and has no Idempotent-Replayed: true is the first request still running, or ended without committing while its claim's lease has not lapsed (internal/api/fragments.go:105-114, internal/api/tx.go:80-98; looked up by principal and key, so it never answers a first send). A 409 with both ingestion and draft, replayed or not, is a recorded refusal (internal/api/fragments.go:291-296, internal/api/drafts.go:94-104): the write was refused, not pending. For the in-progress case, repeat the identical request with the same key, with backoff, until it answers otherwise or the client's wait bound of §8.4 item 2 expires; the contract says the repeat then gets the first request's replay (persistence and API §7.2; §8.4 item 2), or, when the first request ended without committing, executes as a new request, unless its Bronzeward-Expected-Epoch names another epoch (412 epoch-mismatch, A6), reported as this send's own result (internal/api/fragments.go:105-114) |
412 precondition-failed |
someone else wrote first: stop, show the newer state, never refetch and resend (§8.3) |
412 epoch-mismatch |
the epoch is no longer the one the write named in Bronzeward-Expected-Epoch and the key has no record: this send's own transaction committed nothing, though a fragment write's claim, a scope mark's observation or a principal row taken before the refusal stays (persistence and API §7.2). Handle the epoch change of §8.5 with the epoch member, and set the write aside as in §8.4 item 3; never repeat it. After an ambiguous attempt it means that attempt's record is gone or never committed (persistence and API §7.2) |
422 validation-failed |
show rule and position extensions; for marks only the position, never a path. Most mark refusals arrive later, as an event (§11.3) |
422 idempotency-key-reused |
on a first send: the key was used for a different request; nothing was done; a client defect unless the operator supplied the key. On a repeat to a keyed route (fragment write, ingestion start, mark): the outcome is unknown, because a byte-identical repeat is also answered 422 when the provider can no longer compute under the digest-key version the record names, or the recorded key is not one the current key can be matched to; a rotation alone still replays (persistence and API §7.1; internal/api/idempotency.go:94-121); read the resource and report what it shows |
428 precondition-required, idempotency-key-required |
a client defect; show and stop |
500 internal-error |
the outcome may be unknown: repeat the identical request with the same key (§8.4) |
501 not-implemented |
not served by this server; no route answers it today |
503 dependency-unavailable, talos-access-unavailable, transient-conflict, epoch-superseded |
reads: retry with backoff (1 s doubling to 30 s, at most 5 tries, then stop). Writes: repeat with the same key at most 3 times. If every attempt of the write was answered 503, report not done: each such 503 states that its attempt's own transaction committed nothing, so no act or idempotency record exists for it; a fragment write's claim, a scope mark's observation or its start, or a principal row, taken before it stays (persistence and API §9.4). If any earlier attempt was ambiguous (§8.4 item 2), a 503 to the repeat says nothing about that attempt: report the outcome as unknown, as in §8.4 item 4. epoch-superseded is not repeated against the same server process: it started before the current epoch and cannot succeed until restarted. The server sends no Retry-After (G20) |
| an unknown code | treat by status class: 4xx as a refusal, 5xx as an unknown outcome for a write |
| a 5xx that is not a Bronzeward problem | an answer that is not application/problem+json, or whose type does not start urn:bronzeward:problem:, for example a 502, 503 or 504 from the component in front of the server, may come after the server committed: for a write it is an ambiguous outcome as in §8.4 item 2, never not done; reads retry as for the 503 row |
The 503 row applies only to a Bronzeward problem with one of its four codes. The write 503s
say that the attempt they answer committed nothing of its own transaction (what a route committed
before it stays, as the row says); for example authentication's
(internal/api/api.go:319), the installation-state read's (internal/api/api.go:276), the keyed
fingerprint's (internal/api/tx.go:110, internal/api/tx.go:123) and the transaction's
(internal/api/tx.go:308, internal/api/tx.go:310, internal/api/tx.go:314). This list is not
exhaustive, and none of them speaks for an earlier attempt: a keyed route computes its
fingerprint before its idempotency lookup (internal/api/tx.go:58-64), so a provider outage
answers a repeat 503 even when the first attempt's record exists. The repeat is still sent with
the same key, which costs nothing.
Failed operations. Several codes above (machine-identity-mismatch, ingestion-abandoned,
recovery-mode-entered, mostly talos-access-unavailable) usually arrive not as a response but
in an operation's error member or in an event's problem, where instance is the operation's
identifier, not a request's (persistence and API §9.4). A client
shows such a problem with the operation's identifier and final state, applies the row above for
what the operator may do next, and never repeats the request that started the operation: the
202 was already a success.
8.3 ETags¶
Facts: §2 ETag row. After a restore to a backup taken at T, an
ETag issued after T answers 412, while one for a revision the backup holds still matches; the
epoch is not part of the ETag
(persistence and API §12.3,
§4.1). A draft's ETag also moves when the server itself writes the
draft: when an ingestion's job commits its entries (internal/api/runner.go:439-440) and at
publication, which also closes the draft (internal/api/publish.go:398-399).
Proposed. The journeys' last-seen rule (journeys §5.4, C5): the client sends the ETag it last
read or was answered for that draft, stores the one each write answers, and on 412 stops and
shows the current state. It never reads the new ETag and resends by itself: that would overwrite
another author's change unseen. One exception: after an import the client itself started on that
draft reaches succeeded, it re-reads the draft, since the server's own write moved the ETag. It
adopts the re-read ETag only if the draft's revision equals the import operation's
result.draftRevision (internal/api/runner.go:443-444); a higher revision means someone else
wrote since, and the client treats it as a 412: it stops and shows the newer state. The CLI
records each ingestion that its own import start was answered for in its non-secret state, under
the context, its server and the current epoch, beside the ETags (internal/client/state.go). The
record of an ingestion ends when the CLI follows it to its end or abandons it, and an epoch change
drops it with the ETags. A command that removes a record or an ETag at an earlier epoch leaves an
entry that another command kept at a newer epoch unchanged. A continuation or takeover of an ingestion outside that record, another client's
import, adopts no ETag after success: the CLI drops the draft's kept ETag, says so and offers a
read of the draft, since the import can pass over an edit this client never read. ETags are kept per context and draft in memory; the
CLI may keep them in its non-secret state for the length of a scripted sequence. On an epoch
change it drops them all: more than the contract strictly needs, since a pre-backup ETag still
matches, but the client cannot tell which ETags were issued after T. The ingestion start's
If-Match is the target draft's ETag.
8.4 Idempotency and ambiguous responses¶
Facts: a key is scoped to the authenticated principal, a human's being its issuer and subject
(internal/auth/auth.go:190-192, internal/api/idempotency.go:41-43); the fingerprint covers
method, route, path parameters, If-Match and the canonical body, keyed with the provider's
digest key on keyed routes (internal/api/body.go:165-185); where the key has a record, the same
request under it replays, another is 422, an earlier epoch's record is 409
(internal/api/idempotency.go:60-106); where it has none, any request under it executes as a new
one (internal/api/tx.go:198-202), unless its Bronzeward-Expected-Epoch names another epoch
(412 epoch-mismatch, A6); a request whose effect a restore removed lost its record too and
executes again under the same exception
(persistence and API §12.4); the same key does
not make a remote mutation exactly-once
(persistence and API §7.2). The same exception holds wherever
this document, D9 in §13 included, says that a request
finding no record executes.
Keys are not global. The record's primary key is the pair of principal and key
(internal/migrate/migrations/0001_schema.sql:491-508), and the lookup joins on both, with the
authenticated principal's identifier (internal/api/idempotency.go:41-43). Two principals using
the same key string therefore never meet: the second finds no record and executes as a new
request, neither replayed nor refused 422. The fingerprint itself names neither principal nor
epoch (internal/api/body.go:170-187); the principal is in the lookup, and the epoch is compared
on the record found (internal/api/idempotency.go:41-42, internal/api/idempotency.go:65-68).
The principal is the authenticated identity, not the credential: for automation it is the
service identity that owns the token (internal/auth/automation.go:58), so a rotated token keeps
the same idempotency scope.
The server's fingerprint is what makes a repeat safe, but only against a record: a repeat that is
not byte-identical in meaning is refused 422 and does nothing when the first attempt committed
and its record is current. When the first attempt never committed, or a restore removed its
record, there is nothing to compare with, and a changed repeat executes as a new request with the
changed input. A client therefore repeats only the exact request it sent, including its
If-Match, never a reconstruction it cannot prove identical. On a keyed route a byte-identical
repeat can also be answered 422, when the provider can no longer compute under the digest-key version the record
names or the recorded key cannot be matched to the current one; a rotation alone still replays
(persistence and API §7.1;
internal/api/idempotency.go:94-121); then the first request may well have committed. The
dangers are a repeat under another principal, which the server treats as a new request, and a
repeat across a restore, which executes again by design unless it carries
Bronzeward-Expected-Epoch and an entry has minted a new epoch (412 epoch-mismatch, A6). A restore after which nobody entered
recovery mode mints no new epoch, so no epoch comparison can detect it
(persistence and API §12.1); that residual is the
contract's, and this specification does not close it.
Proposed.
- Key. One key per logical write, 128 bits from the operating system's random source,
base64url without padding (22 characters, within
internal/api/api.go:394). Never derived from the request content, so a key reveals nothing about a document. - Ambiguous. A write's outcome is unknown when the connection fails after any byte of the
request was sent, when no complete response arrives (also when item 8's bound ends the
attempt) unless its status is a
4xx(item 8), on500, or on a5xxthat is not a Bronzeward problem (§8.2). The client then repeats the identical request it still holds in memory, with the same key, at most 3 times with backoff. A write's503is repeated the same way; it states only that the attempt it answers committed nothing, so after an ambiguous attempt the outcome stays unknown (§8.2). The in-progress409of a fragment write (route plus409 conflictplus aningestionmember, nodraftmember, answering a repeat and withoutIdempotent-Replayed: true, never the detail text;internal/api/fragments.go:105-114) is repeated with backoff until the first request has answered. A409with bothingestionanddraftmembers, replayed or not, is a recorded refusal (for example the draft closed or a publication active;internal/api/drafts.go:94-104,internal/api/fragments.go:291-296) and is reported as refused, not repeated. The server keeps that claim alive for as long as the first request runs (internal/api/fragments.go:87). A first request that ended without committing (a5xxfrom its transaction or from prepare) only stops the heartbeat; the claim is abandoned at once only after a5xxfrom extraction or generation creation (internal/api/fragments.go:157-167,internal/api/fragments.go:172-193), otherwise when its lease lapses (internal/api/fragments.go:105-114). The client bounds the wait itself: a fixed maximum, configurable and at least the claim lease, after which the outcome is reported unknown as in item 4. The deployment's claim timers are an example of a sensible value (examples/bronzeward.yaml:76-80), served byGET /serverasingestionClaimsince #278: the client's bound is at least the servedleaseSecondswhen that member is not null. A replay (Idempotent-Replayed: true) is reported as "already done earlier; showing that result", except a replayedapplication/problem+json, which is reported as the earlier attempt's refusal (the fragment write replays recorded refusals,internal/api/fragments.go:291-296,internal/api/idempotency.go:80-91); a422to a repeat on a keyed route is reported as an unknown outcome (§8.2). - Same principal and epoch only. Before any repeat the client checks that its credential
still names the same principal: for OIDC, the same
issandsubin the renewed token; for automation, the same service identity, compared by theprincipalthe identity read serves (A1,internal/api/identity.go:56): the client reads it once before the first send, and again with the current credential before a repeat whose token ID changed, so a rotated token of that identity is the same principal. An automation client that cannot read it sends nothing, so every write it sends has aprincipalto compare. Every write, its first send and each repeat, carries the reference epoch inBronzeward-Expected-Epoch: the last epoch the client saw before the first send. An empty or repeated header is400 invalid-request, never taken as absent (internal/api/api.go:426-433). The header needs API revision 2 (persistence and API §9.1): an earlier server ignores it without error, so a client that relies on it declares revision 2 as its minimum (§7). A client that has seen none readsGET /recovery(any role,internal/api/routes.go:94) once before that first send. The server checks the header whenever a lookup finds no record for the key, and refuses only under the key's lock: in the write's own transaction, a fragment write's claim transaction or the first lookup's fresh read (A6,internal/api/idempotency.go:52-58,internal/api/tx.go:204-210,internal/api/fragments.go:95-103,internal/api/tx.go:71-81), so a recovery entry that commits at any point before the write's own transaction is caught: if the first attempt's record survived, the repeat meets the earlier-epoch409of item 7 (AMB-07); otherwise, when a restore removed it or the first attempt never committed, the repeat is refused412 epoch-mismatchnaming the current epoch (G21, AMB-12). A route's work outside those lookups can refuse first, and a fragment write's claim, a scope mark'sobservation-startedentry or a principal row taken before the refusal stays (persistence and API §7.2, §14), but no act or idempotency record commits. A service principal's repeat is refused401instead, its token carrying its epoch. Before a repeat the client still confirms the epoch with oneGET /recoveryand compares itsBronzeward-Epochwith the reference epoch, so a write already known to be across an epoch change is not sent again; the header of the failed attempt is not enough, because a dropped connection shows none. If the principal or the epoch differs, the confirming read yields no epoch header (for example its own503), or the repeat is answered that409or412, the write is set aside: not repeated, not sent under a new key; the client reads the resource and the operator decides (journeys §8). The recovery entry (POST /recovery/entries) is the one exception. Its own commit mints the new epoch, and until the process has seen that commit it refuses every other request,GET /recoveryincluded, with409 recovery-mode-active(internal/api/api.go:343-347). When only the201was lost, the process saw the commit before answering, so the confirming read shows the new epoch and a same-key repeat replays the entry (internal/api/recovery_entries_test.go:236-253; the replay:internal/api/idempotency.go:60-82,internal/api/recovery_entries.go:102-113). When the server lost its own COMMIT reply and answered500, only a same-key repeat completes the entry (internal/api/recovery_entries_test.go:255-296). So the client repeats an entry whose outcome is unknown under the same key, checking the principal but not the epoch, here and in item 4, and sends the entry withoutBronzeward-Expected-Epoch, on its first send and every repeat. The server replays only this process's own entry and refuses any other entry record with a409 conflictcarryingrequestandepoch(internal/api/recovery_entries.go:95-114), so the exception cannot replay an earlier entry or carry any other write across the epoch change. The entry itself can cross one: where its record is missing, because it never committed or a restore removed it, and a later recovery start is waiting for its entry, the repeat becomes that start's entry, with the earlier body's restored backups, stop time and reason, and is reported as this send's own201(internal/api/tx.go:198-202,internal/api/recovery_entries.go:133-135). That is accepted with the exception, like the residual of AMB-10, and A6 does not close it. The entry's answer is then handled as in §8.5, without setting the entry itself aside (REC-04). - After the process ends. The CLI stores no request body, hash of a body, or key journal.
When it gives up with the outcome unknown, it prints, on standard error, the key, method,
route, context, principal (issuer and subject, or the service identity's
principalfrom A1), epoch and theIf-Matchvalue it sent, if any, and what to read to find out. An ETag is not secret. An operator may then repeat with--idempotency-key <key> --expect-epoch <epoch> --expect-principal <principal>, plus--if-match <etag>where the route takes one, all as printed. The recovery entry is the exception: its block prints no epoch, and its repeat takes only--idempotency-key <key> --expect-principal <principal>(the exception of item 3 above). The client refuses, sending nothing, when one of them is missing or, for a human, when its token'sissandsubare not the printed ones. An automation client first sends item 3's identity read and refuses when thatprincipalis not the printed one. Otherwise it refuses after sending only item 3's reads (that identity read, for automation, and the confirming read) when the epoch differs as in that item (not for the recovery entry). It never substitutes an ETag it reads now:If-Matchis part of the fingerprint (persistence and API §7.1), so a new one turns the repeat into another request. The operator supplies the input and theIf-Matchvalue again, and the client keeps nothing to check either against. Where the first attempt's record exists, a changed input orIf-Matchis refused422and does nothing (AMB-04); where it does not, because the first attempt never committed or a restore removed it, the changed input executes (AMB-10), and a changedIf-Matchexecutes when it matches the current resource and is refused412otherwise (AMB-13). An operator who cannot reconstruct the exact input stops and reconciles from the resource instead of repeating. This is option (a) of decision D9, for the journeys' open question on key metadata across a restart, as decided in §13; option (b), a local journal, would replay without that reconstruction. - Browser. The page holds pending writes in memory only. Leaving or reloading the page drops them; the outcome-unknown notice shows the key and the resource to read, and no repeat is offered afterwards.
- A client never generates a fresh key for a write it has already sent.
- Deliberate departure from the contract's advice. For an earlier-epoch record (
409) and for a keyed422it cannot compare, the contract and the server'sdetailtell the client to retry under a new key (persistence and API §7.2, §7.1;internal/api/idempotency.go:66). A client does not do that automatically. In both cases the first request may have committed: an earlier-epoch record exists after any recovery-mode entry, with or without a restore, and its effect may be in the current state; an incomparable422may follow a commit. The recorded act may also have been fenced by that entry (an approval that authorizes nothing, a failed job), and executing afresh repeats it (persistence and API §7.2); whether that repeat is wanted depends on the route and the state, which a client cannot judge. The client reads the resource, reports what it shows, and lets the operator send the write again as a new one if it is still wanted. - Attempt bound. Every request that a client sends to the Bronzeward API has its own bound,
except the event stream of §8.6. This item does not cover a request to
the identity provider: the refresh-token request, for example, keeps the rule of
§9.3 item 3, and its late answer is still read. The response headers
must arrive within 30 s after the request is sent. The whole attempt, which is the request, the headers and the complete
body, must end within 60 s. Each repeat of item 2 and each read of item 3 is a new attempt with
a new bound. The CLI starts the header timer when it has written the whole request, as Go's
ResponseHeaderTimeoutdoes. The web client cannot see that moment, so it starts the timer when it sends the request. That bound is stricter, by the time the upload takes, and a request body is at most 1 MiB (§8.1). A bound that fires ends the attempt in the same way as a connection that the network cut at that point: - A read reports the transport failure that it reports for a cut connection.
- A write is ambiguous under item 2 if a byte of the request possibly left. The web client
cannot see whether one did, so a write attempt that the bound ends is always ambiguous there,
never not done. The CLI counts an attempt as sent once it has a connection
(
internal/client/write.go:455-467). A bound that fires before that is a failure with nothing sent (AMB-01), and a bound that fires after it is ambiguous. - One exception: if a write's headers carried a
4xxstatus and the bound fires during its body, the server refused that attempt, so it is not ambiguous. The client reports the status as a refusal, says that the answer was cut off, and does not repeat. After an earlier ambiguous attempt, that refusal says nothing about the earlier attempt, so the outcome stays unknown (item 4). This is also what the CLI does when the network cuts such a body (internal/client/write.go:497-503,internal/client/write.go:303-307,internal/client/write.go:331-342). - An ambiguous write then takes the repeat path of item 2: the same key, at most 3 repeats, then the outcome-unknown report of items 4 and 5.
So no write stays pending without end. The bound ends each attempt, item 2 limits the repeats,
and the outcome-unknown notice of item 5 settles and offers Dismiss. The in-progress wait of
item 2 keeps its own longer bound, and each repeat in that wait has the same attempt bound. A
write that the server answers after the bound is an ambiguous attempt like any other, and its
repeat uses the same key. The CLI's HTTP client already has these values
(internal/client/transport.go:91-95): ResponseHeaderTimeout is 30 s, and Timeout is
1 minute, which includes the body read. It
also bounds the TLS handshake to 10 s, which a browser does itself. The web client applies the
bound in its request layer, with one abort signal per attempt that covers the fetch and the
body read (#487). The alternatives are D12
in §13.
8.5 Epoch changes¶
Facts: an epoch is a random identity minted at installation and at each recovery-mode entry,
compared by equality only
(persistence and API §12.1). An epoch change makes cursors
400 cursor-invalid, earlier idempotency records 409, a write whose key has no record and whose
Bronzeward-Expected-Epoch names the earlier epoch 412 epoch-mismatch, earlier automation
tokens 401, and streams reset (§2).
Proposed. The client compares Bronzeward-Epoch on every response with the last it saw for
the context. On a change it:
- discards every cursor, cached page, ETag and stream position for the context;
- sets aside every pending write as in §8.4 item 3, except a recovery entry, which that item repeats under its key;
- tells the operator "The installation entered recovery mode or was restored; what was read
before may no longer hold", and, while
Bronzeward-Recovery-Mode: trueis present, shows the recovery banner (journeys §8); - records the new epoch. A context's first epoch is recorded without a notice.
A command that shows one snapshot built from several reads (status, recovery status, the
table of machine show) shows nothing when an epoch change falls between the first and the last
answer it shows, in every output form, because the earlier answers, with the facts they carry, no
longer hold. machine show -o json and -o yaml print the machine answer alone, so they have no
second read to compare. After reads that all succeed, it says so and names the command that reads
again. A failed read that the command needs ends it with that read's own error.
Three more commands read twice, and each compares the epoch of the answers themselves. After
publish succeeds, it shows no next step for the machines when a machine answer has another epoch
than the release answer, or when any answer showed another epoch than the one before while the
machines were read, even if a later answer set it back. It still reports the publication, which is
done, and names release show.
profile set sends nothing when the draft answer and the fragment list have different epochs, or
when the epoch changes before the send. It says that nothing was sent and names draft show.
machine add names the cluster only when the cluster answer has the epoch of the write's answer.
The wait of import does not end on a lapsed lease when the claim or operation answer has another
epoch than the events shown. It reads both again. The watch clears the events shown when the
operation it reads has another epoch, even if the operation has no event to replace them. Every
other multi-page read needs no such comparison, because a cursor carries its epoch and a change
makes it 400 cursor-invalid.
An epoch is never ordered or parsed. A missing Bronzeward-Epoch on an authenticated success is
a defect the client reports.
Start mode (G22). Neither the headers nor GET /recovery say whether the process was started
for recovery, which decides whether plan creation, approval and unfreeze can succeed (journeys
§5.9). Since #278, GET /server serves it as
startMode (persistence and API §9.2), but a
recovery start refuses that read until its entry commits. The client keeps the last mode a
GET /server answer or one of the answers below showed, per context, and reads GET /server
again on context use and after a new epoch. A restart changes the mode without any signal, so the
kept mode is only the last one shown:
- Recovery start:
GET /serveranswering"startMode": "recovery", which a recovery start serves only after its entry; a409 recovery-mode-activewithout ascopemember; or any answer ofPOST /recovery/entriesgiven after authentication and the role check other than the refusal in item 2, its201and its other409 conflictanswers included (each of these carriesepoch). A401, a403, or any answer withoutBronzeward-Epochto the entry shows nothing: both503s decided before routing, the authentication one and the installation-state read's (internal/api/api.go:274-277), and any5xxthat is not a Bronzeward problem (§8.2) lack the header, and every other answer after the role check carries it, except a401or a403 identity-revokedfound there, which drops it (internal/api/problem.go:63-71). Arecovery-mode-activewith ascopemember is a scope's refusal and says nothing about the start mode. - Normal start:
GET /serveranswering"startMode": "normal", or the entry's not-a-recovery-start refusal, a409 conflictwith neither anepochnor arequestmember, whose detail says only a recovery start serves the entry. A409 conflictto the entry is never read as a normal start by its status and code alone. - Unknown before any such answer. The client then disables no action beforehand, and the recovery banner states the mode as unknown or as last shown, until the next such answer.
Evidence for each refusal and entry answer is in the G22 row of §4. Item 1 relies on the
absent scope member: persistence and API §9.4 gives one only to a
scope refusal, since contract correction C2 in
§14 was applied.
8.6 Event resumption¶
Facts: §2 events row. The SSE stream sends no retry: field in the
frames it writes (internal/api/operations.go:295-353); between events it polls every 500 ms
(internal/api/operations.go:210-212); after 15 s with nothing sent it sends a : keep-alive
comment line (persistence and API §8.3).
Proposed.
- A client follows an operation with the stream when it can and with JSON pages otherwise; both are complete. It remembers the last event ID it showed.
- When the stream ends it reads
GET /operations/{id}. A terminal state does not show that the last events arrived: the stream may have been cut after the state was written and before its last events were (the server writes the state after the last entry and ends the stream only once they are written,internal/api/operations.go:233-234,internal/api/operations.go:270). So on a terminal state the client drains the backlog first: it reads the JSON event pages to their end and shows every event after the last one it showed, then ends the watch. Any other state,unresolvedincluded, means the stream ended because the token expired or the connection dropped: the client renews if needed and reconnects withLast-Event-IDand the renewed token, with backoff from 1 s to 30 s on repeated failures. - A
resetevent clears what the watch showed, prints that the installation was restored or entered recovery mode, and shows the replay from the start. The epoch change is handled as in §8.5. The server sendsresetonly when a stream opens (internal/api/operations.go:264-265); a stream that stays open across a recovery entry goes on with event IDs carrying the new epoch (internal/api/operations.go:278). So the client compares the epoch part of every event ID (before the:) with the stream'sBronzeward-Epoch; on a difference it ends the stream, applies §8.5, clears what the watch showed and reopens the stream withoutLast-Event-ID(EVT-05). - The browser cannot send
AuthorizationwithEventSource, so the web client reads the stream withfetchand a streamed response body, sendingAuthorization,AcceptandLast-Event-IDitself (assumption S1). No token is ever put in a URL. - The keep-alive comment keeps an intermediary with an idle timeout above 15 s from cutting a quiet stream; a client ignores comment lines and keeps its last event ID. A connection still cut without the operation having ended is covered by item 2.
- The machine timeline has no stream (G9). A client reads its newest page with
order=newestand keeps that answer'stail; "Load older" followsnextwithorder=newest. While the timeline is on screen the client polls forward fromtail(from the start while there is none), inoldestorder, no more often than every 5 s, followsnextwhile it is present, and keeps thetailof the last page, the one withoutnext. A server below API revision 3 refusesorderas400 invalid-requestand serves notail, so a client that reads the timeline this way declares revision 3 as its minimum. When a poll returns entries the client did not show, a view that also shows the machine and its observations reads both again. It keeps what it shows until they answer, and the timeline poll goes on unrestarted: the timeline can record a freeze, an observation or an applied release that the first machine read predates. Only one such read again is in flight. A poll with new entries during it does not abandon it: the client reads both once more after both answer, so polls that come faster than a read do not stop every read. A view is one path and query. What W3 keeps when a read again of the machine or its observations fails (#442): - A failure the server answers keeps what the view last read and shows the failure above it.
These failures are a refusal the page shows, a
503after its retries, andepoch-superseded, which the client reads as unavailable (web/src/request.ts:149;web/src/machine.test.ts:217,web/src/machine.test.ts:231,web/src/machine.test.ts:333). - A read that gets no answer while the view still shows data reports it only through the
offline banner (W11).
The data stays, with no failure of its own above it, also after a failure the server answered
(
web/src/shell.test.ts:88,web/src/machine.test.ts:369). A read with nothing to show shows the disconnected state in place. When the banner ends because a read answered, W3 reads the machine and its observations again once, as for a timeline poll with new entries: one read again at a time. Until they answer, W3 shows what it keeps, as during any read again. After that, no data older than a failed read stays shown without a sign (web/src/shell.test.ts:106,web/src/machine.test.ts:392,web/e2e/auth/inventory.e2e.ts:350). A transport failure answers at once, so this read again runs at most once per poll interval, counted from when the last one started. A resource that keeps getting no answer is not read again at network speed, also when a slow read holds the read again (web/src/machine.test.ts:409,web/src/machine.test.ts:436,web/src/machine.test.ts:466). A read again that the old view asked for does not run in a new view (web/src/machine.test.ts:466). - A refusal of the credentials (
401after renewal,403 identity-revoked) ends the page (§9.6), and nothing stays (web/src/request.test.ts:96,web/src/request.test.ts:115,web/src/shell.test.ts:181). - A new epoch and the process's recovery refusal end the page (§8.5), and
nothing stays (
web/src/shell.test.ts:152,web/src/shell.test.ts:166). - An observations read that restarted after a refused cursor and then failed keeps nothing: the
list goes with its cursor (§8.2). W3 still shows the machine's own rows with
the observations' failure; only the latest read's line needs the observations. If the machine
read also failed, its failure is still shown (
web/src/machine.test.ts:231,web/src/machine.test.ts:323,web/src/machine.test.ts:514). - A new view keeps nothing of the old view. When the view changes, the old view's answer still
shows at first. A later failure does not keep it, a read without answer included, and the
restart flag does not carry over (
web/src/machine.test.ts:204,web/src/machine.test.ts:231,web/src/machine.test.ts:381). - The observations' restart notice stays through the reads again after it within the view
(
web/src/machine.test.ts:204,web/src/machine.test.ts:245).
9. Authentication¶
9.1 Identity-provider requirements¶
Facts: the server accepts a JWT access token signed with an asymmetric algorithm, with iss
equal to the configured issuer, aud containing the configured audience, exp, iat and a
non-empty sub, and exp - iat at most maxTokenLifetime, 15 minutes by default; it refuses
a token carrying nonce or at_hash, which mark an ID token, and one without a jti; roles
come from the configured groups claim, mapped in deployment configuration
(internal/auth/auth.go:145-248, internal/config/config.go:121-125,
internal/config/config.go:359-360). A disabled account or removed group takes effect when the
current token expires
(persistence and API §10.1).
Proposed deployment requirements, which the packaging ticket documents:
- The identity provider issues access tokens that are JWTs carrying the audience, the groups
claim, a
jtiand a lifetime no longer thanmaxTokenLifetime. An ID token is never sent to the API; the server refuses one (persistence and API §10.1). Many providers set an access token's audience to the requesting client; the deployment configures an audience mapping or resource soaudcontains the server's value. - Two public clients are registered, one for the CLI and one for the web interface, each using
the authorization-code grant with PKCE
S256and no client secret. The CLI client allows loopback redirect URIs with any port; the web client allows exactly the web origin's callback path. The CLI client also has the device-authorization grant enabled (decision D4 (a)). - Every case in which a client keeps a refresh token (the CLI's credential store of
§9.2 item 3; its process memory under D2 (a), since a long wait renews
in-process, §9.5 item 4; its token file under D2 (b); the web client's memory
under D5 (a)) is conditional on refresh tokens issued to public clients rotating on every use
and being bound to the client. Where the provider does not offer that, no client keeps a
refresh token: the CLI keeps only the access token, in its credential store or memory, offers
no token file and runs
loginagain when the access token expires; the web client renews by the redirect of §9.3 item 3. This document sets no refresh-token policy itself. The provider's session length, not the client, bounds how long an operator stays signed in. - The web client's origin is allowed to call the provider's token endpoint cross-origin.
9.2 CLI sign-in¶
Proposed.
bronzewardctl loginruns the authorization-code grant with PKCES256,stateandnonce, using a loopback redirect on127.0.0.1with a port the operating system chooses, and opens the system browser. If no browser can be opened it prints the URL for the operator to open on the same host, and nameslogin --devicefor a host without a browser. The listener accepts one callback carrying the sign-in'sstate, and closes; a callback carrying anotherstate, which any local process that finds the port can send, is answered with an error and ignored, and the wait goes on.- Headless use, where no browser runs on the host, uses the device-authorization grant only
(decision D4 (a)): the CLI prints the user code and the verification URI, shows the URI's host
prominently so that a lookalike prompt stands out, and polls the token endpoint. There is no
fallback to a loopback redirect over a forwarded port. A sign-in the CLI starts by itself
(item 3's memory-only case) on a host without a desktop session uses this grant. An explicit
bronzewardctl loginkeeps item 1's grant unless--deviceis given, because a browser may still run on the host without one, for example on another console. - Store. The refresh token and the current access token are kept in the operating system's
credential store (Secret Service, Keychain or Windows Credential Manager) under the context's
name. Where no store is available (decision D2 (a) and (b)), the tokens are kept in memory for
that process only and each new process runs
login. On Linux no store is available when no D-Bus session bus is named, or when no program on it owns the Secret Service name (org.freedesktop.secrets) and none can be started for it; a store that answers and then fails refusesloginbefore the sign-in starts, so nothing is issued that cannot be kept. A named bus that cannot be asked, for example a stale address, counts as a store that fails. A sign-in whose context was removed or changed while it ran is not kept, and its refresh token is revoked. Removing a context removes its stored sign-in under the renewal lock, with a bounded read first, and leaves an entry alone when a context of that name was added again meanwhile. An explicit per-context opt-in keeps them in a file readable only by its owner instead, offered only when the provider issues rotating, client-bound refresh tokens to public clients (§9.1 item 3). CLI processes that share that file renew one at a time, holding a lock on it while they renew, and read the new pair from it; a context kept in the credential store has a lock of its own in the user's state directory. That lock is never held across the store's unlock prompt: the store is read, and may prompt, for at most a minute before the lock is taken; with--no-inputit never prompts, and a locked store is refused as one that failed. Under the lock no call to the store shows a prompt, so a store locked by then refuses the command, with nothing written or removed. Each store operation under the lock gets at most a minute for its connection to the session bus and its calls together. Neither the session bus nor the D-Bus library ends a connection or a call that gets no answer, or a write that a bus no longer reads. Under the lock the session bus is reached only over a Unix socket or TCP. Another transport fails the command, with nothing written or removed. A store that does not answer in time fails the command. If the call that writes or removes the entry was not yet sent, nothing was written or removed. If that call itself got no answer, the command says that its outcome is unknown: the store may still carry it out after the lock is released. A command waits at most four minutes for a lock that another bronzewardctl process holds, longer than a renewal's bounded read, refresh and write together, and then fails, naming the lock file. The stored sign-in is not changed. Other changes of the command stay, for example the context thatcontext removealready removed. One that locks while a renewal waits on the identity provider refuses the renewed pair: its rotated refresh token is lost, as with any failed write, and the operator signs in again once the current access token expires. One renewal is the exception to these failures: the renewal that a command starts by itself when less than 60 s of the access token's life is left (§9.5 item 1). A store that does not answer in time, a write that got no answer and a lock wait of four minutes make that renewal fail under §9.5 item 3. They do not fail the command. The command shows the error as a notice, with the unknown outcome of a write in the words above, and goes on with the current token until the server refuses it. A renewal that follows a401is not this case. The opt-in is the context'soidc.tokenFile(§5); only in a context that has it mayBRONZEWARD_TOKEN_FILEoverride its path. The CLI writes that file and clears it as its own stored credential (§8.2), and refuses to write or clear a file that holds an automation token (bwt_) or anything else that is not its own document, a refresh-only one included;loginchecks the file before the sign-in starts and again before writing, and refuses a symbolic link at its path. - No token is printed, written to the configuration file, passed in argv or placed in an environment variable by the CLI. No command prints a token.
whoamishows the issuer and subject from the token's claims, read without verification and labelled as the client's reading, and the expiry. Roles come from A1 (GET /identity,internal/api/identity.go:39).
9.3 Browser sign-in¶
Proposed.
- The web interface is a public client using the authorization-code grant with PKCE
S256,stateandnonce, with a full-page redirect to the provider. The PKCE verifier,state,nonceand the page path to return to are held in onesessionStorageentry only between the redirect and the callback, stored only when the redirect follows, and are deleted when the callback is handled; this is the one storage use the web client makes, and it holds no token. The return path starts with/and not//, holds no backslash or control character, is never a full URL and is never the callback or signed-out page; any other is replaced by/(decision B2 of #255). - Access and refresh tokens are held in memory only, in a module the rest of the page cannot
enumerate. They are never written to
localStorage,sessionStorage, IndexedDB, the Cache API, a cookie or a service worker. - Under decision D5 (a), the access token is renewed silently with the rotating refresh token
(§9.5). A rotated refresh token is used once, so one tab renews for all
(decision B1 of #255). Every signed-in tab
requests the exclusive Web Lock
bronzeward-renewerand holds it untilpagehide, when §10.3 item 3 clears its state (assumption S9). Only the holder sends refresh-token requests; before each it postsrenewing, naming the refresh token it sends, on theBroadcastChannel(assumption S3), and after it the new pair. - The holder sends only once no other tab can send the same token
(#388). Each tab holds an exclusive Web Lock
bronzeward-tab:<random>and opens its channel only once that lock is granted. After postingrenewing, the holder lists the heldbronzeward-tab:locks other than its own withnavigator.locks.query()and waits, at most 3 s, for anacknaming the token from each; a tab acks once it has recorded the token as spent. The holder then takes, withsteal, the lock of every tab that did not ack, and sends only if its own tab lock is still its own. A tab can hold the token only from a message posted after its channel opened, so after its tab lock was granted and before the holder's query: each such tab has acked or lost its lock. - A tab whose tab lock was taken (frozen, suspended, or silent for 3 s) may have missed a
renewing. It gives up its refresh token, takes a new tab lock, opens a new channel once that lock is granted, and posts a request. Its old channel still delivers what was posted to it before, of which the tab takes only asigned-outand the tokens arenewingnames, never a pair: the holder may have sent a pair's refresh token without waiting for this tab, before itsrenewingnaming it arrives. Until the new channel opens, its ownsigned-outandredirectinggo out on the old one. The new channel hears everything posted after it opened; 3 s later the tab closes its earlier channels, for what other tabs posted to them before has normally arrived by then (#406). It then takes the holder's current pair, even one of the same expiry as its own, or, granted the renewer lock first, renews by the redirect. A holder whose own tab lock was taken before it sent sends nothing, and does the same. A tab that stops for good (pagehide, sign-out, the signed-out page) gives up its tab lock and is not waited for. A tab's own sign-in redirect needs nothing: no other tab holds its pair until it posts it. A page restored from the back-forward cache reloads (§10.3 item 4). - A refresh-token request unanswered after 15 s fails the renewal (§9.5 item 3), so that a request the provider never answers holds back no later renewal and no pair for a new tab. Its answer, whenever it comes, is not kept: the refresh token it brings, which no tab holds, is revoked, and the one sent stays held for sign-out's revocation. A sign-out waits for that answer within its 5 s bound (§9.7 item 2), also when the request times out during that wait, so a rotated refresh token that arrives in time is revoked before the page navigates (#396). The request is not aborted, since an abort cannot recall a request the provider may already have answered.
- A tab without the lock never renews: under the 60 s threshold or after a
401(§9.5 item 1) it posts a request and waits for the holder's pair, at most 10 s, then treats renewal as failed (§9.5 item 3). Granted the lock while it waits, it renews itself. A holder about to renew by redirect postsredirectingfirst. A tab that received it in the last 10 s and is granted the lock, while it asks or before its own threshold, waits out the rest of those 10 s for that redirect's pair and redirects itself only if none comes, so that one renewal is one redirect, not one per tab.redirectingnames the expiry of the pair the holder leaves with. The first pair newer than that ends the wait, and the 10 s with it, so that the next renewal of that pair does not wait for a redirect already over; while the 10 s run, the newest such expiry received counts, since another sender's olderredirectingcan arrive after it. Aredirectingthat reaches a tab already holding a newer pair, late or repeated, starts no wait (#400). - A holder whose renewal answered a request due first has a new pair, and its own renewal timer waits for that pair's threshold instead of renewing again.
- A refresh token named in a
renewingis spent in every other tab, whatever the answer: a tab never sends it, nor gives its pair to a new tab. A tab that holds it and takes no newer pair, or becomes holder before one comes, has failed to renew: the current token is used until refused, then the page offers sign-in again. - A new tab, a reload, or a page restored from the back-forward cache posts a request. If
navigator.locks.query()shows no holder it is a first tab and redirects at once; otherwise it requests the lock and waits 5 s for the pair, then redirects. A holder whose renewal is in flight answers with the pair that renewal brings, never the one it is renewing, which has no refresh token until then. Granted the lock before a pair arrives, no tab that asked before it is left, so it redirects at once. The redirect is top-level; the provider answers at once from its own session, without a prompt while that session lasts. - A pair obtained by a redirect is posted to every tab by the tab that obtained it. A tab takes
a received pair when it is newer than its own; a pair with another
suborissis a new sign-in (§9.5 item 2). - Pairs are ordered by access-token expiry. If the provider shortens the token lifetime between two pairs, a tab can keep the older pair, whose refresh token another tab has sent. Its renewal then fails as §9.5 item 3 and the page offers sign-in again. The spent token is told by its value, not by expiry, so it is not sent twice.
- Limits. A silent tab delays one renewal by 3 s; after that its lock is taken and it is no
longer waited for. A holder that is itself frozen still holds
bronzeward-renewer: the other tabs' requests go unanswered and fail after 10 s (§9.5 item 3) until it resumes or closes. This predates #388 and is tracked there; taking the renewer lock from a holder is not part of decision B1. Lock grants and channel messages are separate task sources, so a tab can be granted the lock before the previous holder'sredirectingreaches it, although that holder posted it before navigating and releases the lock only when its page unloads. That tab then redirects too: one extra redirect, which the provider answers from its session, and no refresh token sent twice. Having every tab acknowledgeredirecting, as it doesrenewing, would close this at up to 3 s added to every redirect renewal; that is not done. A channel delivers one sender's messages in order, but nothing orders different senders', so no message can show that another tab's earlier posts have reached a rejoined tab's earlier channel. One still in transit 3 s after the new channel opened is lost when that channel closes. A lostsigned-outleaves that tab signed in, showing its data, until it is signed out itself or a renewal fails; a lostrenewingnames a refresh token the tab gave up on rejoining. Keeping every earlier channel open until the page is released would close this at one more open channel per rejoin; that is not done (#406).
Rejected: a lock taken per renewal, because a waiting tab can be granted it before the previous
holder's pair reaches it and then spend the used token; a counter in localStorage, which
item 1 excludes. For #388, also rejected: a fixed delay before sending, which no length makes
safe against a slow or frozen tab; a hash of the token in localStorage, which item 1
excludes; handing the token over on pagehide, which a frozen or crashed tab never fires; and
giving up the tab lock on the freeze event, which only Chromium fires of the browsers in
§15, whereas a steal needs nothing from the frozen tab.
Where the provider does not issue rotating, client-bound refresh tokens
(§9.1 item 3), renewal also uses that redirect, never a
hidden frame (item 4). The page renews by the redirect when its pair, from a sign-in or another
tab, has no refresh token, or when a renewal brings back the refresh token it sent; only the
lock holder redirects, at the 60 s threshold, and another tab asks it as for any renewal, or
redirects itself if granted the lock while it asks. A
failed renewal or a spent token is not this case: renewal has failed (§9.5 item 3), and that
pair goes to no new tab. A 401 is not either: the page shows W1 and does not redirect, since a
token the server refuses would come back refused and the page would loop
(§9.6); W1's button is the operator's redirect. That fallback redirects at every
access-token expiry, the behaviour the
owner's constraint in D5 refuses, so the constraint holds only with a rotating provider:
rotation is a provider requirement (§15). No token is kept in
storage (item 2).
A renewal, a rescheduled renewal or a redirect still pending when the page is refused (§9.6), sees another account (§9.5 item 2) or ends (§9.7, §10.3 item 3) does not go on; only the operator's sign-in button leaves a refused or changed page. A refresh-token request already sent still gets its answer. After an end or another account, the pair it brings is revoked (§9.7 item 2); if it fails after the page has dropped its tokens, the refresh token it sent is revoked, not kept. After a refusal, that pair still goes to the other tabs, whose refresh token the request spent, and the refused page sends nothing with it. 4. Silent renewal through a hidden frame is not used: browsers that block third-party cookies make it fail unpredictably (assumption S2), and a failure would surface as an unexplained sign-out. 5. Every API call goes to the page's own origin (§10.1); only the provider's authorization, token, revocation and end-session endpoints are called elsewhere.
9.4 Automation token input¶
Facts: an automation token is bwt_<token id>.<secret>, printed once by the server-side command
(internal/auth/tokens.go:21, internal/auth/tokens.go:306-322; persistence and API §10.2),
valid at most 90 days (internal/auth/tokens.go:25) and refused when revoked, expired or from
an earlier epoch (internal/auth/automation.go:47-52).
Proposed.
- The CLI reads an automation token from a file (
--token-fileor the context'sautomation.tokenFile, or, in an automation context, a path inBRONZEWARD_TOKEN_FILE) or from standard input (--token-stdin). It never accepts the token as an argument value, nor the token itself in an environment variable (decision D6 (a)):BRONZEWARD_TOKEN_FILEholds a path, never a secret. - A token file must be a regular file readable only by its owner; otherwise the CLI refuses it and says why.
- The CLI recognises an automation context by the
bwt_prefix and says so before sending a request to a human-only route; the server's403stays authoritative. - The web interface accepts no automation token.
- After a restore, automation tokens from the earlier epoch answer
401; the CLI's message says the token must be reissued by the operator with the server-side command (persistence and API §12.3).
9.5 Renewal¶
Proposed.
- A client renews an access token when less than 60 s of its lifetime remains, by its own clock,
and once after a
401on a token it believed valid. It never retries a401more than once per request. - A renewal that returns a different
suborissis a new sign-in: pending writes are set aside (§8.4 item 3) and the change is shown. - A failed renewal (refresh token rejected or expired, provider unreachable) leaves the current
token in use until the server refuses it, then asks the operator to sign in again. The CLI in
--no-inputmode exits with the authentication-refused message instead of prompting. - Long waits (
operation watch,publishwaiting for its operation) renew in-process.
9.6 Failure cases¶
The server answers every token it does not accept with the same 401 and no detail
(internal/api/api.go:314-322, internal/api/problem.go:76-79). The client may add what it can
read locally, labelled as its own reading, never as the server's reason. The web interface shows
W1's text, "Not accepted by this installation. Sign in again.", for every 401 unauthenticated
that its one renewal (§9.5 item 1) does not clear, with its local reading below it;
a 401 with another body follows §8.2's rows. The texts below that name a context,
and the expired-token text, are the CLI's
(lead decision on consistency, 26 October 2026, under the owner's 2026-10-07 grant).
| Case | What the server does | Client behaviour (Proposed) |
|---|---|---|
| Token expired | 401 (internal/auth/auth.go:165-166) |
renew (§9.5); if the provider refuses, the CLI says "Your sign-in expired: sign in again" |
| Wrong issuer | 401 |
if the token's iss differs from oidc.issuer: "The token is from <iss>; this context expects <issuer>. Check the context or the server's configuration." |
| Wrong audience | 401 |
if aud lacks oidc.audience: "The token is not issued for <audience>; the identity provider needs an audience mapping" (§9.1 item 1) |
| Lifetime over the maximum | 401 (internal/auth/auth.go:175-176); the maximum is the deployment's maxTokenLifetime, 15 minutes unless configured (internal/config/config.go:125, internal/config/config.go:359-360) |
GET /server serves the maximum as maxTokenLifetimeSeconds since #278, but only to an accepted token, so the client pre-checks against the value the context last read; with none read yet it reports the 401 as any other. If exp - iat exceeds it: "The token lives longer than this server accepts; the identity provider's access-token lifetime must be shortened" |
An ID token, or no jti |
401 (internal/auth/auth.go:177-180) |
if the token carries nonce or at_hash, or lacks jti: "The identity provider sent an ID token, or an access token without jti; the client or the provider's configuration is at fault" (§9.1 item 1) |
| Groups claim malformed | 401 (internal/auth/auth.go:217-227) |
"Not accepted by <context>: sign in again"; nothing more can be read safely |
| No role for the action | 403 forbidden with the route's roles |
"Needs <roles>; you are signed in as <sub>" |
| A principal whose only qualifying role is publisher cancels a plan it did not create | 403 forbidden with a plan member and the route's roles, publisher included (internal/api/cancellations.go:64-72) |
"<detail>; you are signed in as <sub>", not the roles (§8.2) |
| Human-only route with an automation token | 403 forbidden, humanOnly: true |
"This needs a person signed in, not an automation token" |
| Group removed at the provider | takes effect when the current token expires (persistence and API §10.1) | nothing to detect before; after renewal the next refusal names the roles |
| Account disabled at the provider | the current token is accepted until it expires; in the #162 experiment a refresh after disablement was refused (talosctl-oidc live observations, one provider only) | the failed renewal of §9.5 item 3 |
Identity revoked in Bronzeward, or listed in deniedSubjects |
403 identity-revoked (internal/api/api.go:316-317) for both. A recorded revocation is permanent (internal/api/revocations.go:122); a deniedSubjects entry is deployment configuration and lasts while the entry does (internal/auth/auth.go:186-187, internal/auth/automation.go:45) |
"Your access was revoked or is denied by this installation; contact a recovery administrator." Clear the stored credential as in §8.2, never an operator-supplied automation token file; no automatic new sign-in, which would be refused the same way. The client cannot tell the two causes apart |
| Automation token revoked, rotated or from an earlier epoch | 401 (internal/auth/automation.go:47-52) |
"The automation token was not accepted: it may be revoked, rotated, expired or from before a restore. The operator reissues it with the server-side command." |
| Provider or database unreachable from the server | 503 dependency-unavailable, nothing committed |
retry as §8.2 |
| Provider unreachable from the client | no request reaches the server | keep using the current token until it is refused; then "Cannot reach the identity provider <issuer>", with the cause by its type (§6 item 4) |
9.7 Sign-out and cache clearing¶
Facts: the server keeps no session, so it cannot end one; an access token stays valid until its
expiry, at most maxTokenLifetime after issue, plus the server's 60 s leeway
(internal/auth/auth.go:95, internal/auth/auth.go:165); an identity revocation is permanent and is not a sign-out
(internal/api/revocations.go:122).
Proposed.
bronzewardctl logoutdeletes the context's tokens from the store and from its D2 (b) token file, if it has one, revokes the refresh token at the provider's revocation endpoint if it has one, and says that an access token already issued remains valid at the server for up to its remaining lifetime plus that leeway. The endpoint is therevocation_endpointof the provider's discovery (RFC 7009, sent as a public client). The revocation request follows no redirect, so a 3xx answer is a refused revocation. A refusal shows the status and a standard error code only (§6 item 4). Without arevocation_endpoint, logout says the refresh token is not revoked; a refused or failed revocation still deletes the tokens, says the refresh token is not revoked and exits 1.- The web sign-out discards the in-memory tokens and every cached response, revokes the last
refresh token the tab held if the provider allows it, even one it no longer sends for renewal
(after a failed or unrotated renewal, or one another tab may have spent), or else the refresh
token a renewal in flight sent, at once and without waiting for that renewal, and then the pair
it brings, and the pair brought by the late answer of a refresh request that timed out
(§9.3 item 3, #396),
tells other open tabs of the same origin to discard theirs
(assumption S3), and redirects to the provider's end-session endpoint where it offers one, with
client_idandpost_logout_redirect_urionly. It sends noid_token_hint: the test provider's ID token carries the access token's issuer, audience and lifetime, so the server would accept it as a bearer token, and a URL keeps it in history and provider logs. Without the hint that provider sends the browser through its login UI's logout page. It then shows a signed-out page that holds no data. A tab told of another tab's sign-out hides what it shows at once and revokes the last refresh token it holds, as its own sign-out would, since the other tab may not have had it (a renewal answered just before its message). Either sign-out waits at most 5 s for those revocations, for a renewal in flight to revoke the pair it brings and for a timed-out request's answer to revoke its pair; the page then navigates, and an answer that comes later finds no page. A told tab then shows the signed-out page, which holds no lock and records no other tab's refresh token. A tab acts on one sign-out only: a second tab's, or one after its own, would cut that wait short (#388). - After sign-out, the browser's back button shows no data from before: the web pages are served
Cache-Control: no-store, the application clears its state onpagehideand on a page restored from the back-forward cache (§10.3 items 3-4, assumption S8), and it renders nothing without a token.
10. Browser security¶
10.1 Hosting and origin¶
Fact: a cross-origin request carrying Authorization, Idempotency-Key, If-Match or a JSON
Content-Type makes the browser send a preflight OPTIONS without credentials (assumption S4);
this server authenticates every request but liveness, OPTIONS included, and answers 401
(internal/server/server.go:10-13, internal/api/api.go:260-272). A browser on another origin
therefore cannot use the API today, and making it possible would add an anonymous path to the
server.
Proposed, under decision D1 (a): the web interface is a set of static files served from the
same origin as the API by the component that terminates TLS in front of the server, which routes
/api/v1/ and /livez to the server and everything else to the static files. The server is not
changed, serves no files and sends no CORS header, and the API never accepts a cookie. The web
package ships the static files and the response headers the static host must send.
10.2 Script and content policy¶
Proposed. The static host sends, on every page:
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self' <provider origins>; form-action <issuer origin>; frame-ancestors 'none'; base-uri 'none', withrequire-trusted-types-for 'script'where supported.<provider origins>are the issuer's origin and the origins of the token and revocation endpoints its discovery document names, which may differ from the issuer's (§9.3 item 5);X-Content-Type-Options: nosniff,Referrer-Policy: no-referrer,Cross-Origin-Opener-Policy: same-origin,Cache-Control: no-store.
The application loads no third-party script, font or analytics, and includes no telemetry or remote error reporting. Every value from the API, configuration documents included, is inserted as text, never as HTML; a YAML or diff view that highlights syntax builds its nodes from escaped text. These limit, but cannot remove, the risk that an injected script uses the in-memory token: any script running in the page can call the API as the operator.
CSRF. The API authenticates only by the Authorization header and never by a cookie, so a
cross-site request carries no credential. This holds only while no cookie authentication is
added; any future cookie needs its own CSRF design.
10.3 Caches and back navigation¶
Proposed.
- Every API read from the web client uses
fetchwithcache: 'no-store'. The server sendsCache-Control: no-storeon every API answer (§2 caching row, served by A3 for G13), so a browser or an intermediary keeps nothing even from another client. - The application uses no service worker.
- On
pagehideand on sign-out, the application clears its in-memory state and every form field that holds a document or marks. - Pages are served
no-store, so a page reloaded from history renders nothing until signed in again. A browser may still keep ano-storepage in its back-forward cache (assumption S8); on apageshowwithpersistedtrue the application discards any state and renders nothing until signed in again, so the guarantee rests on items 3 and 4, not on the header alone.
11. Protected input¶
11.1 What is protected¶
Plaintext can pass through a client in these places only:
| Input or output | Route | Why it is protected | Source |
|---|---|---|---|
A fragment document |
PUT /drafts/{id}/fragments/{name} |
may hold values the extraction pipeline will move to the provider | keyed document, internal/api/fragments.go:29-31; compilation §2 |
An ingestion document (source: document) |
POST /ingestions |
a full machine configuration with its secrets | keyed document, internal/api/ingestions.go:24-26 |
| Marks | POST /ingestions/{id}/marks, POST /ingestions marks, PUT /drafts/{id}/fragments/{name} marks |
a path can spell a secret mapping key | on the mark route marks is the keyed member (internal/api/marks.go:67-68); on the start route and the fragment write the keyed member is document, and marks stays in the canonical body that the same keyed fingerprint covers (internal/api/ingestions.go:24-26, internal/api/fragments.go:37, internal/api/fragments.go:51-57, internal/api/body.go:170-172); compilation §3.6 item 4 |
| The protected review | GET /ingestions/{id}/review |
can hold a value nobody marked yet | internal/api/review.go:24-31; compilation §3.6 |
| Tokens | every request | credentials | §9 |
Not protected: rendered release reviews, plan diffs and provenance are redacted by the server (compilation §8.3) and are handled as ordinary reads.
The review is served to any human author, not only the one who started the ingestion
(internal/api/routes.go:99), as the contract intends: "only to the roles that may mark"
(compilation §3.6). A client does not narrow this.
11.2 Rules for both clients¶
Proposed.
- Argv and history. No flag takes a protected value. Documents come from a file, standard input or a prompt; marks only from a prompt or standard input (§11.3 item 3); tokens from §9.4. The documentation says that a shell here-document is saved in the shell's history like any command, so a document goes in a file.
- Logs and diagnostics. Debug output (
-v) prints method, path, status,instanceand timing. It never printsAuthorization, a request body of a keyed route, the review body, or marks. Error output repeats the server's problem document, which names no value (persistence and API §9.4), and never the request. - No telemetry in either client, and no crash reporter that sends memory or arguments.
- No temporary files and no editor. A client never writes protected input or the review to a
temporary file and never opens
$EDITORon it. A document the operator edits in their own editor is the operator's file; the documentation says that editors keep swap and backup files the client cannot control. - Core dumps. The CLI disables core dumps for its own process before it reads protected input
(on Linux,
RLIMIT_CORE0 and not dumpable). A panic handler prints a fixed message and no stack values. - Memory. Protected input is held no longer than the request needs. A garbage-collected runtime cannot guarantee erasure; this is a stated limit, not a guarantee.
- Clipboard and export. No copy, download, save, print or share action on the review, on a document being submitted, or on marks.
11.3 CLI review¶
Proposed, qualifying the journeys' candidate C4 against compilation §3.6:
import reviewshows the review only when standard output is a terminal, through an in-process pager that writes nothing to disk. It is refused, with no override, when standard output is not a terminal, and it has no-o json,-o yaml, save or export form. While it shows the review it takes SIGINT, SIGTERM and SIGHUP from before the terminal goes raw until the terminal is restored. On one it leaves the alternate screen, restores line mode, writes nothing more, starts no marks prompt, and ends the process by that signal. A signal the process inherited as ignored (nohup) stays ignored and is not taken. This holds when the terminal has stopped taking output and a write is waiting: the pager stops waiting for that write on the signal, restores line mode, tries once, for a fraction of a second, to leave the alternate screen, and ends the process by the signal. If the pager ends by its quit key and the terminal does not take the bytes that leave the alternate screen in that time, the command fails with an error and starts no marks prompt. On a resize (SIGWINCH) it reads the new size: with the same width the page stays where it is, and with another width the text is wrapped again and the page starts at the same source line. A size below the smallest the pager takes shows a "Window too small" notice instead of the page, ignores every key but quit, and shows the page again once the size is usable. A nonspacing or enclosing combining mark takes no column. A spacing mark takes one, or two when it is wide or full-width, such as U+302E. A wrap keeps either with the character before it (the marks beyond what a row holds, up to the next character that is not a mark, show as their Go escape); full grapheme clusters, such as emoji joined by a zero-width joiner, are not kept whole.- Before showing it, the CLI states that terminal scrollback, session recording and screen capture are outside its control.
- Marks come from the prompt or standard input. At the prompt a line ends at a line feed, a
carriage return or both, because Enter typed ahead while the pager had the terminal raw waits
in the queue as a carriage return; from standard input a line ends at a line feed, or a
carriage return and a line feed, and a lone carriage return stays in the mark. A refused mark is reported by its
position only, as the server reports it, and a refusal can arrive two ways. A mark that is not
a path at all is refused at once,
422 validation-failedwith aposition(internal/api/marks.go:42). Every refusal compilation makes arrives after the202, as amark-refusedevent on the ingestion's operation carrying the problem (internal/api/runner.go:306-314; compilation §3.6 item 4), and the ingestion returns to paused. After a mark's202({operation, ingestion, generation},internal/api/marks.go:105-106) the client therefore follows the operation's events (§8.6) after themarkedevent whosegenerationequals the202's (internal/api/marks.go:102), until the ingestion pauses again, ends, or the run recordsresume-failed. That event (dependency-unavailable) ends the watch without ending the operation: the staged envelope could not be decrypted, the claim staysheldto its lease and the operationrunning(internal/api/runner.go:212-231; persistence and API §9.3). The client reports the mark as not applied, and offersimport takeoveronce the lease lapses, orimport abandon(compilation §3.6 item 6). A run can also stop without writing any event, for example when the serving process is killed: the claim staysheldand the operationrunninguntil the claim's absolute expiry (internal/staging/sweep.go:10-13,internal/staging/sweep.go:134-138), and the stream shows nothing. So while it follows, the client readsGET /ingestions/{id}again whenever theleaseUntilit last read has passed by its own clock, plus a margin for clock difference (internal/api/ingestions.go:238). A read that still showsheldorresumedwithleaseUntilpassed ends the watch, with the operation stillrunning, but the mark's outcome is reported as unknown, not as not applied. The run's closing transaction checks the lease once, under the claim's row lock, not again atCOMMIT: a mark's is its pause,mark-refusedpause or abandonment; a continuation's is its draft transaction releasing the claim, or an abandonment; a takeover's is either of those, or a pause while the claim's review is stillpending, since a takeover of a marked claim resumes without the mark and pauses again (internal/api/runner.go:243-253; compilation §3.5;internal/staging/claim.go:217-241,internal/api/runner.go:372-377). So a run that passed that check before the lease lapsed can still commit, while this read takes no lock and shows the claim's earlier state, orabandonedonce its absolute expiry has passed. The client offers a later read and the same offers asresume-failed. Only the operation settles the outcome: its terminal state; for a mark, thepausedormark-refusedevent at the202's generation, or aresume-failedevent after itsmarkedevent, which leaves the mark not applied (PROT-05); for a takeover, thepausedevent at itstaken-overevent's generation (internal/api/ingestions.go:362; its202names none); or a later takeover, seen as this client's own takeover202or as ataken-overevent at a later generation than the watched run's, whichever client sent it, which fences the earlier run off (it takes the claim's row lock first,internal/staging/takeover.go:65). An earlier mark is then not applied, since the takeover resumes without it, and the operator marks again; an earlier continuation is carried on by the takeover's run (internal/staging/mark.go:32-34). For a mark, any outcome settled above without apausedormark-refusedevent at the202's generation is not applied, so a latersucceededis never read as the mark applied. A claim readabandoned, or a takeover's409 conflict"the ingestion has ended" (internal/api/ingestions.go:340-341), while the operation is stillrunningsettles nothing. A continuation's watch (import continue) and a takeover's (import takeover, which leaves the claimresumed,internal/staging/takeover.go:84) end the same two ways. It reports amark-refusedevent's422as a refusal by position, never as an accepted mark; amark-refusedproblem without a position (503 dependency-unavailableor500 internal-error,internal/api/runner.go:328-338) is shown by its code as a mark that was not applied. The web interface does the same.
The qualification: the server's no-store and the client's refusal to persist limit what the
client keeps. They do not stop the operator, the terminal, a recording or the host from keeping
the text. The requirement is proposed with that limit stated.
11.4 Web review and document input¶
Proposed.
- The review is fetched with
cache: 'no-store', rendered as text in a read-only element, and discarded on leaving the page, onpagehideand on sign-out. - Document and marks fields set
autocomplete="off",spellcheck="false",autocorrect="off"andautocapitalize="off", so browser spelling services do not receive their text (assumption S5). - No draft of a document or of marks is kept between page loads.
- Browser extensions can read the page; the interface cannot prevent this and says so in its documentation.
11.5 Test artifacts¶
Proposed. Client tests use synthetic values only, and every retained artifact is scanned for them as the fixtures already do for evidence (fixtures, synthetic secrets; acceptance plan line 50 describes the scan with a positive control):
- CLI process tests capture standard output, standard error and the process's argument list, and the scan includes all three. The one exception is a protected review (PROT-01): it runs on a pseudo-terminal, and its output is matched in memory and never retained (the fixtures' review rows match the route's answer the same way; fixtures README, S1 operator review rows).
- Browser tests never retain a trace, HAR, video or screenshot of a protected review or of a page holding a document; the other pages may keep them, as the web ticket requires. The scan covers every retained file and fails on any synthetic value, with a positive control that plants one.
- Tests also check that browser storage (local, session except during the redirect, IndexedDB, Cache API) and the HTTP cache hold no synthetic value after each protected journey.
12. Shared conformance cases¶
Proposed. One set of cases both clients run, so their behaviour cannot drift apart. Each case is a language-neutral record:
id: AMB-02
setup: draft at revision 3; signed in as h-author # fixture state, synthetic
action: fragment set worker-labels --file <synthetic file>
exchange: # what the client must send
- PUT /api/v1/drafts/{draft}/fragments/worker-labels
headers: [Authorization, Idempotency-Key, If-Match: <draft ETag>, Bronzeward-Expected-Epoch: <reference epoch>]
fault: drop the connection after the request body is sent
expect:
client: repeats with the same key and same body; on the in-progress 409 repeats again with backoff
server: replay with Idempotent-Replayed, or the first execution
shown: "saved" with draft revision 4; exit 0
absent: [synthetic value in stdout, stderr, argv, logs, storage]
Cases run against a disposable server and the fixture issuer, with an injector between client and server for faults. Interactive sign-in cases need an issuer with authorization endpoints (G19).
| ID | Area | Case | Expected |
|---|---|---|---|
| AUTH-01 | success | sign in, read machines | Bronzeward-Epoch recorded; list shown |
| AUTH-02 | refusal | each fixture defect expired, wrong-issuer, wrong-audience, over-lifetime, alg-none, groups-not-array, id-token-nonce, id-token-at-hash, no-jti (fixtures/oidc/issuer/issuer.go:40-49) |
401; one renewal attempt at most; the client's local reading labelled as such |
| AUTH-03 | refusal | token without the needed group (-without-group) |
403 forbidden; roles named |
| AUTH-04 | refusal | automation token on approve | warned before sending that the route is human-only (§9.4 item 3); the request sent is answered 403 forbidden with humanOnly, shown as in §9.6 |
| AUTH-05 | refusal | identity revoked | 403 identity-revoked; stored credential cleared; no new sign-in loop |
| AUTH-06 | success | token expires during operation watch |
renewed; stream resumed with Last-Event-ID |
| AUTH-07 | refusal | renewal returns a different subject with a write pending | write set aside; change shown |
| AUTH-08 | success | sign-out, then back navigation (web) | no data shown; storage empty |
| AUTH-09 | success | bronzewardctl logout |
tokens gone from the credential store and from a D2 (b) token file; refresh token revoked where the provider allows; remaining access-token validity stated |
| AUTH-10 | refusal | subject listed in deniedSubjects |
403 identity-revoked; same handling as AUTH-05 |
| AUTH-11 | success | web: three tabs whose access tokens reach the renewal threshold together; second case: the holding tab closes after posting renewing and before its answer |
first: one refresh request per renewal, counted at the provider, every tab on the new pair, no redirect; second: no refresh request with the spent token, renewal failed as §9.5 item 3, and after one tab signs in again every tab uses its pair |
| ETAG-01 | refusal | another author writes first | 412; no resend; newer state shown |
| AMB-01 | disconnect | connection dropped before any request byte is sent | ordinary retry |
| AMB-02 | disconnect | fragment write dropped after the body is sent | repeat with the same key; an in-progress 409 (with ingestion, no draft, not replayed) is repeated with backoff up to the client's wait bound, then outcome unknown; a 409 with ingestion and draft (draft published meanwhile) is reported refused, not repeated; otherwise replay or first execution |
| AMB-03 | refusal | 500 internal-error |
repeat with the same key; replayed result |
| AMB-04 | refusal | first attempt committed, its answer dropped; CLI exits with outcome unknown; operator repeats with the printed values and a changed file (existing record) | 422 idempotency-key-reused; reported as: this send did nothing, the original outcome is still unknown; resource read |
| AMB-10 | disconnect | first attempt dropped by the injector before the server receives it; CLI exits with outcome unknown; operator repeats with the printed values and a changed file (missing record) | the changed input executes as a new request; the client reports this send's answer as its own result, never as a replay or as the original outcome; the case records that no client check prevents it (§8.4 item 4) |
| AMB-11 | refusal | as AMB-04 or AMB-10, with the printed --if-match omitted, with another principal signed in, or after a recovery entry changed the epoch from the printed --expect-epoch |
refused by the client before sending the write; without --if-match or under another principal nothing is sent; in the epoch variant only the confirming read is sent (§8.4 item 3); the write is not sent |
| AMB-13 | refusal | as AMB-04 or AMB-10, with --if-match given a freshly read ETag instead of the printed one |
not detectable by the client (§8.4 item 4); with the first attempt's record: 422, as AMB-04; without it: executes when the ETag matches the current resource, reported as in AMB-10, else 412, reported as: this send did nothing, the original outcome is still unknown; resource read; the case records that no client check prevents it |
| AMB-05 | restore | write dropped, then recovery entry before the repeat | epoch confirmed by a read before repeating; it differs, so not repeated; resource read; operator decides |
| AMB-06 | refusal | keyed repeat after the digest key's recorded version became uncomputable | 422; reported as unknown outcome; resource read; no new key sent |
| AMB-07 | restore | signed in as a human principal (a service principal's repeat is refused 401 instead, §8.4 item 3); write dropped; recovery entry injected after the confirming read and before the repeat, so the repeat meets an earlier-epoch record |
409 conflict with request and epoch; no new key sent; resource read |
| AMB-12 | restore | as AMB-07, but the first attempt's record is missing: a restore to a backup taken before it removed it, or the first attempt never committed (G21) | the repeat carries the first attempt's epoch in Bronzeward-Expected-Epoch and is refused 412 epoch-mismatch naming the current epoch, with no act or idempotency record committed (A6, #293); resource read |
| AMB-08 | refusal | write answered 503 dependency-unavailable, e.g. ingestion start with no provider configured |
repeated at most 3 times with the same key; then reported not done |
| AMB-09 | disconnect | write dropped after the body is sent; every repeat answered 503 |
not reported as not done: outcome unknown, key and resource printed as in §8.4 item 4 |
| AMB-14 | disconnect | the injector holds the response headers of a read and of a write for longer than 30 s; second case: it sends 200 headers and holds the body until 60 s after the send; third case: as the second, with 400 headers on the write |
first and second: the read reports a transport failure when the bound fires; the write is repeated with the same key at most 3 times, then reported as an unknown outcome with its key and the resource to read, never as not done; the web notice settles and offers Dismiss; third: the write is reported refused with status 400 and a cut-off answer, not repeated (§8.4 item 8) |
| PAGE-01 | success | list over three pages | every item once, in order |
| PAGE-02 | restore | cursor used after recovery entry | 400 cursor-invalid; list restarted; notice shown |
| EVT-01 | disconnect | stream cut mid-operation | reconnect with Last-Event-ID; no event shown twice |
| EVT-02 | restore | Last-Event-ID from the earlier epoch |
reset; display cleared and replayed |
| EVT-03 | success | operation ends unresolved then cancelled by resolution |
watch continues past unresolved; ends at cancelled |
| EVT-04 | disconnect | stream cut after the operation's terminal state is written and before its last events are sent | the operation read shows the terminal state; the client drains the JSON event pages, shows the missing events once, then ends the watch |
| EVT-05 | restore | recovery entry, on another process over the same database, while a stream stays open | the first event ID with another epoch ends the stream; §8.5 applied; display cleared; stream reopened without Last-Event-ID |
| REC-01 | restore | recovery mode on | banner; refusals name scope or process restriction |
| REC-02 | refusal | recovery entry sent to a normal start; then to a recovery start whose entry committed under another key | first: 409 conflict without epoch or request, read as a normal start; second: 409 conflict with epoch, read as a recovery start, never as a normal one |
| REC-03 | refusal | plan creation on a recovery start after its entry; plan creation on a pre-restore unaccounted scope of a normal start | first: 409 recovery-mode-active without scope, read as a recovery start; second: with scope, start mode unchanged |
| REC-04 | disconnect | recovery entry commits and its answer is lost: (a) the injector drops the 201, and the confirming read answers 200 with the new epoch; (b) the server loses its own COMMIT reply and answers 500, and the confirming read is refused 409 recovery-mode-active; (b) needs a server-side fault hook, not the injector, and the server binary has none (the test-only commit option, internal/api/api.go:131, :190): its server side is covered by internal/api/recovery_entries_test.go:255-296, and its client side waits for that hook, a prerequisite of #263 |
both: repeated under the same key with no epoch check; 201 with Idempotent-Replayed: true and the minted epoch; §8.5 applied without setting the entry aside |
| PROT-01 | success | protected import, review, mark, continue | review only on a terminal (CLI; a pseudo-terminal in tests, its output matched in memory and never retained, §11.5 item 1); no synthetic value in any artifact or storage |
| PROT-02 | refusal | import review with standard output redirected |
refused, nothing written |
| PROT-03 | refusal | mark that is not a path; mark compilation refuses (for example one that would rewrite text); compilation fails on a dependency | first: 422 with position; second: 202, then, after the marked event of that generation, a mark-refused event with 422 and position and the ingestion paused again; third: mark-refused with 503, shown by code as not applied; none reported as accepted, no path shown |
| PROT-05 | refusal | mark accepted (202), then the run cannot decrypt the staged envelope |
after the marked event, a resume-failed event with dependency-unavailable; the watch ends with the operation still running; the mark reported as not applied; import takeover offered once the lease lapses, and import abandon; no wait without end |
| PROT-06 | disconnect | mark accepted (202), then the serving process is killed before the run writes any event; second case: the same during a continuation; third case: the same during a takeover; fourth case: during a continuation, the run releases the claim before its lease lapses and its draft transaction is delayed past leaseUntil before COMMIT, which needs a server-side fault hook the server binary does not have, a prerequisite of #263 |
the client reads the ingestion once its leaseUntil has passed; held (mark, continuation) or resumed (takeover) with leaseUntil passed ends the watch with the operation still running; the mark, continuation or takeover reported as of unknown outcome, never as not applied; a later read, import takeover and import abandon offered; in the fourth case the outcome stays unknown, even when a read shows the claim abandoned, until a later read shows the operation succeeded; no wait until the claim's absolute expiry |
| PROT-04 | disconnect | connection dropped during the review read | nothing persisted; review read again |
| COMP-01 | refusal | GET /server serves an API revision below the client's minimum |
refused before any write, both revisions named; the recovery entry is the exception, since a recovery start refuses that read until its entry commits: the revision shows after the commit, the entry's success stands, and the client says that the read failed |
| COMP-02 | success | response carrying a Deprecation header |
warning shown once, naming the route |
| ETAG-02 | success | edit after the client's own import succeeded; second case: another author writes between the import and the re-read | first: draft revision equals the import's result.draftRevision, the next write uses the new ETag, no 412; second: revision is higher, treated as 412, newer state shown |
| TLS-02 | refusal | API answers a 3xx to another origin, to the CLI and to the web client; harness check |
the injector sees no request to the target and nothing resent by either client; the CLI names the target |
| TLS-03 | refusal | as TLS-02, to the web client, for a read and for a write; browser message check | the page shows the generic transport-failure message naming a refused redirect as a possible cause and no target; the write is repeated with the same key as in §8.4 item 2 and, when every repeat meets the same redirect, reported as an unknown outcome with its key and the resource to read, never as not done |
| TLS-01 | refusal | certificate from an untrusted CA | nothing sent; host and trust source named |
13. Alternatives and open choices¶
D3, D7, D8 and D9 are decided by the lead on 2026-10-21, under the owner's 2026-10-07 delegation, as their rows state. The owner decided D1, D2, D4, D5, D6 and D10 on 2026-10-22, at the times their rows give; D10's option (a) is served by #293. The lead ruled D11 on 2026-11-01, when the specification review asked the question in #463; the owner can overrule it. The campaign lead decided D12 on 2026-11-02, when the specification review asked the question in #486, under the owner's standing rule to take clearly recommended decisions; it is recorded for owner review.
| ID | Choice | Options | Decision and reason |
|---|---|---|---|
| D1 | Web hosting and origin | (a) static files on the API's origin via the TLS terminator; (b) another origin with CORS added to the server; (c) the server serves the files; (d) backend-for-frontend | Decided by the owner (Serge, 2026-10-22 09:50 CEST): (a). The TLS front end routes /api/v1/ and /livez to the server and serves the static web files for everything else (§10.1). Reason: no server change, no anonymous route, no cookie. Rejected: (b) adds an unauthenticated OPTIONS path and (c) an unauthenticated file route, both against "every request authenticates"; (d) is excluded by the issue unless decided explicitly |
| D2 | CLI token store without an OS credential store | (a) memory only, sign in per process; (b) file readable only by its owner, opt-in per context; (c) file by default | Decided by the owner (Serge, 2026-10-22 09:58 CEST): (a) by default, with (b) as an explicit per-context opt-in, offered only if the provider issues rotating, client-bound refresh tokens to public clients (§9.1 item 3, §9.2 item 3). The opt-in is the context's oidc.tokenFile, whose path BRONZEWARD_TOKEN_FILE may override in that context only. (a) also holds a refresh token in process memory, which §9.1 item 3 conditions on rotation as well. Reason: a refresh token on disk is a long-lived credential, so keeping one there is an operator's visible choice per context. Rejected: (c), which puts one on disk without that choice |
| D3 | How a client learns issuer, client ID and audience | (a) by hand in the context; (b) a static client-configuration file served next to the web files; (c) an anonymous API route | Decided by the lead, 2026-10-21, under the owner's 2026-10-07 delegation: (a) now, and (b) if D1 is decided (a). Reason: under same-origin hosting the web client needs the file anyway, and the CLI can import it with context add --from <url> under TLS; (c) adds a second anonymous route. D1 is decided (a), so (b) applies, and entering the values by hand stays possible (§5 item 4) |
| D4 | CLI sign-in without a local browser | (a) device-authorization grant; (b) loopback over a forwarded port; (c) both | Decided by the owner (Serge, 2026-10-22 10:04 CEST): (a) only, with no forwarded-loopback fallback (§9.2 item 2). Reason: device flow suits SSH sessions; it is phishable by a lookalike prompt, so the CLI shows the verification URI's host prominently. Rejected: (b) and (c) |
| D5 | Browser renewal and new tabs | (a) access and refresh tokens in memory only, renewed silently by the rotating refresh token; a new tab receives them from an open tab over BroadcastChannel; a reload or first tab makes a top-level redirect to the provider, which answers from its own session without a prompt; no token in localStorage, sessionStorage or IndexedDB; without rotation, renewal by that redirect, never a hidden frame; (b) a refresh token bound by DPoP, kept in browser storage; (c) an authentication proxy that holds the session and gives the browser a cookie |
Decided by the owner (Serge, 2026-10-22 10:20 CEST): (a) (§9.3 item 3). The owner's constraints (10:10 CEST): "reload and new tab is not acceptable / re-auth manually every 15m is not acceptable". Reason: (a) meets both without writing a token to storage. Rejected: (b), which keeps a credential in storage; (c), a stateful component in front of an API that accepts no cookie (§10.1). An earlier set of options, (b) silent frame renewal and (c) a redirect at every expiry, was replaced: the first breaks under third-party cookie blocking (assumption S2), the second interrupts work every 15 minutes. (a)'s fallback for a provider without rotation behaves as that second option did, so the constraints hold only with a rotating provider, which §15 lists as a provider requirement |
| D6 | Automation token in an environment variable | (a) not accepted, file or stdin only; (b) accepted as BRONZEWARD_TOKEN |
Decided by the owner (Serge, 2026-10-22 09:59 CEST): (a). The path variable BRONZEWARD_TOKEN_FILE is accepted; a secret in an environment variable never is (§9.4 item 1). Reason: an environment variable is inherited by every child process and readable by the same user's processes; CI systems can write the token to a file first. Rejected: (b), the common CI convention |
| D7 | Generated or handwritten clients | (a) both handwritten; (b) an OpenAPI description of v1 in the repository, checked against the route table by a test, generating request and response types for both clients, with handwritten behaviour (idempotency, ETags, epochs, renewal); (c) fully generated clients | Decided by the lead, 2026-10-21, under the owner's 2026-10-07 delegation: (b), generating types only; all behaviour stays handwritten. Reason: the types stay aligned with the server while the behaviour this document specifies stays explicit and tested by the shared cases; no OpenAPI description existed then (search of the repository); api/openapi.yaml now provides it (#275). The Go CLI generates its types too rather than reusing the server's (#254 decision T1, 2026-10-25; §15) |
| D8 | Conformance case format and home | (a) case records in the repository run by both clients' harnesses; (b) separate suites per client | Decided by the lead, 2026-10-21, under the owner's 2026-10-07 delegation: (a), as §12 |
| D9 | Idempotency key metadata across a CLI restart | (a) no local journal: on giving up, the CLI prints the key, route, principal, epoch and If-Match, and the operator repeats with them and the same input (§8.4 item 4); (b) a local journal of pending keys, owner-readable, replayed on the next run |
Decided by the lead, 2026-10-21, under the owner's 2026-10-07 delegation: (a). Reason: a journal would hold request context on disk and need its own expiry and epoch rules. Its cost is that the operator must supply the exact input again: where no record exists, a changed input executes (AMB-10), so an operator who cannot reconstruct it reconciles from the resource; (b) avoids that reconstruction only if the journal holds the request itself, documents included, which on the keyed routes (fragment write, ingestion start, mark) would write protected plaintext to disk against §11.2 item 4 and the secret-ingress boundary. So (b) could journal request bodies only for non-keyed routes, and for the keyed ones offers nothing over (a) |
| D10 | Epoch check on a human's repeated write (G21) | (a) server-enforced: mutations accept an expected-epoch request header and refuse, committing no act or idempotency record, when it differs from the current epoch, giving humans an epoch check like the one service tokens already get (API issue A6); (b) client-only check of §8.4 item 3 for humans, with the race between its confirming read and the repeat explicitly accepted | Decided by the owner (Serge, 2026-10-22 08:43 CEST): (a), served by #293. Reason: it closes the race at the only place that knows the epoch at commit time, and keeps the earlier-epoch 409 of AMB-07 because, unlike the service check, which refuses before any record is answered (internal/api/idempotency.go:47-49), it runs only when a lookup finds no record and refuses only under the key's lock: the write's own, a fragment write's claim transaction's or the first lookup's fresh read (internal/api/idempotency.go:52-58, internal/api/tx.go:204-210, internal/api/fragments.go:95-103, internal/api/tx.go:71-81, as A6). Rejected: (b), which needs no server change and leaves a short window, but a human's repeat that lands in it after a restore removed the record, or whose first attempt never committed, executes afresh, with no error to any party |
| D11 | Which remote text the CLI escapes in human output | (a) all remote text, with no list of fields, escaped as a value before it is formatted, tab and line feed included; (b) only fields that hold free text; (c) the list of item 4 as it stands, with the server refusing control characters in its inputs | Ruled by the lead (2026-11-01): (a) (§6 item 8). Reason: the API schemas give most text members as plain strings, and a format that a schema declares binds a correct server, not the bytes that arrive, so no field can be called safe by its format, and a list of fields goes out of date with every command. A test that fills every string member of every response type is what keeps the rule true. Rejected: (b), which needs that list and trusts the formats, declared or not, of a server it cannot check; (c), which trusts the server for resource text and not for its version string, and leaves the CLI open to any input the server does not refuse |
| D12 | A client's bound on its own request | (a) no client bound, so the browser or the operating system decides; (b) one total bound per logical write, its repeats included; (c) a bound per attempt on the response headers and on the whole attempt, with the same values in both clients; (d) an idle bound that fires only when no byte arrives for some time | Decided by the campaign lead on 2026-11-02 under the owner's standing rule to take clearly recommended decisions, recorded for owner review: (c), with 30 s for the headers and 60 s for the attempt (§8.4 item 8). Reason: fetch has no timeout of its own, so under (a) a stalled answer keeps a write on "Saving" or "Repeating" until the operator leaves the page, which drops the write (#486). The CLI already bounds each attempt with these values (internal/client/transport.go:91-95), so one pair of values keeps both clients and AMB-14 the same. A request body is at most 1 MiB (§8.1), lists come in pages, and a write that starts a long job answers 202 and goes on as an operation (§8.2), so a working server is expected to answer well inside 60 s. A write that it answers later is handled as an ambiguous attempt, under the same key. Rejected: (b), which gives a repeat no time of its own after a stalled first attempt used the budget; (d), which a server that sends a byte from time to time never fires, and which Go's HTTP client does not offer for a body read |
14. Proposed API-gap issues and downstream refinement¶
This document creates no issue. The owner decides which to open and links them natively.
A1. Serve the caller's identity and roles. An authenticated GET that answers the
principal's kind, issuer and subject or service name, the principal ID when one exists, the roles
the credential maps to, the credential's expiry and the current epoch. It must answer a
credential that maps to no role, which the current role check would refuse
(internal/api/api.go:385-391), so it needs a decision on whether a role-less identity may read
its own identity. Closes G1; blocks the CLI and web sign-in tickets' role display. Served since
#277: GET /identity in persistence and API §9.2,
and the role-less read decided in §10.3.
A2. Serve the server version, API revision and installation settings. An authenticated
GET with the server's version, the API revision within v1, the optional features it serves, and
the read-only settings a client shows: maxTokenLifetime, the settle floor, the maximum transport
deadline, the ingestion claim's lease and absolute expiry, and the plan defaults; and the serving
process's start mode, normal or recovery. Closes G2, G18 and G22; gives the in-progress 409 wait of §8.4 item 2
a server-derived bound; lets clients refuse an incompatible server
(the CLI ticket's acceptance criterion). Served since
#278: GET /server, any role, in
persistence and API §9.2, and the API revision and
its table in §9.1. It describes the process that answered.
A recovery start refuses it before its entry commits, as every request but the entry, so G22
stays open for that window (§8.5).
A3. Response headers for clients. Cache-Control: no-store on every authenticated response,
a request-ID response header equal to the problem instance, and keep-alive comments on the event
stream at a fixed interval. Closes G13 and G16, and the idle-cut risk in
§8.6. Served since #279.
A4. Navigation reads for the journeys. A list of ingestions (filterable by state and machine),
the ingestion named in its operation's subject, and list filters: plans by state and
machine, machines by cluster; and a member on the machine stating whether it is pending
convergence, computed from the digests the server already compares. Closes G3, G10, G24 and the
first part of G9. Served since #280: the routes
and filters in persistence and API §9.2, the ingest
subject in §8.3 and pendingConvergence
in §9.3; a machine with a Desired release and no Applied
state reads as pending convergence.
A5. A sign-in-capable test issuer. Add a pinned Zitadel, the reference provider of §15, to the fixtures (#281), with authorization-code and PKCE, refresh with rotation, revocation, end-session and device authorization, which D4 (a) makes the only headless sign-in, so client sign-in is tested against an endpoint and not a minted token. Closes G19; blocks the sign-in tickets' verification. Served since #281, with the residuals listed in G19 (fixtures README).
A6. An expected-epoch precondition on mutations (D10 option (a); served by
#293). Every mutating route accepts an
optional Bronzeward-Expected-Epoch naming the epoch the client expects, checked only when the
idempotency lookup finds no record, and refused only by a lookup under the key's lock: the
write's own, a fragment write's claim transaction's or a fresh one (internal/api/idempotency.go:52-58,
internal/api/tx.go:204-210, internal/api/fragments.go:95-103, internal/api/tx.go:71-81),
so an earlier-epoch record
still answers its 409 (AMB-07); a mismatch is refused 412 epoch-mismatch naming the current
epoch and commits no act or idempotency record, while what the route committed before that lookup,
a fragment write's claim, a scope mark's observation or a principal row, stays
(persistence and API §7.2). It gives human
principals an epoch check like the one service tokens get, but placed differently: the service
check refuses a stale token before any record is answered
(internal/api/idempotency.go:47-49). Both clients send it on every write and every repeat,
except the recovery entry, neither its first send nor a repeat: its first send is already
refused by its own first step when another entry has committed since the process started
(internal/api/recovery_entries.go:133-135), and its own commit changes the epoch, which
§8.4 item 3 lets its repeat cross. The server's
own-entry check still bounds that repeat, and its residual (a repeat with no record becoming a
later start's entry) stays open. Closes G21.
A7. Principal display names. A way to show a principal identifier as a person or service:
either a read that maps an identifier to its kind and issuer and subject or service name, or that
name served next to the identifier in the bodies that carry one (acts, releases, plans,
approvals, recovery entries). Which identities a caller may resolve is part of the issue. Closes
G23; until then clients show shortened principal identifiers. Specified by
#335: GET /principals, any role, mapping up
to 100 identifiers in one read, in persistence and API §9.2;
the batch read was chosen over names in the bodies (choice
§17.40). A client resolves the identifiers a
web view or a table output shows in requests of at most 100 identifiers each, each given once, and shows a human by its
display name, served from API revision 5, or by subject and issuer when display is null
(persistence and API §10), a service
by its name, and the shortened identifier for one answered unknown or when the read fails.
-o json and -o yaml print the resource with the same values, full identifiers included
(operator experience §4).
C1. Contract correction. Add the reset event to the stream contract of
persistence and API §8.3 (G15). A
documentation change for the landing of this specification or its own small change. Applied: both
lists name it.
C2. Contract correction. The recovery-mode-active row of
persistence and API §9.4 said the body names the scope, but the
process refusals of a recovery start, before and after its entry, carry no scope member
(internal/api/api.go:242-244, internal/api/recovery_exits.go:211-213). The code is right: a
process refusal concerns no scope. State that only the scope refusals name the scope (G22,
§8.5). A documentation change, like C1. Applied: the row says so.
L1. Later design issue: draft render and field provenance. A render of a draft before publication, and provenance that says which fragment set a field, are compilation design changes outside this milestone (G5, G6). Proposed as one issue for a later milestone; no client ticket waits for it.
L2. Later design issue: serving observed configuration. Whether and how drift content is served is a redaction design question outside this milestone (G7); until it is settled clients use a drift-adoption import with review.
Downstream refinement. Each client ticket cites this document's
sections as its contract, and its status/needs-refinement label is lifted only with the
dependencies below in place:
| Ticket | Depends on | Sections |
|---|---|---|
| #254 CLI sign-in, contexts, inventory | A1, A2, A5, D2-D4, D6, D9, D10 | 5, 6, 7, 8, 9, 12 |
| #255 web sign-in, navigation, inventory | A1, A2, A3, A5, D1, D3, D5, D10 | 5, 7, 8, 9, 10, 12 |
| #256, #257 import, drafts, publication | A4, D9 (CLI), D10 | 8.3, 8.4, 11 |
| #258, #259 plans, approval, operations | A4 (plan filters), D9 (CLI), D10 | 8.4, 8.6 |
| #260, #261 drift and recovery | A2 (settings, start mode), D9 (CLI), D10 | 8.4, 8.5, 8.6 |
| #262 packaging and documentation | D1, D3, D7 | 6, 7, 9.1, 10.1, 10.2 |
| #263 validation | A5, D8; a server-side COMMIT fault hook for REC-04 (b); a server-side hook delaying a draft transaction's COMMIT after its claim's release for PROT-06's fourth case |
11.5, 12 |
A7 blocks no ticket: until it lands, the tickets from #256 to #261 show shortened principal identifiers (G23). A4's convergence member, served since #280, did not block the inventory views of
254 and #255 either (G24).¶
15. Assumptions and limits¶
Assumptions this document relies on without a citation in this repository:
- S1. The browser
EventSourceinterface cannot send anAuthorizationheader; a streamedfetchresponse can (WHATWG HTML and Fetch standards). - S2. Browsers that block third-party cookies make hidden-frame OIDC renewal unreliable.
- S3. Tabs of one origin can signal each other with
BroadcastChannel. - S4. A cross-origin request with an
Authorizationheader, a custom header or a JSON content type is preceded by a preflightOPTIONSsent without credentials (Fetch standard, CORS protocol). - S5. Browser spelling services can send field text to a remote service unless spell checking is off for the field.
- S6. The identity providers operators run can issue JWT access tokens with a configurable audience and groups claim, PKCE public clients and rotating refresh tokens. One provider was exercised for Talos access (talosctl-oidc research), and #281 exercised Zitadel v4.19.5 for this client's grants (live checks); no other release or product was.
- S7. A browser
fetchfollows a redirect by default and resends the method and body on a307or308;redirect: 'error'makes any redirect a network error instead (Fetch standard). - S8.
Cache-Control: no-storedoes not keep every browser from restoring a page from its back-forward cache; a page restored that way receivespageshowwithpersistedtrue (HTML standard). - S9. Every matrix browser provides Web Locks (
navigator.locks), grants an exclusive lock to one tab of an origin at a time, in request order, and releases a tab's locks when it closes or leaves the page (Web Locks API). AUTH-11 observes the grant order and the release on close.
Implementation choices the owner made on 2026-10-22:
- CLI language (Serge, 10:23 CEST): Go, as a second binary,
cmd/bronzewardctl, beside the server'scmd/bronzeward. Rejected: TypeScript shared with the web client; Rust or Python. D7 (b) generates both clients' types from the OpenAPI description. The Go CLI does not reuse the server's Go types: decision T1 of #254 (the lead, 2026-10-25, under the owner's 2026-10-07 delegation) keeps D7 (b) for the CLI. The types are generated by oapi-codegen v2.8.1 intointernal/client/apitypes(mise run generate), andmise run gofails when the generated file is stale. Reasons: the server's response types are unexported, and importinginternal/apiwould link the server's provider, Talos and database access into the CLI. - Frontend framework (Serge, 10:27 CEST): TypeScript and React, built with Vite into static
files, with no server-side rendering, as D1 (a) implies. Dependencies follow a tight policy:
pinned versions, no large UI kit, updates through Renovate. The API types are generated from
the OpenAPI description, as D7 (b) states. The operation stream is read with
fetch, becauseEventSourcecannot send anAuthorizationheader (assumption S1). Rejected: Svelte; Lit web components or no framework. - Browser support matrix (Serge, 11:58 CEST): current desktop browsers, the last two major
versions of Chrome, Edge and Firefox, plus Firefox's current extended-support release, and
Safari 16 or later. Mobile browsers may work but are not a tested target. Reason: it covers desk
use and every browser feature D5 (a) needs:
BroadcastChannel, in Safari since 15.4, and streamedfetchand WebCrypto, in all of them. Rejected: mobile Safari and Chrome as tested targets, because responsive layouts across eleven screens cost too much for the PoC; a single browser. - Reference identity provider (Serge, 10:33 CEST): Zitadel, for the test issuer (#281), the packaging guide (#262) and the usability validation (#263). Bronzeward itself stays a generic OIDC relying party. Rejected: Keycloak, the one provider exercised live in this repository (talosctl-oidc research); no reference provider, with only the stub issuer.
The decisions above add these requirements on any identity provider a deployment uses. #281
verified each against Zitadel v4.19.5
(live checks): the device grant
(check 1), rotation on both clients (check 2, and the self-test's CLI renewal), refusal of a
rotated-out token (check 3), binding to the client (check 4), the audience (check 6, with the
project's aud scope) and a groups claim, which needs an Actions v2 target because the role
claim is an object and no groups scope exists (checks 7-8):
- the device-authorization grant on the CLI client (D4 (a));
- refresh tokens that rotate on every use and are bound to the client, for public clients of both kinds, the browser client and the native CLI client (D2 (b), D5 (a));
- JWT access tokens whose audience carries the server's value (§9.1 item 1);
- a groups claim that is an array of strings. The server refuses a token whose groups claim is
not one (
internal/auth/auth.go:213-225). Zitadel's role claim is an object, so the test provider adds agroupsarray from an Actions v2 target.
Residual risk of the D2 (b) token file and of D5 (a), with Zitadel v4.19.5, observed by #281
(check 3): a replayed refresh token that was already used is refused, but the session is not
revoked. Whoever refreshes a copied token first therefore keeps that session until its idle or
absolute expiry, and the legitimate client is refused and signs in again. Rotation does not
prevent this; the mitigation is deployment configuration, a short absolute refresh-token
expiration, the instance OIDC setting refreshTokenExpiration, which bounds renewal (check 5).
The packaging guide of #262 documents it.
Limits:
- This specification is accepted and partly implemented: with an automation token (#254), bronzewardctl reads version, whoami, status, clusters, machines with their timeline and observations, fragment, profile and assignment heads, revisions and history, drafts, releases, plans, operations and acts. Its tests run the §12 cases TLS-01, TLS-02, COMP-01, COMP-02, PAGE-01, PAGE-02 and REC-01. bronzewardctl's other reads of §3.1, its sign-in and the web client are not implemented yet. No synthetic contract probe was run against the server for this document: every server behaviour above is read from source and contract text, and G17 is inferred, not observed.
- For a person signed in as recovery-admin (#361), bronzewardctl reads recovery status and sends recovery enter, recovery exit and identity revoke; enter and revoke are sent only when the confirmation is typed back. Its tests run REC-02 and REC-04 (a) against a served bronzeward. REC-04 (b) waits for the server-side COMMIT fault hook of #263.
- Line references were read at
3cbe953, except those to the identity read and the expected-epoch check, which are read with #277 and #293 respectively applied (see the opening), and those to the web client in §8.6 item 6, which are read with #443 applied. They move as the code changes. - Every choice in §13 and every implementation choice above is decided. This document decides no credential policy.
- It does not change any role, human-only rule, route, schema or server setting.