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-v1andruby-service-v2, the suffix is derived from the version rather than passed asOUT_SUFFIX, and which version an unpinned consumer gets is anopenchoreo.dev/defaultlabel 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.mdis 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.

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.

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

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:
- Severity is derived, never declared. Nobody types "breaking" anywhere. The
page applies
docs/VERSIONING.md's own rules to the fields, so the answer on the page and the answer in the document cannot drift. It reports implied bump: major beside declared bump: major rather than reconciling them — when those disagree exactly one is wrong, and the page has no way to know which, but a person reading it does. - The description change is shown, as implementation. Rewording the
sizeClassdescription cannot break a manifest, so it is classified out of the contract and listed anyway. A page that hid it would be unable to show that switching a resource type from MinIO to Garage touched almost every line and was correctly a patch — the most instructive thing this platform has done with a version number.
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.

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:
- It reports a component that pins no version as a finding of its own. An
unpinned consumer re-renders from whatever the construct says on the day
something cuts a release for it, which is the involuntary upgrade this whole
axis exists to end. As of this writing that is 113 of 115 adoption
findings; the two that pin are
console(python-service 1.1.0) andinkwell-web(ruby-service 2.0.0, since this walkthrough). - The pin matcher tolerates comments between
name:andversion:. The first version required them adjacent, which matched no manifest in this repository — including the ones whose comment explains why the pin is there. - The scan is every six hours. For the three upstream axes that is fine;
for adoption it is not, because adoption changes the moment somebody merges.
Between the merge and the next scan this page said
inkwell-web 1.1.0 → 2.0.0about a component already on 2.0.0.POST /upgrades/rescanexists for exactly this and takes a few seconds. The page's ownstaleflag is set at twice the interval — twelve hours — so it will not warn you inside that window.
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'].

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:

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

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.

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.
- 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. - The promotion itself emitted no deploy event at all.
openchoreo_deployment_infois 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'spromoted-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.