A version moving

A construct version rolling out, as it happened VERSIONING-WALKTHROUGH.md

A component type goes to 2.0.0

What actually happens when a platform construct makes a breaking change, told through the one this platform has really done: ruby-service 1.1.0 → 2.0.0 on 2026-09-06, which removed the xlarge size class.

docs/VERSIONING.md is the rules. This is the events, in order, with what each one looks like and — more usefully — what it does not look like. Four of the five steps produce no build, no deploy and no log line, and the first time this ran end to end the honest report was "I see no evidence of the 2.0.0 transition". That reaction was correct, and most of what follows is about why.

Read the object names below as they were on the day. Later that same day the naming changed: every construct object now carries its major, so the two objects here are ruby-service-v1 and ruby-service-v2, the suffix is derived from the version rather than passed as OUT_SUFFIX, and which version an unpinned consumer gets is an openchoreo.dev/default label rather than whichever object happened to hold the bare name. The console pages moved with them. Nothing else in this account changed, and it is left as it was written because it is a record of what happened rather than a manual — docs/VERSIONING.md is the manual.

Screenshots are in docs/screenshots/, captured by platform/dev/shoot.py against a console fed from a kubectl get of the real control plane (see Reproducing this).

As a single page, with every screenshot inline: https://claude.ai/code/artifact/84ba46ab-7b1a-409a-ac5c-d391f2579099

That page is the same content and is easier to send to somebody who does not have this repository checked out. It is private until it is shared, so a colleague clicking it cold will be refused rather than shown a 404 — ask for access rather than assuming the link has rotted.

Its screenshots are downscaled and palette-quantised to fit the page budget, and its /upgrades shot is cropped to the head of a list that runs to 115 rows. docs/screenshots/ holds the full-resolution, uncropped originals. The page is built from platform/dev/walkthrough-page.html by platform/dev/inline-images.py, which is the only reason it can be rebuilt at all.


0. The shape of the thing before anything moves

A ComponentRelease inlines the whole ComponentType spec and every Trait spec, and its name is an FNV hash over that structure. The platform has always pinned by value; a release is already a complete, immutable, content-addressed snapshot of the golden template.

What versioning added is not the pin. It is three things the pin could not do:

missing added by
nothing named which declared version a release froze spec.componentType.version on the ComponentRelease
nobody chose the pin — staging was upgraded involuntarily on the next push spec.componentType.version on the Component
nothing showed what would change before adopting the contract-diff page, and the same diff in the PR body

All three need Component.spec.componentType to be mutable, and upstream makes it self == oldSelf. platform/openchoreo-patches/construct-versioning.patch narrows that to self.kind == oldSelf.kind && self.name == oldSelf.name: identity and workload type stay immutable, the version may move. Every pinning scheme needs that, including a version-in-the-name scheme, so the choice between them was about ergonomics and never about whether a patch was required.

The construct catalog

1. Publishing 2.0.0

One command emits a second object beside the first:

ONLY=ruby-service CONTRACT_VERSION=2.0.0 OUT_SUFFIX=-2 SIZES=small,medium,large \
  platform/openchoreo-config/component-types/generate.sh

The identity is in labels, not in the name:

metadata:
  name: ruby-service-2                 # any DNS name
  labels:
    openchoreo.dev/name: ruby-service  # the identity
    openchoreo.dev/version: "2.0.0"

The unsuffixed object keeps its name and gains the same labels, so every consumer that has not pinned keeps tracking it and nothing that exists breaks.

What this looks like: nothing. No build, no deploy, no release. Publishing a version nobody has adopted changes no running workload. The construct page is the only place it shows up — and note that it is a page per object, so 1.1.0 and 2.0.0 are two pages (/platform/component-types/ruby-service and …/ruby-service-2) rather than one page with a version selector. Each lists only the consumers that resolve to it: pinned consumers on the version they pinned, and unpinned ones on the floating object, because floating resolves by name. That split is the whole question a second version raises — who moved and who did not.

ruby-service, the floating 1.1.0 object ruby-service-2, carrying 2.0.0

Side by side those two pages are the clearest statement of what a version is here: same description, same eight rendered objects, same refusals — and sizeClass reading small | medium | large | xlarge on one and small | medium | large on the other. The manifest snippet differs too, and that is the part to copy: the pinned page says name: ruby-service, version: "2.0.0", never name: ruby-service-2. The object name is an implementation detail of publishing; the identity is the label.

2. What 2.0.0 actually changes

The contract diff

Read this page rather than a file diff. The two ComponentType objects differ in a few hundred lines of CEL, template YAML and probe wiring, and almost none of it is anything a consumer can see. The question is narrow — will my manifest still apply, and will it deploy the same thing? — and docs/VERSIONING.md already defines the contract that answers it.

For 1.1.0 → 2.0.0 the whole contract change is one row:

environmentConfigs.openAPIV3Schema.properties.sizeClass.enum[3]
  1.1.0: xlarge
  2.0.0: —
  removed from environmentConfigs — what a platform engineer may tune per
  environment. A consumer depending on it breaks.

Two properties of that page are worth more than the row itself:

3. The upgrade arrives as a pull request

platform/upgrade-watch gained a fourth axis: for every Component, Resource and Project it compares the pinned construct version against the newest supported one under docs/VERSIONING.md's window (the current major, plus the previous major until the next minor after it), and reports the gap.

The fourth axis

It proposes; it does not act. The PR is opened from the console, by a person, through /actions/open-pr and project-flow's /git/* — so there is one code path that writes to git and one audit trail that names who asked. Every PR says so in its own footer. The body carries the contract diff from step 2, built from the same /api/platform/…/compare bytes the page renders, so the diff in the request and the diff on the page cannot disagree:

adoption:inkwell-web:component-type:ruby-service: 1.1.0 → 2.0.0

* **1.1.0 → 2.0.0** (major)
* inkwell-web → component-type ruby-service

### What changes for a consumer
1 breaking change
| environmentConfigs...sizeClass.enum[3] | xlarge | — | removed from
  environmentConfigs … A consumer depending on it breaks. |

What this looks like: a PR, and nothing else. Opening it deploys nothing.

One thing the PR cannot do for you. splice() rewrites the version and only the version — that is what keeps the diff to one token and makes it safe against a hand-written file, and on several pins the comment beside the version is the reason the pin exists. It cannot update prose. So this PR bumped ruby-service to 2.0.0 under a comment reading "2.0.0 is published and this stays on 1.1.0 deliberately", and it stayed that way through the merge, the release and the promotion. The pull request body now quotes any comment still naming the old version and asks a person to look; it does not edit it, because a bot rewriting somebody's reasoning is a worse idea than a bot leaving it stale and saying so.

Two things the scanner had to get right, both found by it being wrong first:

4. Merging it

The merge is the interesting part, because it is deliberately not special:

merge  →  sync_manifests() applies the Component
       →  the controller cuts a new ComponentRelease
       →  staging autoDeploys it
       →  the ordinary promote moves it to production

The upgrade becomes a normal deploy — normal preview, normal capacity check, normal rollback. That property is the point of routing it through git rather than through a platform button.

For inkwell-web the merge produced inkwell-web-7767957469, which froze ruby-service@2.0.0 with sizeClass enum ['small','medium','large'].

The component

What this looks like, and why it looked like nothing

There is no build. A component-type version bump re-renders from the same image; nothing about the application source changed, so there is nothing to compile. The last build in the console will be whatever last touched the app. This is correct and it is also the single most confusing thing about the whole flow — if you are looking at /builds for evidence, there will never be any.

There may be no deploy. Where the new release goes depends entirely on the binding it lands on:

environment binding state at merge what happened
staging Undeploy the binding's releaseName moved to the 2.0.0 release and nothing started, because nothing is running
production Active untouched at this point — production does not move until step 5

Both of those are the platform working. Together they mean that at the end of the merge, a correct and fully-recorded major version transition had produced zero running-workload change, and every screen that reports running workloads showed nothing. (Production has since been promoted — step 5 — and that did not change the count, for a different and more interesting reason.)

The place it is visible is the release history:

The release history

inkwell-web-7767957469   component type 1.1.0 → 2.0.0 (major), and 2 other fields

That line is recent. Until 2026-09-06 the same release summarised itself as 3 fields changed — which is what a whitespace edit says too. The summary named the image change and nothing else, so the first construct major bump on this platform read as noise. A contract change now leads the line, with the severity derived from the two versions rather than declared: the image says which build is running, the construct version says which rules it runs under, and only one of those can invalidate somebody's manifest.

5. Promotion

The project Deploys

Production does not move until somebody promotes. That is the gate working, and it is the last step of the transition rather than a missing one.

The gate needs a Ready source, and a hibernated staging is not Ready. promote() refuses (retry, "staging is not ready; not promoting") — correctly, because promoting from an environment nothing has run in promotes an untested release. Waking inkwell-web staging was not enough either: it connects to inkwell-enrich, which connects to ledger, and a pending connection keeps a binding out of Ready. Three bindings had to be awake, and the status field that says so is status.pendingConnections, which names the component and the reason (component is undeployed). The resources were already Active, so the chain stopped there.

Done 2026-09-06T18:04:41Z through project-flow's /promote:

inkwell-web-production: inkwell-web-78b68c54f7 -> inkwell-web-7767957469
  promoted-from: staging   promoted-by: thomas   previous-release: inkwell-web-78b68c54f7

The promotion restarted nothing, and that is the right answer

The production pod's age did not reset. Not a failed deploy — the two releases render identically, checked rather than assumed:

image old == image new     default-inkwell-rails:v1-c260ec93@sha256:b550b4d6…
rendered templates identical: True
workload identical:           True

The whole difference between them is the contract: an enum value, a description, and the version. Production declares no sizeClass (33 of the platform's 34 bindings do not), so removing xlarge changes nothing it renders. Production is now running under the 2.0.0 rules, and Kubernetes had nothing to do about it.

That is what a well-chosen breaking change looks like from the inside: loud in the contract, silent in the cluster. It is also the fifth and last place in this walkthrough where the correct outcome is "nothing appears to have happened" — and the reason the summary line on the release page had to be fixed, because that line is now the only place the transition is visible at all.

previous-release is recorded, so POST /rollback can put production back on inkwell-web-78b68c54f7 without a build.

What production actually saw

Read that against the "every release, as built" view earlier and note they disagree, correctly. As built, the step was 1.1.0 → 2.0.0. As production saw it, the step was — → 2.0.0: the release production was running (inkwell-web-78b68c54f7) predates the pin entirely, so production went from unversioned straight to 2.0.0 in one move.

That is the whole reason the per-environment view exists. A ComponentRelease carries no environment and one is cut per build, so diffing them in creation order pairs releases that were never adjacent anywhere. The environment view joins them through the image digest to the deploy history — the only record of what an environment actually ran.

This promotion broke that view, in two ways, and both were visible in the first screenshot taken after it. They are worth reading as a pair, because they are the same fact seen from two sides: a construct version bump re-renders from the same build, so two releases carried one image.

  1. The join kept one release per image and broke ties by recency. Production's 14:36 deploy was therefore handed to a release created at 16:55 — so the page dated 2.0.0 to 14:36, and inkwell-web-78b68c54f7, which production ran for three and a half hours, disappeared from its own history. A deploy can only have been running a release that already existed, so the tie is now broken by the deploy's own time.
  2. The promotion itself emitted no deploy event at all. openchoreo_deployment_info is keyed on the image ref, and the image did not change, so there was nothing for the join to attach to. The page showed the previous release as the current one: production running under a different contract, and the view unable to say so. That row now comes from the binding, which holds the release it is on and carries project-flow's promoted-at — prepended only, never used to overrule a row the metric already has. The metric is evidence of what ran; the binding is evidence of what it was pointed at, and they are not interchangeable.

The row says what it is rather than borrowing a deploy's vocabulary: promoted into production; no new image, so nothing restarted.


What to look at, in order, when this seems not to have happened

The four questions that resolve it, cheapest first:

export KUBECONFIG=~/.kube/openchoreo.yaml

# 1. Did the pin actually change?
kubectl --context k3d-openchoreo-cp get component inkwell-web \
  -o jsonpath='{.spec.componentType}{"\n"}'
# {"kind":"ClusterComponentType","name":"deployment/ruby-service","version":"2.0.0"}

# 2. Did a release freeze it?  (The version is a SPEC FIELD, not an annotation.)
kubectl --context k3d-openchoreo-cp get componentrelease inkwell-web-7767957469 \
  -o jsonpath='{.spec.componentType.version}{"\n"}'

# 3. Which binding points at it, and is that binding even running?
kubectl --context k3d-openchoreo-cp get releasebindings \
  -o custom-columns=NAME:.metadata.name,ENV:.spec.environment,STATE:.spec.state,RELEASE:.spec.releaseName \
  | grep inkwell-web
# ...-staging  staging  Undeploy  inkwell-web-7767957469   <- moved, not running
# ...-production production Active inkwell-web-78b68c54f7  <- awaiting promotion

# 4. Only then look for a build. There will not be one.

If (1) and (2) hold and (3) explains where it went, the transition happened. A quiet console after those four is the platform being correct, not the platform being broken.

Eight ways the console reported this as nothing

The transition above was correctly recorded by the platform from the first minute: the Component carried the pin, the release froze it, and the diff was exact. Every fault was in the telling, and they are worth listing because they are nearly all one mistake — a page that had the data, failed to match it, and reported the miss as a fact about the world. The last two are its close relative: a page that matched the data to the wrong thing and reported that with equal confidence.

where it said it should have said
/components/{name} HTTP 500 the page — it passed a delivery row to a macro wanting a graph node, and died on label after rendering the links
release summary 3 fields changed component type 1.1.0 → 2.0.0 (major)
release summary image v1-926b2fa0 → v1-926b2fa0 image v1-926b2fa0 rebuilt (e4ccece96889 → 7cc43db98a44) — same tag, different digest
per-environment releases No ComponentReleases recorded for rideshare the binding there is hibernated; 21 releases exist
construct page, Used by Nothing on this platform uses it 1 — components write deployment/ruby-service, the object is ruby-service
construct page, How you ask for it name: ruby-service-2 name: ruby-service, version: "2.0.0"
production release history 2.0.0 dated 14:36, and the release production really ran missing 2.0.0 at 18:04, marked as a promotion; the displaced release restored
production release history the promotion absent entirely it changed no image, so it emitted no deploy event — that row comes from the binding

Four of those actively assert something false about the platform, which is worse than a blank panel and much worse than an error. The console's _http.soft / Unavailable discipline exists precisely to keep "could not read" separate from "nothing there"; not one of the eight was caught by it, because in every case the read succeeded and the matching failed. That is a distinct failure mode and it now has tests.

There is also one efficiency fault with the same root: the per-environment view asked for the whole namespace and discarded all but one component in the next loop — eight pages and about 10MB, ~2s, measured twice in the API access log, to answer a question about 21 objects.

Reproducing this

The screenshots come from a console running on a workstation against a snapshot of the real control plane:

platform/dev/console-local.sh              # capture + serve + run, on :18000
platform/dev/shoot.py platform/dev/shots-versioning.txt docs/screenshots/

console-local.sh runs the real console image's code with real Prometheus, Tempo, Loki and Pyroscope, a replayed OpenChoreo API (platform/dev/ocapi-snapshot.py, from kubectl get -o json), authentication off, and writes off — the promote and rollback controls on a local console would otherwise post to the real project-flow.

The API has to be the replayed one. The console reads it with the signed-in person's OIDC token, deliberately, so that the platform's own ClusterAuthzRoleBindings are the console's authorization model and there is no second copy of the rules living in the console. With auth off there is no token and the API answers 401, and there is no machine identity to borrow: the one the platform entitles (system-app-binding.yaml, matching sub: openchoreo-system-app) has no credentials on this cluster, and the backstage client, which does have credentials, is entitled to projects and components and to no releases at all.

That last fact is worth keeping. An unentitled caller does not get 403 from a list endpoint — it gets 200 {"items": []}. Measured with the backstage client:

projects           200  8 items
components         200  21 items
componentreleases  200  0 items     <- not "none exist"; "none for you"
releasebindings    200  0 items

GetComponentRelease has a 403 branch and ListComponentReleases has none, so a permission gap and an empty platform are the same response. Any tool reading these lists — this console included — cannot distinguish them and should not pretend to.