Design sketch: the customer update-review flow (m197b)
Design sketch: the customer update-review flow (m197b)
Section titled “Design sketch: the customer update-review flow (m197b)”Documentation only. No code, no migrations, no routes, no endpoints exist yet — this is the contract sketch, before anyone builds it.
Status of this document: every claim about existing code carries a
file:line citation. Box citations point into this worktree, where
every cited file is unmodified since 3f451e4 — this packet adds one new
file and touches nothing else. Parlor, Website and Kit citations point into
the sibling checkouts under cafaye/, read-only; the Website ones are read
with git show <branch>:<path> because that checkout sits on a different
branch and must not be disturbed. Anything extrapolated rather than cited is
marked [UNVERIFIED] with the reason. Nothing here crosses
docs/BOUNDARIES.md.
0. The binding constraints
Section titled “0. The binding constraints”These are load-bearing (docs/BOUNDARIES.md:1-20); every section below is
written against them:
- B1 — Parlor is the single UI (
docs/BOUNDARIES.md:6-8). Box is a Cafaye-only JSON API and “must never gain a customer-facing frontend, admin console, or any other human UI.” The review surface this document sketches therefore lives in Parlor; Box only records and serves state. - B2 — Box is Cafaye-only (
docs/BOUNDARIES.md:9-11). Nothing in the flow assumes Box runs on customer boxes. - B3 — the data plane stays put (
docs/BOUNDARIES.md:12-14). The apply step itself is executed by the customer’s own deployment substrate (Kit/Argo, §2.4); Box coordinates from the outside. - B4 — the customer’s app repo stays their source of truth
(
docs/BOUNDARIES.md:15-17). Box records coordination state (releases, update decisions); it never rewrites customer source. - B5 — no release is auto-applied without customer acceptance
(
docs/BOUNDARIES.md:18-20). “Any future release/update workflow must gate on an explicit customer acceptance step. A ‘convenient’ auto-apply is a defect, not a feature.” §6 is the enforcement section.
Authorization must follow the shipped pattern exactly: no JWT
verification of Box’s own; the caller’s bearer is forwarded to
Identity only (README.md:50-56; app/clients/identity_client.rb:8-20);
denial bodies are generic unauthenticated/forbidden/not_found/
unavailable and never name other customers’ IDs
(app/controllers/application_controller.rb:55-83); bearer tokens and
Identity response bodies are never logged
(app/clients/identity_client.rb:19-20).
Recording must follow the shipped precedent: every state change is an
append-only event row, insert-only, with the acting Identity user ID
attached (app/models/entitlement_event.rb:1-21).
1. What the customer sees
Section titled “1. What the customer sees”Given B1, the customer’s surface is Parlor’s. Two checkouts matter here and they are not interchangeable:
Parlor itself (cafaye/parlor, on master) already owns the two route
families this flow needs. The account screen is
src/routes/accounts/$accountId.tsx (the param spelling the router types;
the screen it mounts is src/app/accounts/[accountId]/page.tsx), and the
staff console is src/routes/admin.tsx, whose route file states that the
staff gate is “a fresh operator/me read inside it — hiding this link or
rendering client-side is not authorization”
(cafaye/parlor/src/routes/admin.tsx:10-11) — the same per-request re-check
Box itself performs (app/clients/identity_client.rb:50-65).
Parlor’s src/lib/ holds no Box client: its service clients are
identity.ts, billing.ts, accounts.ts, operator.ts, credentials.ts
(cafaye/parlor/src/lib/), and a scan of src/app/accounts/,
src/app/admin/ and src/lib/ finds no installation, box, chart or
release surface. Parlor is where the screens belong; it has nothing to
extend yet.
The Parlor-derived Website portal (cafaye/website, branch
website-bootstrap-m161) is the only frontend that already speaks to
Box. REPORT-website-conductor-ui-m169.md:5-8 calls it “the only
frontend for both the customer portal and staff operations”, built strictly
against Box’s versioned contract: src/lib/box.ts transcribes
the four operations and nothing else (src/lib/box.ts:5-12), and every
call is forwarded through the same-origin allow-list’s box entries
(src/lib/upstream.ts:146,535-566). The customer surface of the entitlement
slice is src/app/accounts/[accountId]/entitlement-panel.tsx; the staff
surface is src/app/admin/admin-screen.tsx.
So the review flow attaches to those same two places, in that order. On the
customer side it is a sibling panel on the account screen, next to where
<AccountEntitlementPanel token accountId> already sits
(cafaye/website/src/app/accounts/[accountId]/account-screen.tsx:148,
alongside headingId="account-facts" at 188, members at 237, rename at
505, invite at 587). On the staff side it is a sibling of
Panel headingId="admin-entitlements" title="Kit entitlements"
(cafaye/website/src/app/admin/admin-screen.tsx:530). No new route family,
no second frontend: B1 holds.
Three conventions those panels already enforce, which the new panels must copy rather than reinvent:
- The service’s
detailis never rendered. Every sentence is written in the client, “because that string is where a host, a port or an internal class name would come from” (cafaye/website/src/app/accounts/[accountId]/account-screen.tsx:27-29;cafaye/website/src/app/accounts/[accountId]/entitlement-panel.tsx:12-13). A Boxerror.messageis therefore mapped by the panel, never printed. - 404, not 403, for “not a member”. One “cannot see it” state that
says neither “no such account” nor “not a member”
(
cafaye/website/src/app/accounts/[accountId]/account-screen.tsx:14-19), matching Box’s identical-body denial (app/controllers/application_controller.rb:66-71). - No service credential in the browser. The caller’s own session token
rides every call and the client never mints, stores or defaults one
(
cafaye/website/src/lib/box.ts:13-19).
[UNVERIFIED] — the new panels’ names and first home: no such panel exists
today in any checkout (a scan of the Website branch’s src/app tree and
Parlor’s src/app tree shows only the entitlement panels above). The
specific child-route or panel name (e.g. an updates panel) is
extrapolated; the route families it lives inside are verified. Whether the
panels land first on Parlor proper or on the Parlor-derived Website branch
that already carries the Box client is a product decision
(§8, item 11).
1.1 The customer’s review states
Section titled “1.1 The customer’s review states”A customer moves through these states, each one a page/banner in Parlor:
| State | What the customer is shown |
|---|---|
offered (a.k.a. review pending) |
“An update is waiting for your decision.” The offer names what changes (product, current version → target version, and the version’s stability tier — §2.3), which installations it targets (the account’s named installations and their current versions), what accepting means (the named installations will be moved to the target version by the customer’s own substrate, at its next reconcile), and what rejecting means (installations stay on current; the offer closes; staff will contact you if the release is security-flagged). Accept and Reject buttons. |
accepted |
The decision is recorded with timestamp and actor. The page shows that the substrate has been (or is being) driven toward the target version, and what the customer will see next. |
rejected |
The decision is recorded; the offer is closed with a “why” line the customer may add. Nothing changed on any installation. |
applying |
Progress indication; the customer is told the substrate is reconciling and that they do not need to do anything. Read-only. |
applied |
Success: per-installation result (which installation is now on which version), when, and a link to the update history. |
apply_failed |
Failure, in plain words: which installation failed, when, that installations are still on their current version (or, if partial, which ones moved), and what happens next (staff has been alerted; a retry is a new offer). |
rolled_back |
What the customer sees when the follow-on review completes: not a seventh state but a reading of a second review’s applied row (§5). “Production is back on version X, at |
Every state is reachable read-only; only offered has decision buttons.
A customer never sees another customer’s IDs, versions of other accounts,
or internal Box identifiers — consistent with the generic-denial
rule (app/controllers/application_controller.rb:68-71) and the “never
names other customers’ IDs” README rule (README.md:54-56).
The decision buttons are additionally gated client-side the way Parlor
already gates every button: can(role, capability) reads
src/lib/roles.ts, “a transcription of the identity service’s tenancy”
(cafaye/parlor/src/lib/roles.ts:4) — a reason not to offer a button,
never a substitute for the service’s answer, which is 403 when the
transcription is wrong (cafaye/parlor/src/app/accounts/[accountId]/account-screen.tsx:5-10).
For update-review the only capability that matters is “is a member of this
account”, and that is Box’s per-request Identity call
(app/controllers/application_controller.rb:36-53); the client-side gate
merely decides whether the buttons are drawn.
1.2 What Box records, per state
Section titled “1.2 What Box records, per state”The state a customer is shown is derived from the event stream, never from
a mutable status column — the review’s state is the action of its most
recent event. That is what makes the history in §1.1 true by construction
and what makes B5 checkable (§6): to know whether anything was applied you
read the events, and there is no field to set that would make the events
lie. One row per event, insert-only, in the EntitlementEvent style
(app/models/entitlement_event.rb:1-21):
| Customer-visible state | Box’s record | Fields carried |
|---|---|---|
offered |
offered event (T1) |
release id + version, the targeted installation_ids, the resolved from_version, expires_at?, actor = creating staff user |
accepted |
accepted event (T3) |
actor = the member’s Identity user ID, accepted_at, the release + installation set as accepted (snapshot, so a later installation rename cannot rewrite what was consented to) |
rejected |
rejected event (T4) |
actor = the member’s Identity user ID, reason? free text, rejected_at |
applying |
applying event (T6) |
actor = staff or plane operator, started_at; forbidden unless a preceding accepted event exists (§6.3) |
applied |
one applied event per installation (T7) |
label, result_version, completed_at, actor |
apply_failed |
apply_failed event (T8) |
failing labels, detail, failed_at, actor — and no forward arrow (§2.5) |
expired |
expired event (T2) |
actor = system or the withdrawing staff user, expired_at |
rolled_back |
a new review’s events (T9 + T1..T7), plus a superseded event on the original |
supersedes → the original review id; the original’s own state never changes (§5) |
Nothing here is a column update. An EntitlementEvent-style model refuses
update and destroy outright
(app/models/entitlement_event.rb:14-21), so “correcting” history means
appending, exactly as the entitlement slice’s grant/revoke precedent
requires (app/controllers/operator/entitlements_controller.rb:40,62).
2. The state machine
Section titled “2. The state machine”2.1 States
Section titled “2.1 States”An UpdateReview is one customer-visible, per-account decision object about one Release applied to a set of Installations.
┌──────────── new review (T1) ────────────┐ │ │ ▼ │ ┌─────────┐ accept (T3) │ staff creates (T1) ───────▶ │ OFFERED │ ──────────────────────▶ ┌──────────┐ └─────────┘ │ ACCEPTED │ │ │ └──────────┘ reject (T4) │ │ expire (T2) │ applying (T6) ▼ ▼ ▼ ┌──────────┐ ┌──────────┐ ┌──────────┐ ok (T7) │ REJECTED │ │ EXPIRED │ │ APPLYING │───────────▶ ┌─────────┐ └──────────┘ └──────────┘ └──────────┘ │ APPLIED │ (terminal) (terminal) │ fail (T8) └─────────┘ ▼ │ ┌────────────────┐ │ │ APPLY_FAILED │ (terminal: │ └────────────────┘ no outgoing │ │ arrow) │ a new review (T1) may be │ offered to the customer │ │ ROLLBACK is not a state of this review. Undoing is always a NEW UpdateReview whose Release │ is the previously-applied version (supersedes = this review, §5), and it then runs the │ same offering → accepted → applying → applied path. Nothing auto-rolls-back.The brief’s requested shape maps exactly: offered (review) → accept or
reject → applied / apply_failed (failed) / rolled_back (a follow-on
review’s outcome shown in the same history).
2.2 Exact transitions and the event that records each
Section titled “2.2 Exact transitions and the event that records each”Every transition is an insert-only event row on the review, in the
EntitlementEvent style (app/models/entitlement_event.rb:1-21), each
with actor_user_id (the Identity user ID of whoever caused it) and
occurred_at. No update path, no delete path, before_update /
before_destroy refusal copied from the precedent.
| # | From → To | Event action | Caused by | Who acts (Identity) |
|---|---|---|---|---|
| T1 | — → offered |
offered |
Staff creates a review (Parlor admin) | staff user |
| T2 | offered → offered |
expired |
Offer TTL elapsed, or staff withdrew it | none (system) or staff |
| T3 | offered → accepted |
accepted |
Customer presses Accept | account member |
| T4 | offered → rejected |
rejected |
Customer presses Reject | account member |
| T5 | offered → decided |
(no event) | Second accept/reject attempt | — (refused by API, §4.4) |
| T6 | accepted → applying |
applying |
Apply begins (substrate move starts) | staff or plane operator |
| T7 | applying → applied |
applied |
Outcome reported per installation | staff/plane operator |
| T8 | applying → apply_failed |
apply_failed |
Outcome reported with failure detail | staff/plane operator |
| T9 | applied → (new review) |
superseded |
Rollback review created for this one | staff user |
offered means awaiting customer decision. Only T3/T4 may leave it;
there is no event without a named actor and no transition out of
offered other than decision or expiry — this is B5 mechanically.
[UNVERIFIED] — who performs T6/T7/T8 in production: a staff member in
the Parlor admin UI, an operator runbook, or an automated plane callback.
§8 keeps this open. The API is shaped so the answer can be any of the
three without changing the state machine.
2.3 The Release object
Section titled “2.3 The Release object”A Release is an immutable, Cafaye-side record of a shippable version —
for Kit, the box-declared version. Kit is explicit that its VERSION file
“is the single source of truth for how safe a version is to consume”
(cafaye/kit/README.md:22-24) and that a stability tier is derived from
the version string — MAJOR = breaking, MINOR = additive, PATCH =
invisible (cafaye/kit/README.md:25-30), with a MAJOR without a
MIGRATIONS.md section “a red build” (cafaye/kit/README.md:30). That
derivation is not prose only: Kit’s own stability_tier derives the three
tiers from the version and stability_gate_check gates a release on what
the tier owes, including “a version string the derivation cannot place is a
red build rather than a guess” (cafaye/kit/README.md:166). A
Box Release therefore carries:
product(kit, perEntitlement::PRODUCTSapp/models/entitlement.rb:7);version(string, the single source-of-truth value);tier(derived per Kit’s rules — recorded at Release creation, never re-derived at display time, so the fleet cannot drift);migrations_ref(pointer to theMIGRATIONS.mdsection for that version; required whentier = breaking);artifact_ref/chart_ref(what the substrate will pull — for Kit the chart whose name and version are read fromChart.yaml, the OCI tag and the Argo pulltargetRevisionbeing “one fact written twice” (cafaye/kit/README.md:1263-1265, the publish end of the pull-based loop described atcafaye/kit/README.md:116));notes(human summary shown in Parlor).
The customer’s offer (T1) reads from this record. This keeps Box’s record of what is being offered traceable to the same facts the fleet already treats as canonical, without Box reading customer source (B4).
2.4 The Installation object
Section titled “2.4 The Installation object”An Installation is one of the account’s deployed boxes. Box
holds no box access (B2/B3, docs/BOUNDARIES.md:25 excludes
“customer-stack code, SSH/box access, or deployment/publish workflow”),
so an Installation record is coordination metadata only:
account_id— Identity account UUID, string, no cross-service foreign key, matching the existing rule that Box “never owns account data and must keep working (fail-closed reads) even if Identity is down” (app/models/entitlement.rb:1-5);label(the customer-facing name, e.g. “production” / “staging”);current_version,target_version— last reported / being driven to;status_hint(e.g.reconciling,healthy) as last reported.
How a box reports this state is Kit’s pull-based loop: the box’s Argo CD
Applications reconcile toward chart versions from the OCI registry
(cafaye/kit/README.md:116,148, cafaye/kit/README.md:1263-1265) — “a
commit changes a chart, Argo makes the box agree” (cafaye/kit/README.md:116).
The substrate install itself carries those Applications:
templates/substrate/manifests/applications/ is “the eight Argo CD
Application manifests … These are the artifacts a values commit changes
and Argo reconciles” (cafaye/kit/README.md:148). [UNVERIFIED] — the exact
channel by which a box’s applied-version state reaches Box (Argo CD API
read by staff, a reporting job, or a callback) and the reporting cadence.
What is verified is the shape of the source of truth: chart version /
Argo targetRevision are “one fact written twice”
(cafaye/kit/README.md:1263-1265). §8 leaves the sync channel open.
2.5 Recovery transitions
Section titled “2.5 Recovery transitions”apply_failed (T8) has no forward arrow inside its own review. Recovery is
always a new review — either staff-offered re-offer of the same
Release, or a rollback review (§5) — because every change to what is
applied must pass the acceptance gate. expired (T2) is likewise
closed-ended; expiry policy (TTL length) is deliberately not decided (§8).
3. Accept and reject — meanings and consequences
Section titled “3. Accept and reject — meanings and consequences”Accept (T3) — the customer’s explicit consent, named and timestamped.
- Customer sees: confirmation, the exact version that will be applied and when (at the substrate’s next reconcile).
- Box records:
acceptedevent with the member’s Identity user ID, the acceptedReleaseid and version, and the Installation set. From this moment the review is decided and immutable (§4.4). - Consequences: the target version becomes the permitted target for the
named installations — nothing more and nothing less. Box does not
apply anything itself (B3); it hands the accepted intent to the plane
(staff/operator/plane, §2.2 T6-T7). If the customer’s entitlement for
the product lapses between accept and apply — staff revoke writes
status = "revoked"plus arevokeaudit event (app/controllers/operator/entitlements_controller.rb:60-63), and the model distinguishes the two states (app/models/entitlement.rb:17-21) — the apply must not proceed: entitlement state is checked before T6, and a lapsed entitlement freezes the review inacceptedwith staff alerted.[UNVERIFIED]— whether a lapsed-entitlement freeze should instead force a new offer; treated as an open question, §8.
Reject (T4) — the customer’s refusal, equally explicit.
- Customer sees: the offer closed, optional free-text “why” recorded with the event, and what happens next (nothing changes; staff may follow up).
- Box records:
rejectedevent with actor, version, and reason. - Consequences: installations stay on
current_version. A new Release may be offered later as a new review (T1 on a fresh review object) — re-offering must never rewrite or resurrect the rejected one, because the event history is append-only (app/models/entitlement_event.rb:1-5).
Neither decision is ever inferred from silence, timeout, or an operator
clicking on the customer’s behalf; an operator may act only in the staff
surface, and staff actions land in the event log attributed to their own
Identity user ID (the entitlement slice’s grant/revoke precedent,
app/controllers/operator/entitlements_controller.rb:40,62).
4. The Box API surface the flow would need
Section titled “4. The Box API surface the flow would need”Sketched in the style of docs/openapi/entitlements-v1.yaml: one
versioned document per workflow, docs/openapi/update-reviews-v1.yaml to
be written when the workflow exists — the scope guardrails require each
new endpoint to have its workflow first (docs/BOUNDARIES.md:26-27).
Prose/tables below are the contract; paths, methods, statuses and bodies
are exact enough to transcribe into YAML without invention. Shared
conventions, all already proven in the entitlement slice:
bearerTokensecurity, “forwarded to Identity for authorization” (docs/openapi/entitlements-v1.yaml:12-19);Errorschema witherror.codeenumunauthenticated | forbidden | not_found | unprocessable | unavailable(docs/openapi/entitlements-v1.yaml:21-32);- 401 missing/invalid token, 403 authenticated-but-wrong-role, 503
Identity unreachable-or-unconfigured fail-closed
(
docs/openapi/entitlements-v1.yaml:84-98,121-123,158-161,180-183); - 404 bodies identical for unknown-vs-unauthorized
(
docs/openapi/entitlements-v1.yaml:89-93).
4.1 Customer surface (via Parlor, member-scoped)
Section titled “4.1 Customer surface (via Parlor, member-scoped)”Member check per request, exactly as EntitlementsController#index does
it: UUID-validate the path id, then require_account_member
(app/controllers/entitlements_controller.rb:5-13,
app/controllers/application_controller.rb:36-53).
| Method | Path | Meaning / notes |
|---|---|---|
| GET | /v1/accounts/{account_id}/update-reviews |
The account’s reviews, newest first, ?status= filter (offered, accepted, rejected, applying, applied, apply_failed, expired, superseded). 200 { update_reviews: [...] }; empty array when none (matches the entitlement list’s empty case docs/openapi/entitlements-v1.yaml:74). |
| GET | /v1/accounts/{account_id}/update-reviews/{id} |
One review in full: release (product, current→target version, tier, migrations_ref, notes), targeted installations with current versions, and the full event history. 200; 404 same body as “not yours” (docs/openapi/entitlements-v1.yaml:89-93). |
| POST | /v1/accounts/{account_id}/update-reviews/{id}/accept |
Records T3. 200 { update_review } (state accepted). 404 unknown-or-not-member; 409 when already decided — returns the row with decided: true, mirroring the entitlement slice’s safe-retry 409 (docs/openapi/entitlements-v1.yaml:148-157); 410 when expired (offer no longer live). |
| POST | /v1/accounts/{account_id}/update-reviews/{id}/reject |
Records T4. Body: { reason?: string } (free text, length-bounded). 200 { update_review } (state rejected); 404 / 409 / 410 as above. |
UpdateReview response object (customer view): id, account_id,
state (enum: offered | accepted | rejected | applying |
applied | apply_failed | expired | superseded), release
({product, from_version, to_version, tier, migrations_ref, notes}), installations ([{label, current_version}]), decided_by (null until decided — the Identity user
ID once decided, not exposed to other accounts by rule), decided_at,
created_at. Status enums appear in three places (path enum, response
enum, event list) and must be generated from one source — the m171
StaffEntitlement allOf correction exists because two status enums
drifted apart and made the contract false
(REPORT-conductor-entitlements-m165.md:68-92).
4.2 Staff surface (via Parlor admin, staff-scoped)
Section titled “4.2 Staff surface (via Parlor admin, staff-scoped)”require_staff per action, as today (app/controllers/operator/entitlements_controller.rb:8,17,53).
| Method | Path | Meaning / notes |
|---|---|---|
| GET | /v1/operator/update-reviews |
Staff list/filter (account_id, status, product), newest first, capped at 100 — same cap-and-filters shape as docs/openapi/entitlements-v1.yaml:99-123. This is also the stuck-update view: filter status=applying or status=apply_failed (§7). |
| POST | /v1/operator/update-reviews |
Creates a review (T1). Body: { account_id, release: {product, to_version, tier, migrations_ref, notes}, installation_ids: [...], expires_at? }. Box resolves from_version from the account’s Installation records — staff never hand-type a from-version. 201 { update_review } (state offered); 404 unknown account (generic, same rule as entitlements); 422 validation (e.g. tier=breaking without migrations_ref, §2.3). There is deliberately no field in this body that can preselect a decided state — §6. |
| POST | /v1/operator/update-reviews/{id}/record-outcome |
Records T6/T7/T8 from the plane side. Body: { event: applying | applied | apply_failed, installations: [{label, result_version?}], detail? }. 200 { update_review }; 409 if the event is not legal from the review’s current state (e.g. applied on a rejected review); 404 unknown. [UNVERIFIED] — whether the plane authenticates as staff at all, or needs its own credential class; §8. |
| POST | /v1/operator/update-reviews/{id}/supersede |
Creates the rollback review (§5): same target shape as T1 but target = previous applied version resolved from the installations’ history, supersedes = this review. 201 returns the new review in state offered. The old review’s state is unchanged; a superseded event (T9) is appended to it. |
No DELETE and no PATCH on any of these — the event stream is the only
way state moves, the same discipline as EntitlementEvent
(app/models/entitlement_event.rb:1-5).
4.3 Worked flows
Section titled “4.3 Worked flows”Accept path: Parlor: POST /v1/accounts/{acct}/update-reviews/7/accept Header: Authorization: Bearer <customer Identity session token> Box: IdentityClient#account_member? (identity_client.rb:72-81) → 200 identity answer = member; 404 = non-member → render_not_found (application_controller.rb:36-53,68-71) → UpdateReview transaction: insert an `accepted` event (actor = the member's Identity user id); the state the API answers is that event's action, per §1.2 → 200 { update_review: {...state: "accepted"} } (T3)
Apply failure path: Parlor admin: POST /v1/operator/update-reviews/7/record-outcome body { event: "apply_failed", installations: [{ label: "production", detail: "chart pull refused" }] } → 200 { update_review: {...state: "apply_failed"} } (T8) → customer sees §1.1 apply_failed row; staff sees it in the status=apply_failed filter of the operator list.4.4 Decisions are single-shot
Section titled “4.4 Decisions are single-shot”An accept/reject attempt on a decided or expired review is a refusal —
409 with the row plus decided: true (or 410 if expired) — matching how
the entitlement slice makes retries safe without double-writing history
(docs/openapi/entitlements-v1.yaml:148-157;
REPORT-conductor-entitlements-m165.md:46-47). The append-only guard
makes a second decision physically unwritable even by a bug
(app/models/entitlement_event.rb:14-21).
5. Rollback
Section titled “5. Rollback”A rollback is not a special power — it is a new UpdateReview whose
Release is the previously-applied version, created by
POST /v1/operator/update-reviews/{id}/supersede (§4.2), and it then
passes through the identical gate: offered → accepted → applying → applied (§2.1). Consequences:
- No silent revert. Rolling back cannot happen on a failure event
alone;
apply_failed(T8) has no forward transition in its own review (§2.5). An operator cannot “just roll it back” without a customer decision, because there is no code path that applies a version without anacceptedevent. - What the customer sees. The failure appears as
apply_failed(§1.1) — installations still on current version, staff alerted. The recovery offer appears as a new row: “Return production to version X”, its Accept/Reject buttons meaning exactly what §3 says, with the target being the previous version. On acceptance and completion the history shows the rollback as its ownappliedrow; the original failed review staysapply_failedforever (T9 marks itsuperseded), so the record reads as: update attempted, failed, rollback offered, rollback accepted, rollback applied. Nothing is rewritten (B4-adjacent: history is append-only by construction). - Unwanted-but-applied rollback. Same mechanism after
applied: the supersede flow creates a review targeting the prior version; the customer’s Accept is what moves the box.
The substrate executes the move; Box records it (B3). Kit’s
pull-based reconciliation is what carries the box back to the old chart
version — same mechanism, earlier target (cafaye/kit/README.md:1263-1265).
6. How the BOUNDARIES line-5 gate is enforced
Section titled “6. How the BOUNDARIES line-5 gate is enforced”docs/BOUNDARIES.md:18-20 forbids auto-apply. In this sketch the gate is
enforced in four independent places, so that no single mistake can apply
an unaccepted release:
- Every creation path is gated at birth. Reviews are created only by
T1 (
POST /v1/operator/update-reviews) or by the supersede path (§4.2), and the only state either can produce isoffered— neither create body has a field capable of producing any other state, and a supersede returns the new review inofferedwhile leaving the old one’s state untouched. A review cannot be born decided, and anappliedreview cannot be created directly. - Single-shot decisions with append-only events. T3/T4 are the only
events that can decide a review, each requires the customer’s bearer
to pass the same per-request Identity member check as every other
customer read (
app/controllers/application_controller.rb:36-53), and the event model refuses updates/destroys (app/models/entitlement_event.rb:14-21). There is no actorless transition out ofofferedexcept expiry (§2.2). - Apply reads the decision, never the clock. The plane-facing
record-outcomeendpoint (§4.2) acceptsapplyingonly for reviews in stateaccepted; any other precondition is a 409. A scheduler that tried to apply “because the offer is old enough” would have to fabricate a state transition the API does not offer. - Rollback passes the same gate. §5 makes even reverting a release a customer-accepted review, so the emergency-safe path people reach for (“just undo it”) cannot become the auto-apply bypass.
“A convenient auto-apply” — named as a defect per B5 — would look like
any of: an endpoint that creates a review already in accepted; a job
that flips offered → applying after a timeout; a
record-outcome call accepted without a prior accepted event; a
rollback that runs without a superseding accepted review; or staff
“accepting on behalf of” a customer with no member token check. Each is
excluded by construction above, and each should be added as a failing
test the day implementation starts (not part of this documentation-only
packet).
7. Success reporting and the operator view of a stuck update
Section titled “7. Success reporting and the operator view of a stuck update”Success reaching the customer. Box records T7 per installation
(applied, with the result version), which is what the Parlor review page
renders as applied (§1.1). The same event powers the account’s update
history. Whether a customer is additionally pushed a notification
(email via Courier, the customer data plane’s mail surface
docs/BOUNDARIES.md:12-14) is a real option but is deliberately left
open — §8. The in-product record is the guaranteed channel; anything else
is additive.
[UNVERIFIED] — whether any customer-facing notification surface should be
used at all. What is verified is that none exists to reuse today: Parlor’s
route families are accounts, admin, settings, invitations,
verify-email, login, register, forgot-password, reset-password
(cafaye/parlor/src/routes/), its email surfaces are the transactional
Identity ones (verification, password reset —
cafaye/parlor/src/app/settings/settings-screen.tsx:38-47), and neither
Parlor nor the Website branch has a notification list, toast centre, or
inbox. A push channel would be new surface, so the sketch does not assume
one.
The stuck-update operator view. GET /v1/operator/update-reviews with
status=applying and status=apply_failed (§4.2) is the staff surface:
staff see the account (by ID, per the entitlement list’s account_id
filter docs/openapi/entitlements-v1.yaml:102-105), the release, each
installation’s last reported state, and how long the review has sat in
its state (created_at plus the applying event’s occurred_at). Per
B1 that view lives in Parlor’s admin area — today’s shadow is
cafaye/website/src/app/admin/admin-screen.tsx
(cafaye/website/REPORT-website-conductor-ui-m169.md). What staff see of
another account’s data is itself constrained: staff are re-checked per
request against Identity’s GET /v1/operator/me
(app/clients/identity_client.rb:50-65), so operator visibility is
exactly what staff authorization already grants — no new role is
invented by this flow. Stuck-alerting (paging, thresholds, escalation)
is deliberately not decided (§8).
8. What is deliberately NOT decided
Section titled “8. What is deliberately NOT decided”The sketch’s contract stops here; the following are open questions for whoever implements it. None blocks the shape above; each was left open on purpose rather than guessed.
- Offer lifecycle policy — expiry TTL for
expired(T2), whether offers auto-expire at all, and who can withdraw one. - Failure recovery ownership — whether
apply_failedauto-creates a re-offer (staff-triggered here) and whether the customer sees a “retry” action at all. - Who may accept on multi-member accounts — any member (the
entitlement read’s member check,
app/controllers/entitlements_controller.rb:8) or only a designated owner role; whether a second member may veto an acceptance. - The plane↔Box channel — exact mechanism and credential class
for
record-outcome(staff token, a plane credential, or a runbook-driven staff action); §2.2 T6-T8 and §7 depend on this. - How Installation state is reported in — Argo CD read, callback, or
staff-entered (§2.4); this decides whether
current_versionis live or approximate. - Entitlement lapse during
accepted— freeze (assumed here, §3) vs. forced re-offer; interaction with the existing revoke path (app/models/entitlement.rb:17-21). - Push notifications — Courier email or nothing beyond the Parlor record (§7); who authors those templates if it happens.
- Rollback authorization nuance — customer acceptance is assumed mandatory for every rollback (§5); whether an operator-force rollback without acceptance can ever exist (it would need a written BOUNDARIES amendment naming it) is a product decision, not an implementation one.
- Pagination and cap — the 100-row cap is inherited from the
entitlement list (
REPORT-conductor-entitlements-m165.md:101-103); update-review pagination is unresolved the same way. - Idempotency keys — deferred in the entitlement slice
(
REPORT-conductor-entitlements-m165.md:104-106); the same deferral applies here, with the same safe-retry 409 pattern (§4.4) covering it. - The new review screens’ names and first home — §1 verifies the
route families (Parlor’s
accounts/$accountIdandadmin) and the per-request staff check, but not the new child-route names or components, nor whether they land first on Parlor proper or the Parlor-derived Website branch (§1).
Appendix: citations used
Section titled “Appendix: citations used”Box worktree (box-update-flow-m197 @ 3f451e4):
docs/BOUNDARIES.md:1-33, README.md:1-101,
app/models/entitlement.rb:1-22, app/models/entitlement_event.rb:1-21,
app/clients/identity_client.rb:8-20,50-81,
app/controllers/application_controller.rb:9-83,
app/controllers/entitlements_controller.rb:5-27,
app/controllers/operator/entitlements_controller.rb:1-85,
docs/openapi/entitlements-v1.yaml:1-183,
REPORT-conductor-entitlements-m165.md:46-47,68-92,99-112,
REPORT-conductor-foundation-m160.md:108-121.
Siblings (read-only): cafaye/kit/README.md:22-24,25-30,116,148,166,1263-1265;
cafaye/website/REPORT-website-conductor-ui-m169.md:5-8;
cafaye/website/src/app/accounts/[accountId]/entitlement-panel.tsx;
cafaye/website/src/app/admin/admin-screen.tsx;
cafaye/parlor/src/routes/accounts/$accountId.tsx;
cafaye/parlor/src/routes/admin.tsx:10-11;
cafaye/parlor/src/app/accounts/[accountId]/account-screen.tsx:14-19,27-29;
cafaye/website/src/app/accounts/[accountId]/account-screen.tsx:14-19,148,27-29;
cafaye/website/src/app/admin/admin-screen.tsx:530;
cafaye/website/src/lib/box.ts:5-19;
cafaye/website/src/lib/upstream.ts:146,535-566 (branch website-bootstrap-m161;
read with git show <branch>:<path>, never checked out).