Credentials on this page are redacted. This is the public export; the real values live in docs/ACCESS.md in the private repository.
Feedback, round by round
What was asked for, and what it turned out to be — FEEDBACK.md
Feedback, and what happened to it
Four rounds of notes used to sit loose in the repository root — fixes.md,
more-work.md, grafana-fix.md, more-fixes2.md — two of them not even
committed. They are the record of what was actually asked for, so they are kept
verbatim below rather than summarised, each with its current status.
The HAR captures that came with rounds 3 and 4 have been deleted. They were full browser session captures (55MB, Grafana / Zulip / Thunder cookies in the clear) and the issues they documented are resolved or recorded here.
Status vocabulary: done · partly done · open · answered (a question, not a defect) · won't (decided against, with the reason).
Round 1 — fixes.md
logging into portal get this error:
Origin 'http://portal.TAILNET.ts.net:8080' is not allowed
done. CORS origin allowlist; see platform/bootstrap/values/obs-observer-cors.yaml
and the Thunder app scripts under platform/bootstrap/thunder-scripts/.
I want to see the best observability story I can. I'm interested in the grafana lgtm stack, especially pyroscope. I want to see as much as possible, ebpf auto instrumentation is a plus, as well as instrumenting the actual app to get better metrics, access to stack, queries. Open to other observability stacks. I want to trace from commit push to post-deploy as richly as possible. There are notes about network enrichment via cillium too.
done, and still the main line of work. Loki / Tempo / Pyroscope / Prometheus
behind Grafana (26-lgtm.sh), Alloy on all four planes (27-alloy.sh), Beyla
eBPF on the data plane (28-beyla.sh), Cilium with Hubble (34-cilium.sh),
and explicit OTel instrumentation in the apps plus platform/gems/platform_telemetry
and platform/pylib/platform_telemetry. Commit-to-deploy is one trace: the push
is the root span and the build's stages hang under it — docs/OBSERVABILITY.md.
I would be interested to see additional projects added, a note taking app, based on the outline project, but using cloudnativepg via our platform components. And some kind of chat app, I like zulip, but if that doesn't work another could be acceptable
partly done, and the Outline half was dropped. Zulip runs and is the whole
chatops story (five bots, one of them a conductor that scripts conversations so
the traces are never empty). Outline was declared under apps/outline/ and never
ran: its binding was hibernated and its outline-db / outline-redis /
outline-files Resources were never bound.
Removed 2026-09-04 rather than provisioned. It had been a documented-as-reachable
URL that could not start for a week, and README.md and docs/ACCESS.md both
listed wiki.jung.town — so the cost of keeping it was a lie in the two documents
people read first. The note-taking app the round asked for exists and works:
inkwell, on CloudNativePG through the platform's own postgres resource type,
which is nearer the point than a second vendored wiki.
Round 2 — more-work.md
Can we enforce projects being able to register under a project specific subdomain wildcard as default, preventing them from taking a top level subdomain without a certain level of permission setting it somewhere. We should make it the default even if we can't enforce it easily.
done. Default is <environment>-<project>.jung.town; the apex is refused by
a ValidatingAdmissionPolicy, platform/openchoreo-config/policies/restrict-zone-apex.yaml.
Also sign in is painful, if we integrate with oauth/oidc provider like GitHub (open to others) can we simplify account login for others to test. [...] if everyone with access can use their existing account to login to Zulip, test it and the chat bots, and then already be logged in for the portal and other observability services that would be great. Creds in infra folder .github-oauth
done. One GitHub sign-in at the gateway covers portal, Grafana, Gitea, Zulip
and Argo; access is a GitHub allowlist
(platform/bootstrap/thunder-scripts/66-allowlist.sh). docs/ACCESS.md has the
full model, including which three services are exempt and why.
Make sure the docs include walkthroughs for creating a new rails project through deploy, making a change including a rollback. Adding trait [...] that modifies the project to include a solid queue worker, that runs a job to store something in object storage, with the buckets being provisioned, wired up automatically and development environment resources are cleaned up. This should also get a subdomain declared and provisioned automatically as a step. This should also demonstrate adding auth that automatically generates a client to interact with our idp and configures it on the app side.
done. docs/WALKTHROUGH.md and docs/WORKING-ON-IT.md, and the
@walkthrough bot drives the whole thing end to end in chat.
Round 3 — grafana-fix.md
How do we improve visibility into deployments, can we add capacitor for flux or integrate more tightly into portal. I assume there are logs, metadata, if not more.
partly done. Flux has its own dashboard
(platform/grafana-dashboards/platform/infra-flux.json); Capacitor was not
added.
If it isn't a heavy lift to expose the other platform level projects/services in the portal, that would be good. For example gitea, grafana, etc. Anything that has a hostname should exist and have links to their hostname
done. platform/portal-catalog/platform-services.yaml.
(I still do not reliably see dashboard tab links or links on entities by the way)
done. platform/portal-image/entity-links.patch and grafana-plugin.patch.
Also the chess board images do not load/work correctly.
done. chess-render serves both board.png and board.svg; the traffic
generator exercises both every pass.
More importantly, I still can't verify any of the grafana information because dashboards fail to load with a lot of 403 on websockets.
done. Gateway/Thunder configuration; the HAR that documented it is deleted.
Round 4 — more-fixes2.md
Localblocks processor not found error accessing traces drilldown.
done. local-blocks is enabled in the metrics generator's
overrides.defaults.metrics_generator.processors, and it is in the RUNNING
config, not only in the values file — /conf/tempo.yaml:49 inside tempo-0
lists it. platform/bootstrap/values/lgtm-tempo.yaml carries a long note on why
it is required: it is the only processor that keeps raw spans queryable, so every
Traces Drilldown view fails without it, and span-metrics/service-graphs
remote-write aggregates and cannot answer {...} | rate().
Also I'm expecting to see more of the graph/timeline/gantt style view of spans when they are available instead of just lists of traces only.
open. The build waterfall exists
(platform/grafana-dashboards/ci/build-pipeline.json) but most trace surfaces
still render as lists.
Metrics drill down says I don't have permission.
open. Grafana role/permission for the Metrics Drilldown app.
On the build pipeline view, especially when I get there clicking from a specific build from a message or wherever, it would be good if the charts were scoped for the actual relevant time so they maximize the use of space.
done. links.build_pipeline() brackets the build's own window, and the
console's grafana() embed macro does the same — pinned by
test_an_embedded_dashboard_is_scoped_to_the_build_not_the_last_hour. The
now-1h still in the dashboard JSON is each dashboard's DEFAULT range, which is
correct for opening one cold.
I still want to see trace/span views integrated with logs and profiles everywhere possible.
partly done, and the two things that were silently blocking it are fixed:
demo-conductor and orchard were both hibernated, so the bot traces and the
CPU profiles they feed had no data at all — an empty panel is indistinguishable
from a broken one. Both are awake.
Example links from chess bot that redirected and didn't show what I expected: Trace / Profile / Logs [three Grafana explore URLs]
done. There is now one Grafana link builder,
platform/pylib/platform_telemetry/platform_telemetry/links.py, and every
function in it is window-aware: trace() uses a DAY (a trace id needs no window
but Grafana insists on one), and build_pipeline(), transaction() and
span_profiles() bracket the caller's own window and pad either side. Its header
records the bug by name. alert-relay/relay.py:106 and builds.py:174 carry the
same rule for their own links.
In portal UI when viewing builds, it usually says No logs available for this step and it isn't easy to find a link to the argo workflow page from there. Is there a fix?
done, and the cause was the opposite of the obvious one. OpenChoreo decides
whether to offer live logs by asking whether the Argo Workflow object still
exists. podGC deleted the pods at 2h while deliberately leaving that object,
so the portal answered "yes, live logs", asked for pod logs, found no pods, and
returned an empty array with HTTP 200 — rendered as No logs available for this
step for every step of every build older than two hours. The Loki fallback
works but is only consulted when the Workflow object is gone. ttlStrategy now
matches podGC's 2h, so pods and object go together and the portal falls
through to Loki. See platform/bootstrap/values/wp-argo.yaml.
In grafana the deployed at and other urls for example staing-rideshare.jung.town/rideshare-http which works, not all of them do, even for environments that have https serving the same endpoint, which is the endpoint I generally expect to see
open. Endpoint URLs should prefer the https external URL; some panels use
the http one and some components publish neither.
Generally evaluate if we are using dagger according to best practices. I expected the trace/span correlation to be easier since that is most of what dagger cloud appears to do. If we are being limited in our ability to report profiles etc, we do not have to use dagger for the entire pipeline, or can use a different path for rails apps. I hoped it would be possible to obtain that in a standardized way with dagger modules
answered, and acted on. Dagger was not the limit; two things were.
-
Correlation was never wired up.
dagger.Tracer()ships in the generated SDK and the module runtime already stands up an OTel SDK before dispatch — it was simply never called, so every span in a build came from the engine's own instrumentation and was named after API calls (Container.withExec) rather than after anything a person cares about.stage()inplatform/dagger-modules/platform/main.gofixes that, and force-flushes per stage because the tail of a pipeline was being lost at module exit. -
The pipeline was not shaped like a pipeline. Every stage was awaited before the next began, so lint, audit and the two RSpec suites ran end to end despite sharing nothing. They now run concurrently (
runStages), which is what finally makes the waterfall show overlap instead of a staircase: four stages that took 40.2s in sequence take 13.6s.Worse, one push ran the pipeline once per component — three times for a Rails app, five for the bots — publishing byte-identical images under different names. One build now serves all of them.
So: no, there is no need to leave Dagger or special-case Rails.
Is there a reason we stood up a new minio vs continuing to use garage by the way? It seems cleaner and has continued support and is already in cluster.
answered, and the honest answer was "we did not, but staging still was".
The objectstore resource type was switched from MinIO to Garage and
production was promoted onto the new release — but staging stayed pinned to
inkwell-files-5d96489b4f from 2026-08-29 and went on running
quay.io/minio/minio for five days. One resource type, two backends, and
nothing reported it: both bindings were Ready, because each was correctly
serving the release it was pinned to.
Fixed 2026-09-03 with occ resource promote inkwell-files --env staging
in the cluster only — production was promoted and the repository was not
updated, so staging stayed on MinIO. Corrected 2026-09-04; see below. inkwell-enrich needed no
change at all — it consumes the objectstore output contract as S3_ENDPOINT /
S3_BUCKET / S3_* and never knew which implementation was behind it, which
is exactly what that contract is for. Verified by writing an attachment
through it after the swap.
The dead resource-types/objectstore-minio.yaml.replaced has been deleted; the
live type is resource-types/objectstore-garage.yaml.
The general lesson is worth more than the fix: a resource type can be changed underneath everyone, and environments pinned to older releases keep serving the old one indefinitely without any binding going un-Ready. Promotion is per environment and nothing nags. Worth a drift check — see the open items below.
Tech docs are either missing for most projects or broken.
open. platform/techdocs/generate.py builds from live CRs but coverage is
thin.
I'm leaving another har file related to a 404 in tulip for the board, clicking on it loads it, but it spins in the chat view.
open. Zulip's own preview fetch for the board image.
Round 5 — the console walkthrough, 2026-09-04 (newest)
Seventeen items, given as one message. Grouped here by what they turned out to be, because three of them had the same cause and one of them was not a console bug at all.
Done
The deploy page shows all components individually, but I'm generally concerned first with seeing the projects [...] a dag style view showing each project broken down, their environments, the pipeline between each environment, and THEN inside of that broken down by component.
done. graph.pipeline_layout() plus app/pipeline.py. Environments are
columns in the order the DeploymentPipeline promotes through, the arrows run
between the bands rather than between cells (promotion is a property of the
environments, not of any one component), and each column holds that
environment's components and its resources — a backing service is part of
what an environment is, and leaving databases out is how chat came to look
empty. On the project page as a "Flow" section placed first, and fleet-wide at
/overview.
Rows are deliberately not aligned across columns: a component missing from one environment leaves no gap, because an empty slot in a grid reads as a fault rather than as an absence.
pipeline_layout is not layout(). A layered DAG assigns layers from the edge
graph, which would put the promotion edges between nodes in the same column
and have nowhere to draw them.
It should always be possible to drill down to the actual Kubernetes manifest and set of objects eventually.
done, three ways, because "always" means three different things here.
-
A live cell drills to
/deploys/<binding>/detail, which lists the objects the platform created and candescribeany one down to its YAML. -
Without a binding it falls back to
/deploys?component=&environment=, the component's deploy history, which is served from the delivery index and needs no token. The binding name comes from a token-scoped read, so anonymously every one of 41 cells had an empty binding and therefore no link at all — a picture of the whole estate that could not be clicked. The fallback is a worse answer than the manifest and much better than a dead cell.The binding name is not reconstructed as
<component>-<environment>. That convention holds for every binding in the cluster today and breaks on the onesdns_safe()truncates at 60 characters — silently, on the longest names, which are the least likely to be spot-checked. -
A construct is not deployed anywhere, so its drill-down is what it expands into:
spec.resources/spec.creates/spec.patches, rendered on/platform/{slug}/{name}as kind, API group, name pattern, target plane and the condition each object is included under.${metadata.name}is printed verbatim; substituting a plausible value would produce a name that looks real and is not. A trait that only patches renders nothing —patchesis a third verb, kept apart, because listing a patched Deployment under the same heading would claim the trait produces one.This is also the answer to "each of the platform docs pages would ideally include a graph showing what the object expands into", and the template graph's type nodes already linked to that page, so the chain from "what would this scaffold create" to "which Kubernetes objects" now closes.
Still not seeing a route to see the actual kube objects deployed in an environment. Environment possibly needs its own dag diagram or there should be an environment filter.
done. /environments and /environments/<env> transpose the flow view:
columns are projects, the environment is fixed. No edges — promotion runs between
environments and this view has fixed one, so an arrow between two project columns
would invent a relationship the pipeline does not describe.
The transpose exposed a latent bug: pipeline_layout stamped the column key onto
every cell as environment, harmless while columns are environments and wrong the
moment they are projects, where every tooltip would have read "web in inkwell".
The three views of the same estate (list, by project, by environment) share a sub-nav rather than taking a fourth rail entry; the rail is at ten and wraps.
I still get 404 for most links like
git.jung.town/apps/bakery/commit/4e24446...
done, and the URL was never wrong. All twelve apps/* repositories are
private: true; Gitea answers an unauthenticated request for a private repo with
404 rather than 403, so as not to confirm the repo exists; and git.jung.town
is genuinely public — confirmed by fetching it from outside this LAN, which
returns the Gitea home page. So the link was correct, the commit was there, and
the reader had no Gitea session. Three rounds of "the links are broken" were a
missing session reported as a bad URL.
Publishing the repositories would fix every link with no code, and publish twelve
applications' full source and history to the internet. Not a side effect worth
taking on to make a hyperlink work — so /commits/<org>~<repo>/<sha> and
/commits/<org>~<repo>/<base>...<head> render the commit and the range with the
console's own token, and source_ref points there, which is every commit
reference in the console at once. The Gitea link stays as a labelled secondary,
saying it answers 404 rather than 403, because for someone who has a session it
is still the fuller tool.
The range route must be declared before the single-commit route: {sha} is
[^/]+, which matches aaa...bbb too, so the other order renders every range as
a commit page for a sha that does not exist. Pinned by a test, not a comment.
we should display changes in-line on build page when reasonable
done in the previous round; the diff block is now a shared macro so the build page and the commit page cannot drift into rendering the same diff differently.
I think I need time stamps in general on the commit names to know what order they are in.
done. source_ref(..., at=) renders an age beside the sha with the absolute
time in the tooltip. Eight hex characters carry no order, so a column of them
cannot be read as a sequence — and "3h ago" answers the ordering question at a
glance where an ISO timestamp does not.
Open
Can we also do junit test output as a platform option/standard and incorporate it into the console with a richer UI.
done, both halves. TESTING.md is the contract: a JUnit XML file at
one path in every language module, plus one OTLP log record per test case carrying the
same fields. A new language joins by producing both and the console changes nothing.
/builds/{run}/tests is the report — totals, failures first with their type, message
and captured output, the ten slowest, every case filterable by outcome and suite, and
a per-suite breakdown. The build page previously showed four cases out of 220
inside a stage box.
The platform writes the XML rather than requiring a reporter package: pytest has
--junitxml built in, and rspec_junit_formatter would have to go into every
application's Gemfile — against this instrumentation's own rule that the application
changes nothing, which is why the plugin arrives by --require in the first place. So
the rspec plugin writes it, in about fifty lines, and the escaping is tested against
an independent parser rather than reasoned about: &<>"' escaped, and the control
characters XML 1.0 forbids stripped, because they cannot be escaped and Ruby test
output contains them. That input arrives on the day the suite is already broken.
The console reads the log records, not the file — the file never leaves the build
container, because build-artifacts is ClusterIP on the workflow plane and the console
runs on the data plane. Writing it anyway is what makes this a standard rather than a
private schema.
Three things found by verifying rather than by reasoning:
- Every build in retention predates
test.duration, so the report would have been entirely untimed until the module change had rolled through every repository. Test spans carry real durations, so the view joins them by span id with a name fallback: 209 cases, all timed, on a build that predates the field. The record wins where both exist — it is the suite's own measurement, where the span also contains the fixture teardown the tracer wrapped around it. classnamedisagreed with the XML from the same run: the record saidtests.test_console.pywhere pytest's--junitxmlwritestests.test_console. One standard reporting a test under two names depending on which artefact you read is the exact inconsistency this was meant to remove.- The JSON summary was returning the report.
slowestpassed through whole put ten complete case objects, captured output and all, into the endpoint whose purpose is to be the cheap answer to "did the tests pass".
A missing duration is left missing throughout. Zero would make an untimed test look instant, sort it below every real one, and report a 220-case suite as 0.00s — a fast suite rather than absent timings — so the page reports how many cases were timed whenever that is not all of them.
For the git stuff I was kind of hoping to see something more like
git-bahn/git-graph, with the git commits visualized as annotated nodes that you can move between, show more information progressively, see when you are on a PR environment that you are branched off a commit.
done. /components/{component}/graph. Gitea's commit payload carries parents, so
this is a real DAG: branches are lanes that persist down the page and curve back into
the trunk at their merge base. Checked against chess, whose demo branch holds lane 1
for three rows and rejoins lane 0 at the shared ancestor.
Each row draws its own rail. One tall SVG beside the rows breaks the instant a row
expands — the rows grow, the SVG does not, and every dot below the open one points at
the wrong commit, worst exactly when someone is reading a commit closely. A
fixed-height SVG per row makes the alignment structural rather than something to keep in
sync; the rail continues behind an expanded row via preserveAspectRatio="none", which
stretches the lanes vertically and leaves their x alone.
The annotations are the reason not to use a general git viewer: anyone can draw a commit
graph, but "live in production, built twice, and this is what PR 7's review environment
forked from" is knowledge only the platform has. The base commit of a pull request is
marked forks here and names the review-pr-<n> environment and the -pr-<n> component
suffix — which is what the request was actually about.
Progressive disclosure is a <details> per row, no JavaScript: sha, subject, chips and
author collapsed; message body, builds (each linking to its test report), deploys, pull
requests and three range links expanded.
Two lane-algorithm details, both tested rather than commented:
- Every lane waiting for a commit is freed when it arrives, not just the leftmost. Two converging branches have both been waiting for it, and releasing one leaves the other holding a rail for a sha that never comes again — lanes then grow monotonically and a repository with a few merged branches sprouts dead rails down the page.
- A parent outside the window ends its lane, and the page says the graph stops. A graph that quietly guesses its own ancestry is worse than one that admits where it ran out.
Found while wiring it: deployed_where returns one entry per deploy event, so a commit
deployed to production twice rendered "production, staging, production, staging" —
a rendering fault to read, a redeploy in fact. Grouped per environment now, with a count
and whether that environment is serving it.
Zulip project still does not display expected information.
part of it found and fixed; the rest is open. chat rendered empty across every
telemetry panel because its spans report service_namespace as the Kubernetes
namespace — dp-default-chat-development-03220a9b — with no project and no
deployment_environment label at all. Every query filtered on
service_namespace="chat" therefore matched nothing, in the console and in Grafana
alike. A second scope matches the namespace pattern and finds 182 operations
there, and the page says how many rows were found only that way, because everything
else keyed on those attributes is still empty for them.
The pattern is assembled from names the platform already knows and never parsed back
out of a namespace: dp-default-my-app-staging-abc12345 is my-app/staging or
my/app-staging with equal justification, and only the platform knows which. The
environment filter is deliberately not applied to that scope — the environment is
already fixed by the namespace, and those series carry no environment label, so
filtering on one makes them vanish again.
Still open: the real fix is upstream, getting Zulip's instrumentation to send
service.namespace as the project and to send an environment at all. Until then
every other project-keyed panel is empty for it.
Build log still empty, still haven't seen profiles anywhere on console.
Build log: already fixed, and checked across every build type rather than one. Every component's most recent build has a populated log — 288 to 892 lines, with real content in each stage panel: console 892/6 tabs, brochure 288/2, rideshare 389/5, openchoreo-bot 590/6, storefront 304/2, inkwell-enrich 505/6, ledger 473/6, buildviewer 504/6.
Asking why the two static builds have 2 tabs where everyone else has 5 or 6 turned up a
real gap: StaticCi never audited, and its own doc comment said it did. It has
claimed "lints, audits and builds" since it was written and only ever linted, so
storefront and brochure are the only components on this platform that have never had
a dependency audit. Frontend.Audit already existed, with its own reasoned
--audit-level=high threshold, and nothing called it. Now wired through runStages in
parallel with lint, matching RailsCi and FastapiCi.
Still open for static components: they run no tests at all. The frontend module has
no Test function, so run-tests is accepted by the workflow and has nothing to call.
That is the remaining reason a static build's pipeline is shorter than everyone else's,
and it is where the JUnit standard should reach next.
Profiles: done, and the reason they were nowhere was concrete.
pyro.service_profile() had zero callers. CI profiles were wired to the build page;
the profiles of the applications actually serving traffic were fetched by nobody — while
eight services have one, inkwell-web 283 stack levels deep across 1,099 frames and
chess-render burning 172 seconds of CPU an hour.
/components/{component}/profile, filterable by operation and window, linked from
Project 360's per-component table (beside the CPU column — that column states the
quantity, this answers where it went), the commit graph, the timeline and the build page.
A page nothing links to is the state this started in.
The numbers come before the picture, which is the answer to "I'm not sure the flame graph is rendered correctly". A flame graph cannot settle that question: a broken renderer and a genuinely flat profile look identical. Hot frames are computed server-side from the same flamebearer and shown as percentages, so a bar that disagrees with a number is checkable — and a failed canvas costs the picture, not the profile.
Three bugs found against live data:
totalsummed across occurrences double-counts recursion, and chess-render'sSurface.drawcame out at 190.3%. A percentage over 100 makes a whole table look broken whatever else is right. It is now the largest single occurrence.- The single-stack test was wrong. Requiring "one frame per level and no self time
anywhere" missed the case, because a single sample does have self time — at its leaf.
inkwell-webfiltered to one operation is exactly that: 283 levels, one frame each, 10ms. That is the shape reported as flat even bars, so the page now says so and suggests widening the window instead of drawing it. - The operation filter offered nothing.
prom.transactions("")buildsservice_namespace="", which matches only series with no namespace, so the dropdown was empty for every component. The page rendered perfectly well with zero options, which is why it needed counting rather than looking at: 32 operations for inkwell-web once the project is resolved from the delivery index.
That makes three functions this session that were written, documented and uncalled:
loki.build_all (fixed earlier), pyro.service_profile, and Frontend.Audit. Worth a
grep for the pattern rather than waiting to trip over the fourth.
The comparison feature is not exactly what I expected [...] I want the field level diff but again I want to see how that actually renders differently between environments with a rich diff.
done. The compare page diffed release pins; it now also diffs the rendered Kubernetes objects — paired by kind, field by field, with the YAML in context.
A raw diff is the wrong answer, and that was measured before deciding. Diffing
inkwell-web's staging and production Deployments as text gives 40 changed lines.
Substituting the environment name and release hash brings it to 8, and all 8 are
environment-uid and dp-resource-hash — identifiers that differ by construction. The
real answer is: same image, same replicas, same limits, same env keys. A page showing 40
red and green lines to say "these are the same" buries the case that matters.
So every difference is classified substantive / environment / identity, and the page
leads with the substantive count. inkwell-web reports 0 substantive, 36
environment-scoped, 11 identifiers, and says "these two environments render the same".
The classifier took three passes, all against live cluster data:
- Exact substitution only. A generated name differs by the environment and the content
hash at once, so
inkwell-web-staging-379f55e8vsinkwell-web-production-da55eda4fell through — 22 substantive differences for two identical renderings, which is the raw-diff problem with new labels. - Tolerate the hash, but only on name-like fields. Safe and too narrow:
DB_HOSTandDATABASE_URLpoint at the environment's own database and still read as substantive. Down to 5. - An exclusion list instead — version-like leaves (
image,tag,digest,sha,revision) never get hash tolerance. That is the guard everything rests on: an image tag isv1-45db1889againstv1-e8881d0b, exactly the shape of a generated name suffix, and normalising it would classify a genuinely different build as an environment difference and hide the most important thing the page can show.
Verified in both directions: inkwell-web 0 substantive; inkwell-enrich — which
really is on different images in the two environments — reports the image and nothing
else. (ledger, rideshare and storefront are also on different images across
environments, which this page now makes visible.)
identical means identical after classification, not byte-identical: two renders
differing only in a UID are the same rendering, and calling that "changed" is what made
the old comparison useless. A component whose objects cannot be read is reported as
unreadable and left out of the counts — it is not a component whose environments agree.
Verifying it live found two false claims on the same page, both from token-scoped reads falling through to "equal", and both in the one place someone comes to ask whether production matches staging:
- "These two environments agree about everything."
releaseis token-scoped, so signed out it is empty on both sides and the comparison fell through tokind = "same"— two empty strings being equal. Six of inkwell's components were reported identical without their releases ever having been read. There is now anunknownkind,identicalrequires every pair to have actually been compared, and the page says "No release could be read for either environment. That is not the same as them agreeing." - "No component is deployed in both staging and production" — printed directly below the page's own list of six components identical in both. Binding names are token-scoped too, and an empty binding was read as an absent deployment.
That is the fourth time this session the signed-out rendering differed from the authenticated one in a way that mattered, and the second where the wrong answer was a confident sentence rather than a blank panel. The rule in NEXT-SESSION.md is not optional advice.
The project 360 include from grafana is working, but I would like to remove it and natively expose all of the graphs there. I do want a full featured transactions table, filterable by component/env.
done, and rebuilding it found four wrong numbers in the dashboard. The embed is
gone; the project page computes the six headline stats, three server-drawn charts
(requests/s, 5xx share, p95, each by component) and the per-component table itself,
filterable by environment and window from the page, with the filter in the URL.
/projects/{name}/transactions is the full table, filterable by environment,
component, producer and window.
The four numbers, because each is a different way to be confidently wrong and none of them is visible from inside an iframe:
- Apdex read 0.00 for a project answering in 26 ms. The buckets were spelled
le="0.25"andle="1.0"; Prometheus labels that second boundary"1". A selector matching no series is an empty result, not an error, so tolerating was zero and every project scored the worst Apdex there is. Boundaries are now read off the histogram, andapdex_treports which was used — an Apdex quoted against an unstated threshold compares to nothing. float("+Inf")succeeds in Python, so+Infbecame the "nearest boundary at or above T" for any T, and a histogram without sub-second buckets would have scored a flawless 1.00 forever. Found by writing the test for (1).- p95 for one operation read 16384 ms against a true 1.9 ms.
traces_spanmetrics_*is written by two independent systems — Tempo's metrics generator and Beyla's eBPF instrumentation — with different bucket boundaries, powers of two against round numbers. Summing those bylemakes the histogram non-monotonic andhistogram_quantilereturns the top bucket. Wrong by a factor of eight thousand. - The two producers were being added. They disagree legitimately and by a lot:
110 calls against 1147 for the same operation, because Beyla sees every call at
the network and Tempo only sees spans that were sampled and exported.
sourceis a column now, nothing merges across it, and the page says why one operation has two rows. The dashboard grouped on a label Beyla does not set, which avoided mixing the buckets by accident and collapsed every Beyla row into one blank-named row.
The other integrated dashboards are also too awkward on build page and we should bring the information that isn't already there in-line, and if we can only view something in grafana, create a more targeted dashboard to integrate.
done. No page embeds a Grafana dashboard any more. Comparing the two embeds panel by panel gave a sharper answer than expected.
The build pipeline dashboard was almost entirely duplicative. Build picker, result,
duration, commit, pusher, ordered stages, waterfall, critical path, slowest tests, flame
graph, per-stage output, full log — all already inline, the waterfall included, and the
console's is filterable by any attribute in the trace where the embedded one is not. One
panel was missing: Argo's step durations. prom.build_steps(run) was already fetched,
already passed to the template, and dropped — the fifth written-and-uncalled thing this
session. It matters because it covers the phases around the container: a build spending
four minutes in checkout-source looks instant in the stage view, since none of that time
is a Dagger stage.
The Component 360 embed showed three things with no inline equivalent, two of them from
recording rules the console referenced nowhere — component:network_bytes_in:rate5m and
component:container_memory_rss:sum are computed on every scrape and existed only inside
that iframe. Memory as a number was already on the project page; only a series answers
"is it climbing since I deployed". Three server-rendered charts now.
The third was traces, and tempo.search() had zero callers — TraceQL across the estate,
implemented and documented, while the only trace view in the console was one build's
waterfall. There is a recent-traces table now. Tempo returns the literal
<root span not yet received> for a trace still being written, which becomes an explicit
partial flag rather than printing Tempo's internal apology in a service-name column.
The environment page's embed was the worst of the three: Component 360 for
e.components[0] — one component — with a note telling the reader to open Grafana to
switch. Every component in the environment is a row now, each linking to its own profile.
What still points at Grafana is one link per trace id, because this page renders a waterfall for the build's trace, not an arbitrary application trace. That is the "can only view it in Grafana" case, and it is a link to one trace rather than an iframe.
A test asserts no template embeds a dashboard at all: that regression is silent, because an embed looks fine until you notice it needs a Grafana session the console's readers lack.
On the build page clicking a test span works, but I'm not sure the flame graph is rendered correctly, it's totally even and flat stacked bars. I don't see the test results logs in that box correctly either.
both addressed. The logs: the span-id decode was returning base64 where Loki wants hex, so the query matched every time and found nothing.
The flame graph was not rendering wrongly. A millisecond-long test span is one
sample: every level holds exactly one frame at full width with self=0, so the
drawing is N identical bars stacked on each other — which is precisely "totally
even and flat stacked bars". spans.js now detects that shape (no branching
anywhere and no self time anywhere) and says so instead of drawing it: "one stack,
N frames deep, and no self time anywhere — too short to sample more than once, so
there is no distribution to draw. Profile the whole stage instead."
Not yet verified against a real build, because it needs a signed-in session on a build that has profiles. If a span with some branching still draws flat, the detection is too narrow and the threshold is the thing to revisit.
I also don't see any better docs with the new markdoc features, I would like to see some interactive stripe style examples demonstrating platform features (how to setup a new project via cli or otherwise for example).
done. /guides — six documents, one per question someone arrives with: setting up a
new project (the parenthetical above, and the first in the sequence), finding your way,
down to the manifest, what changed, is it healthy, every page has a JSON twin.
Tags, not HTML, and that constraint shaped the design. docs.py renders untrusted
prose from application repositories with html=False, and that flag is the only thing
stopping a <script> in someone's README executing on a page carrying a session cookie.
So interactive examples cannot be built by letting authors write HTML. Markdoc's model is
the right one: `
{% tag attr="value" %}
annotations a platform-controlled renderer turns into markup. Five tags —tabs, steps, callout, try, api` — because each is a
promise about how it renders.
Attribute values cannot break out of their quotes at all: the pattern is "[^"]*", so an
injection attempt truncates into a harmless label rather than closing the attribute. An
unknown tag renders visibly rather than vanishing, because a typo an author cannot see
is worse than one they can.
try and api are what make these interactive rather than illustrated — every example
points at a route on this instance. A test resolves all 25 targets against the app's own
router, so it asks "does this route exist in this build" rather than "is upstream
reachable". Documentation linking to a route someone renamed is worse than none, and that
test is the difference between saying so and meaning it.
Tabs are a radio group plus :checked ~, so they work with JavaScript off — the same
reason the DAG and the charts are server-rendered SVG. The guides ship with the console
rather than being read from a repository: they describe this build's own routes, version
with the code providing them, and render with no upstream call and no token — which
matters most for the one document whose job is explaining why other pages look empty.
The omission worth recording: the first five guides covered reading the platform and not creating on it. The parenthetical in the request named exactly that, and it took writing this ledger entry to notice.
Found while acting on the above
-
/platform/{slug}/{name}reported a construct that exists as absent. "postgres not found — no resource type by that name is registered on this platform." It is registered. The catalogue is a token-scoped platform API call, so anonymously the read fails,soft(..., default=[])turned the failure into an empty list, and the empty list turned into a confident false statement about the platform. Exactly the trap_http's own docstring names, made by thedefault=argument that exists to avoid 500s. Fixed: the failure renders as unavailable with its reason, the genuinely-absent case admits both readings, and the template guardscrather than rendering the page shell around an empty construct — that shell claimed the construct has no parameters, refuses nothing, renders nothing and is used by nobody, four invented claims. -
Pyroscope served 503 for 38 hours: its Raft metastore had no leader. Reported as "cannot read pyroscope" on
/components/console/profile, and the console was reporting it correctly —Could not read pyroscope. HTTPStatusError: 503, with the URL. The fault was upstream.pyroscope-0was0/1 Runningwith zero restarts for 38 hours, using 24m of CPU and 94Mi of a 1Gi limit — so not starved, not crashing, simply never Ready. The logs say why, once a second:rpc error: code = Unavailable desc = node is not the leader, andfailed to prepare compaction plan. A single-node Raft that loses leadership does not re-elect on its own, and/readywaits on the metastore, so the readiness probe timed out forever. Deleting the pod bootstrapped a fresh election: Ready in about 90 seconds, and all three profile pages checked afterwards return real data.Worth contrasting with the two resource findings above: zero restarts was the tell. A throttled component restarts; a wedged consensus group sits there.
kubectl get podshows the same0/1either way. -
prune.sh --applyreclaimed essentially nothing, and the report said so first. Disk went 789G → 789G, 89% → 89%. The dry run had already shown the ceiling: ~866MB of dangling images against a 938G filesystem, with the Dagger cache at 0B and 81GB of local volumes all in use. The script is honest and the disk pressure is not reclaimable waste — it is data that something owns. Worth knowing before reaching for it as a remedy again. -
Resources can be hibernated now.
ResourceReleaseBinding.spec.statewas added —platform/openchoreo-patches/resource-hibernation.patch, a fork of the vendored OpenChoreo with the build and load steps documented, becausevendor/is gitignored. Verified end to end onorchard-db-stagingwith the data loss agreed first: undeploy removed the RenderedRelease, the CNPG Cluster and the pod, the condition readResourcesUndeployed, and waking recreated all three and reachedReady=True.Two things this taught that were not obvious:
My first version told the operator that
retainPolicydecided whether storage survived, and gatedhibernate.sh sleep-resourceon it. Both wrong. Undeploy deletes the RenderedRelease, whosedataplane-cleanupfinalizer removes the data-plane objects unconditionally —renderedrelease.finalizeDataPlanenever reads retainPolicy, which governs binding deletion. So the guard would have waved through exactly the case it was written to catch, and the condition would have said something reassuring and false in the one place someone is deciding whether to destroy a database. Found by readingfinalizeDataPlanerather than assuming, and confirmed on the way out: undeploying left zero PVCs behind. The guard now refuses every resource without--force.Waking needs the same nudge as everything else: CNPG reported "Cluster in healthy state" with the pod 1/1 while the binding still said
readyWhen returned false. Onekubectl annotate renderedreleaseand it went Ready. That is the third time this session a healthy dependency did not requeue a terminal condition. -
A resource could not be hibernated (fixed above).
hibernate.sh sleep-project orchard stagingscaled the three component pods to zero (~440Mi) and left the Postgres running, becauseResourceReleaseBindinghas nostatefield in its CRD — checked against the schema, not inferred from the script. That is whyhibernate.shonly ever patchesreleasebinding. The gap is real: the only way to reclaim a hibernated project's database is to delete the binding, which withretainPolicy: Deletedestroys the data. Astate: UndeployonResourceReleaseBindingwould make "hibernate this project indefinitely" mean what it sounds like. -
Prometheus was restarting itself in a loop, 31 times over 38 hours. Found because a probe against it failed while building the dashboard replacements. It sat at 871m against a 1000m CPU limit — pinned at its ceiling — so the liveness probe timed out (
Get "/-/healthy": context deadline exceeded), kubelet killed it, and startup replayed the WAL, which costs more CPU than steady state. Each restart therefore made the next probe more likely to fail: a loop that sustains itself once entered, and a loaded host is enough to enter it.It exits 0, so nothing looks like a crash.
kubectl get podshows1/2Ready with a climbing restart count and no reason, and metric-backed console pages go intermittently blank — which reads as broken instrumentation rather than as a throttled collector. That confusion is already recorded once for Tempo/Prometheus under build load; this is the mechanism behind it.Limit raised to 2000m on a 12-core host; memory untouched at 1500Mi because memory was never the problem (784Mi throughout). Now 2/2 and healthy. Third resource-limit finding this session after Beyla and CoreDNS, and the pattern is the same each time: the limit that bites is not the one the comment beside it reasons about.
-
What that Beyla runaway cost downstream, and how to unpick it. The gateway timeout was the visible symptom; the cascade underneath took an hour to unwind and every step of it will recur, so the path is worth writing down.
Load average 100 → CNPG's health probes time out → ~27 failovers on inkwell's production Postgres (
Latest checkpoint's TimeLineID: 28) → each failover leaves the demoted primary needingpg_rewind→ one of them lands on a branch with no shared history:pg_rewind: error: could not find common ancestor of the source and target cluster's timelines. That instance can never rejoin. It crashlooped 20 times.One unready instance holds the whole cluster in phase
Failing over, even thoughcurrentPrimary == targetPrimaryand the switchover itself finished. That keepsreadyWhenfalse → theinkwell-db-productionbinding goes not-Ready → consumers cannot resolve their dependency → new pods get noDATABASE_URL→db-preparefalls back to a local Unix socket and exits 1 → the rollout stalls.PG::ConnectionBad ... socket "/var/run/postgresql/.s.PGSQL.5432"means the env var was never injected, not that the database is down. That is already recorded for theretainPolicy: Retaincase and it is worth repeating: the message names Postgres and the fault is in dependency resolution. Check the ResourceReleaseBinding first.Fixed at the source 2026-09-08. The reason an unready provider reached the pods at all was that the ReleaseBinding reconcile rendered first and checked the dependency afterwards, so a provider that was not Ready produced a manifest with its env simply missing.
platform/openchoreo-patches/resource-dependency-gate.patchmoves that check before the render: the last good rendering now stays applied instead of being replaced by one with noDATABASE_URL. So this failure mode no longer needs the binding nudge to recover from -- though the nudge is still the fix for a condition that has gone terminal.Production was never down. It kept serving from the previous pods the whole time — a stalled rollout, not an outage, and the two look identical in a pod listing until you notice there are two ReplicaSets.
The fix, in order: cap the thing eating the box; delete the diverged replica's PVC and pod so CNPG re-bootstraps it with
pg_basebackup(the data lives on the primary and the other replica — 5,816 rows, verified before deleting anything); then nudge the binding, because a dependency going healthy does not requeue a terminal condition —kubectl annotate renderedrelease ... openchoreo.dev/nudge=$(date +%s) --overwrite. That last step took it from Ready=False to Ready=True in about a minute, having sat False for an hour with a healthy cluster underneath.A load average is not a CPU measurement. Mid-incident it read 119 while every container together used 2.75 of 12 cores;
vmstatshowed 53–61% iowait from the basebackup on a 90%-full disk. Reading the number as saturation pointed at the wrong culprit untilvmstatcorrected it. -
Beyla took 4.1 of 12 cores and the console returned gateway timeouts. Reported while asking for the guides. No builds were running; the data plane was at 528% CPU with load average 100, and beyla accounted for nearly all of it while every other pod there was under 56m. It had a memory limit and no CPU limit, only a 50m request — and the careful reasoning beside that block is entirely about memory ("Beyla's memory scales with the number of processes it instruments, not with traffic"). The same shape as the CoreDNS finding above: not memory-bound, no CPU limit, and the real constraint was scheduler share on a saturated node.
Capped at 1500m. Load average fell 100 → 8 and the console went from timing out to answering in about two seconds. NOT fixed by dropping the
networkfeature, where most of the cost goes:beyla_network_flow_bytes_totalhas 790 series andrecording-rules.yaml:162consumes them — removing a feature something depends on to fix a resource problem is how a dashboard goes quietly blank.The caveat, stated rather than implied: after the cap Beyla settles at 20–46m, nowhere near the ceiling, so 4130m was a pathological state rather than the cost of the workload — and the restart the patch caused cleared it as much as the limit did. The limit's job is bounding the next occurrence, not curing this one. Also worth knowing: Beyla is the only source of
http_server_request_durationhere, so the restart blanked every RED tile for about two minutes; verified it came back rather than assuming. -
A fix that only works signed in looked identical to the bug it fixed. The Kubernetes-namespace scope that makes
chatnon-empty is built from the project's environment list, which comes from a token-scoped platform read — so signed out the list was empty, the scope silently switched itself off, and/projects/chat/transactionsrendered 0 rows where the same call in a shell returned 182. Fixed by taking the environment names from Prometheus, which needs no token. This is the third time in one session that the anonymous rendering differed from the authenticated one in a way that mattered; the rule in NEXT-SESSION.md — verifying withcurlverifies the signed-out page — is worth applying to any new panel before it ships, not after. -
.ruff_cache/and.pytest_cache/land in the app directory, and one left root-owned by any container predatinguv.sh's--userflag makes ruff fail with "Failed to create temporary file … Permission denied" — which fails the documented pre-push gate and reads as a broken lint on a tree where nothing is wrong. Fixed: both caches now live in the mounted cache.
Round 6 — the console, again, 2026-09-04 (newest)
Nine requests as one message. They were four complaints with nine symptoms: nothing had an identity, nothing was reachable, nothing was refused politely, and nothing was predicted.
Verifying the plan before building it deleted two of its pieces. Four facts in the first draft were wrong, all four checked against the live cluster:
- The quota's live
hardandusedare already on the control plane, in the project RenderedRelease's status, andocapi.rendered_releases()already fetches it. The planned kube-state-metrics change — a lockstep Helm edit tocollectorsandmetricAllowliston two clusters, both restated in full because Helm replaces lists — was unnecessary.kube_resourcequotais also namespace-scoped and could not have given the per-component breakdown anyway. - The image digest is already end to end. All 41
openchoreo_deployment_infoseries carry…@sha256:…, and 69 of 78 WorkflowRuns carry it in theiropenchoreo.dev/workloadannotation. No Dagger change, no Argo change: it was being parsed off and thrown away. - The quota arithmetic in the first draft was wrong twice. Kubernetes charges
max(sum(containers), max(initContainers)), not the sum — so countingdb-prepareadditively overstates every Rails pod by 768Mi, 12% of a 6Gi quota, and the check would refuse promotions that fit. And SolidQueue workers are Deployments, already counted; adding them again double-counts. Verified exactly:chess/stagingcomputes 2304Mi and the cluster reports 2304Mi. - Prometheus retention is 7 days, not 48 hours (
/api/v1/status/runtimeinfo), andtimeline.deployed_where()has two callers, not zero. NEXT-SESSION.md is stale on the latter.
Done
-
Anonymous access removed. One rendering instead of two, which deletes the bug class that produced four confidently-wrong panels in one session. The six "not signed in" caveats became scope caveats — still true for a signed-in viewer whose token sees less.
-
A role model.
User.groupshad been populated since OIDC landed and read by nothing.permissions.Capabilitiesmemoisesocapi.mayper request, which is what makes render-time checks affordable at all — one HTTP round trip per question was why nothing was ever pre-disabled. A control the viewer may not use renders disabled with the reason beside it, never hidden. -
One spelling of each authorization question.
permissions.GATESis shared by the render-time check andactions._guard, so the button and the POST cannot drift into asking different things. -
The rail went from ten entries to six, with sub-tabs over a structure in
app/nav.pythat a test resolves against the real router. A project now has Builds, Deploys, Logs and Resources tabs; a component has five. -
Rails transactions. Slowest
ActiveRecord.transactionspans with their statements, slowest statements grouped by the obfuscateddb.statement, a by-model breakdown, and suspected N+1 — all from Tempo, becausetraces_spanmetrics_*has nodb.*label and every query in a Rails app shares one span name (EXECUTE inkwell). -
A dedicated transaction search at
/transactions, fleet-wide: 293 operations across 7 projects, and every operation row now links into it carrying its own window rather thannow-1h. -
The log viewer that did not exist.
loki.component()has takensearchandminutessince it was written and the one page rendering it passed none of them. -
prom.build()is a label match instead of a scan over the estate, with an explicit outside retention state — the build page used to render its headings around a valid trace. -
A durable deploy history.
previous-releaseheld one value and a second promotion overwrote it; there is now a bounded ring beside it, and both are written becauserollback()reads the old one. -
A capacity check before the promotion, blocking in project-flow with an explicit
force, and shown beside the button before it is pressed. -
The bots can attach pictures, verified end to end in a real Zulip DM: the reply carries
message_inline_imageand the uploaded file is byte-identical to what the console served.framework.upload()formalises what one bot did privately, and the console serves the rendering over an HMAC-signed/internal/render/*— signed over path AND query, so a signature for one project cannot be replayed onto another.Testing it found three faults and only the first was expected. Worth keeping, because each produced a success that was indistinguishable from working:
- An uploaded SVG does not inline in Zulip. Measured both ways against
this realm rather than reasoned about: the same picture as
.svgrenders as a plain anchor, and as.pngit inlines with a thumbnail. So the first version attached a file nobody would ever open.chess-renderhad already concluded this for the board — its header says so — which is exactly the kind of note that only helps if you read it before shipping. cairosvg rasterises now, withlibcairo2in both image stages. - The render was a valid picture of nothing. 369 bytes, HTTP 200,
image/svg+xml, and empty — because it calledpipeline.for_project(project, "")and bindings, the promotion path and the environment order are all token-scoped, while the console has no machine identity. It is built from project-flow's delivery index now, which needs no token; 6.5KB and twelve components for inkwell. The honest cost is no promotion arrows, so the columns fall back to the conventional order. - The bot could not reach the console at all.
CONSOLE_URLwas unset, so it fell back tohttp://console:8080and died withName or service not known— silently, becausepicture()fails soft by design and the reply was therefore correct and pictureless. Measured from inside the bot's pod: the direct service address is refused (the console's endpoint isvisibility: [external], and chatops and devtools are different cells),https://console.jung.townanswers 200. The public name is what works and it hairpins through Cloudflare; the better fix isnamespaceon the console's own visibility, which is a change there plus a rebuild.
- An uploaded SVG does not inline in Zulip. Measured both ways against
this realm rather than reasoned about: the same picture as
Found while building, each caught by a test refusing to pass for the right reason
[r.path for r in app.routes]silently omits every included router. FastAPI wraps them in_IncludedRouter, which has nopathand keeps its children behindoriginal_router. The auth-inventory test was checking 19 of 37 paths and skipping all of/builds,/cacheand every write action. It now asserts its own coverage first.- Short-circuiting
may()onAUTH_ENABLEDwould have madeCONSOLE_AUTH=0mean no authorization at all. Caught by an existing test whose docstring says it exists because every other write test fakes a yes. - A test that passed whether or not its monkeypatch did anything.
ocapi.mayfails closed and there is no platform under test, so every write control is withheld by default — the greyed-out assertion held for the wrong reason. It now asserts both renderings of one fixture. tempo.trace()takes a build RUN NAME and derives the id. Passing a real trace id hashes it into a nonexistent one, Tempo answers 404, and the N+1 panel reported a confident, vacuous zero.trace_by_idis the id-taking half._rendered_for({project, environment})matched a resource's RenderedRelease. A project's cell, its components and its resources all carry those two labels, and forshowcase/stagingthe first match was a Postgres cluster with no ResourceQuota in it — socell_quotareturned{}and the capacity check would have waved every promotion through.HISTORY = POLICY + "history"was a NameError at import, ~700 lines above wherePOLICYis defined. project-flow would not have started. Found by importing the module rather than by reading it.
Open
- The exec console is a command, not a terminal. The data-plane cluster
agent already carries
pods/execin its ClusterRole, so the permission exists; what does not exist is an endpoint on the OpenChoreo API that uses it — itsk8sresourcesservice proxies tree, events and logs and stops there. project-flow cannot stand in: it talks only to the control plane and its RBAC is entirelyopenchoreo.devCRDs, with no corepodsat all. Adding exec is a patch to the vendored API, the wayresource-hibernation.patchaddedstate. Who may use it is already decided and enforced. - Gitea per-user repository permissions are blocked on per-user Gitea users.
git.jung.townis behind the gateway OIDC filter and then one sharedplatformaccount, so "write on apps, read elsewhere" has nobody to grant to. It needs Gitea's own OIDC first, the way Grafana and Zulip have theirs. - Tempo restarted 21 times in 41 hours while this was being built, exit 137
with
NodeNotReady— at 516Mi of a 3500Mi limit, so not its own limit. The host was at 27 of 31GB with 2GB of swap consumed and a load average of 101 at 12% iowait. Nothing was a runaway: four k3s nodes at ~1.5GB each plus a 2GB Dagger engine is the documented steady state. The console reports it correctly as "could not reach tempo" rather than as an empty panel, which is the design working. - Promotion gates were never missing.
project_policy()has parsedpromotion,soak,promotion-checkandproduction-windowoff the Project all along, andauto_promotions()enforces all four. The console still renders none of it beyond awaitingchip with no reason, nonot_beforeand no deadline — all three of which/statereturns. That is the next thing to render, not to build.
Round 7 — Zulip's telemetry, and Resources as a kind of thing
I thought we were also wrapping zulip spans. [...] when I click on a component in an environment, not a drill down
Two questions, one cause. The platform has two kinds of inhabitant and both the telemetry pipeline and the console modelled one of them.
Zulip's attribution: the fix was pod labels, and it was not Zulip-specific
Zulip reported service_namespace="dp-default-chat-development-03220a9b" with
no deployment_environment at all, so every panel keyed on project or
environment was empty for it.
Beyla reads POD LABELS, not the process environment.
lgtm-beyla.yaml:63-66 maps openchoreo.dev/{component,project,environment}
onto service.{name,namespace} and deployment.environment, and the file
already names the exact symptom of a pod without them. The first hypothesis
here — inject OTEL_RESOURCE_ATTRIBUTES — was wrong; Beyla never reads it.
And Zulip was not special. Checked across the estate: Component pods carry
the labels, garage's pods carry {project, environment, resource}, and every
CNPG database pod carried none at all — same as Zulip's chart pods. A class,
not an incident.
So the fix is at the source, where four consumers read the same labels — Beyla,
Alloy's k8sattributes, openchoreo:pod_identity and Loki's stream labels. A
transform further down would have fixed one of them.
podLabelson Zulip's chart and on each of its four subcharts, validated withhelm templateagainst the real schema before it went near the cluster — that file records an unknown key once making everyhelm upgradefail while the release stayed pinned at the last good install, reportingUpgradeFailedwhere nobody was looking.inheritedMetadata.labelson the CNPG Cluster, because CNPG does not propagate the Cluster's own labels to its pods.openchoreo.dev/resourceas a fallback forservice.name, so a Resource reportsinkwell-filesand notr-inkwell-files-staging-acc0f746.openchoreo:pod_identityselected onlabel_openchoreo_dev_component != ""— a label only a Component has — so every Resource pod was absent from every recording rule beneath it. Selecting onprojectand falling back to the resource name brought inchat(5 pods) and also inkwell 16 → 22, chatops 19 → 24, chess 6 → 8: databases and object stores whose CPU and memory were scraped, attributed to nothing, and shown on no project's page.
Verified end to end rather than inspected: zero Beyla series remain under a
dp-* namespace, and chat now reports 98 series with
deployment_environment="development".
Wrapping Zulip as a Component: evaluated and rejected
It would have made the telemetry worse. The otel-instrumentation trait
stamps otel-sdk: "true", which is how Alloy decides to drop Beyla's duplicate
spans — so the eBPF signal would be filtered and nothing would replace it, since
Zulip carries no SDK. Beyond that ComponentType has no readyWhen, so a
HelmRelease reports Healthy the instant it is created — which is why
helmchart is a ResourceType, argued in its own header — and vendored-service
renders one single-container Deployment with fixed probes against Zulip's
StatefulSet, three PVCs and a 300s boot.
Django SDK instrumentation: declined, not forgotten. No OTel Operator is
installed, so injection needs one added or a forked zulip-server image; Zulip
runs supervisord with uwsgi and ~20 queue workers, the hard case for Python
auto-instrumentation; and a fork is against this platform's rule that the
application changes nothing. The gain would be real — Django and ORM spans
instead of socket-level ones — so it is recorded as considered.
The delivery index only counted components
deployment_index() iterated ReleaseBindings and skipped anything without a
component, so a project made only of Resources did not exist as far as the
platform's own delivery metric was concerned. chat was absent from
openchoreo_deployment_info, and therefore from known_projects(), /deploys,
the fleet transaction search and everything downstream. Six running pods the
platform could not name. It now emits resource rows carrying kind, with no
image, commit or build_run — a Resource is deployed, never built, and inventing
those fields is how a Commit column starts showing something that is not one.
The existing database pods — done, and the risk was not real
Deferred once on measured conditions (2GB of 31GB free, swap exhausted, load average 42 climbing, one database pod at 36 restarts) because rolling five databases into that is the documented precondition for the CNPG cascade. Done once load had fallen to 6.93 against a 15-minute average of 32.9.
And the caution turned out to be unnecessary, which is the part worth
keeping. inheritedMetadata is reconciled onto RUNNING pods: advancing each
binding relabelled the existing pod in place with no restart at all — the
29-hour-old r-bakery-db-staging pod simply gained the labels. There was never
a rollout to be afraid of. inkwell-db-production, the cluster that cascaded
before, held timeline 39 throughout and stayed 3/3.
The order still stands as the right way to do it — hibernated first, the three-instance production cluster last — but the expensive part of the wait was avoidable, and anyone repeating this should know that CNPG does not need a rollout to apply inherited labels.
Result, checked across the whole data plane: every workload pod in every
dp-* namespace now carries openchoreo.dev/project. openchoreo:pod_identity
went 63 → 78 pods (inkwell 22 → 27, showcase 8 → 10), and Beyla now names the
resource pods properly — inkwell-files rather than
r-inkwell-files-staging-acc0f746, which is the service.name fallback doing
its job. No r--prefixed service names and no dp- namespaces remain in the
span metrics.
Found on the way: chat had the inkwell-files fuse
The cluster ran zulip-65cf9f69b8; apps/zulip/openchoreo/bindings.yaml said
zulip-7f6ff95767, five days older. development is the PLATFORM_ENV, which
manifest sync does apply — so the next push would have rolled Zulip back to
a release nobody chose, silently, with both objects Ready throughout because
each was correctly serving what it was pinned to. Exactly the fuse recorded for
inkwell-files, on a different project, found only because this change had to
touch the pin. Both now name zulip-8fdcf7bcb.
Also still open and now visible: 16 ResourceReleases for zulip alone, which is the unbounded release history already recorded below.
Rotated: the webhook secret
infra/.webhook-secret was committed before .gitignore covered it, and
.gitignore never untracks — the value is in this repository's history and
stays there (git log -S still finds it in two commits). Untracking made the
working tree clean; only rotation made the history harmless.
Rotated 2026-09-03 with infra/rotate-webhook-secret.sh, which moves all four
places it is trusted together: the file, git-webhook-secrets on the control
plane, project-flow-secrets, and every repository webhook.
Two things it teaches, both learned the hard way on the first run:
- Gitea's
EditHookignoresconfig.secret. It returns HTTP 200 and echoes the config back withsecretmasked — exactly as it does on success — and stores nothing. The secret is write-once at creation. The first rotation reported all ten hooks updated while every one still signed with the old value.setup-hook.shalready deleted-and-recreated for this reason; the rotation now calls it rather than reimplementing it. - Gitea's test-delivery returns 204 for dispatching, not for the
receiver's verdict. All ten came back 204 while project-flow logged
rejected webhook: bad signatureten times. Verification now reads project-flow's own log, and samples one repository rather than ten — the first run fired ten real push events at once, which is eight concurrent builds and a load average of 102.
Correction: the objectstore fix never reached the repository
Found 2026-09-04 by platform/upgrade-watch, confirmed against
platform/capacity/drift.sh --inverted, which reported the inversion as still
present:
Resource/inkwell-files (inkwell)
staging inkwell-files-5d96489b4f 2026-08-29T09:49:33Z <-- MinIO
production inkwell-files-5786fd4d99 2026-09-02T22:36:12Z <-- Garage, 4.5d newer
Verified by reading both releases rather than by inference: 5d96489b4f's spec
mentions minio, 5786fd4d99's mentions garage.
The promotion advanced production in the cluster and nothing wrote it down. All three of inkwell's binding files still pinned the MinIO-era release.
Fixed 2026-09-04 by pinning all three to inkwell-files-5786fd4d99.
Which environment the stale pin actually endangered
The first version of this entry said "the next push reverts production to MinIO".
That was wrong, and the way it was wrong is worth keeping. sync_manifests
skips any *ReleaseBinding whose environment is not the ROOT or PLATFORM
environment (controller.py:2568, ROOT_ENV=staging, PLATFORM_ENV=development),
so bindings-production.yaml is never applied by a push and production was never
at risk from one.
The fuse was on staging, which is synced:
- staging's file said MinIO and staging was running MinIO, so a push changed nothing visible — it silently preserved the bug;
- and it meant
occ resource promote inkwell-files --env stagingcould never hold. Whatever that command did on 2026-09-03, the next push put it back.
So the mechanism is not "the repo overwrites production" but "the repo owns
staging, and a promotion of staging that is not written down is undone." Because
the repo is now correct, the next push to apps/inkwell moves staging onto
Garage through the ordinary path; no out-of-band command is needed.
inkwell-db was deliberately left alone: it is pinned to inkwell-db-66995c964b
in every environment and in the repo, so it is merely behind latest — which
production is supposed to be, and which drift.sh correctly refuses to alarm on.
Two lessons:
occ * promotechanges the cluster, not the manifest. A promotion that is not written back is a promotion with a fuse on it, and nothing warns — both bindings stayReady, each correctly serving what it is pinned to.- "Verified in the cluster" is not "fixed" when a git repository is the desired state. And when reporting such a thing, check which environments the sync actually owns before naming the blast radius.
Open, found while acting on the above
These were not reported; they were in the way.
-
~~
wiki.jung.townis documented as reachable and cannot start.~~ Dropped 2026-09-04.apps/outline/and thewikiproject are gone, along with the rows inREADME.mdanddocs/ACCESS.md. The gateway certificate still covers*.wiki.jung.town, which is harmless and one less thing to change if it ever comes back. -
inkwell-webin production is behind.notes/relatedexists in the repo and returns 200 on the new staging image; production still runs the image from four commits earlier, so the traffic generator logs a permanent 404 against it. Oneplatform/templates/promote.sh --project inkwellfixes it. -
A
ResourceReleaseBindingstuck inRetainHoldis invisible. Three were wedged, two for ten hours, and nothing surfaced it.retainPolicy: Retainholds the finalizer until someone flips it back toDelete, and a component cannot resolve a dependency against a terminating resource — so the symptom arrives asPG::ConnectionBad ... socket /var/run/postgresql, which says nothing about bindings. Worth a check inplatform/capacity/. -
~~Nothing reports an environment pinned to a stale resource release.~~ Done —
platform/capacity/drift.sh. It does not alarm on "behind latest", which production is supposed to be; it fails on an inversion, a source environment older than the target it promotes into, which cannot happen by promoting.--invertedexits 1 for a cron or CI, and--self-testproves the detector still fires against the real inkwell-files data from before the fix. Currently: no inversions. -
A build can lose everything to one DNS lookup, at the last step. Under a burst of eight concurrent builds (load average 102), two died with
lookup host.k3d.internal on 10.87.0.1:53: no such host— that is buildkit's embedded resolver inside the Dagger engine, forwarding to a single-replica CoreDNS on the workflow plane with a 170Mi limit. The failure lands onpublish, so the lint, audit, tests and image build are all thrown away, and the message names DNS rather than contention. Both succeeded on redelivery. Partly addressed:wpnow runs two CoreDNS replicas (apply-coredns.sh, which is idempotent and runs at boot). CoreDNS is not memory-bound here — ~30Mi of a 170Mi limit — and has no CPU limit at all, only a 100m request, so the constraint was scheduler share on a saturated node. Verified with 60 concurrent lookups: the two replicas served 242 and 240 requests, so the load genuinely splits. The scale survives a k3s restart because k3s's owncoredns.yamldeclares noreplicasand therefore never reverts the field — worth re-checking after a k3s upgrade.Also addressed:
Publishin the platform Dagger module now retries a transient failure three times, 3s then 9s, so one bad lookup no longer discards a whole pipeline's work. Retrying is safe because pushing is idempotent — same content, same tag, same digest, and layers already uploaded are reused rather than re-sent. Only transient errors: retrying a 401 or a malformed reference would turn a clear five-second error into an obscure thirty-second one. Each retry records a span event, because a retry that heals silently is indistinguishable from a healthy build, and losing builds to load without saying so is the whole problem this started as.The decision is made on error TEXT — buildkit and the Dagger API preserve no typed error and no status code — which rots quietly in both directions, so
publish_retry_test.gopins the twelve messages that must retry (including the exact one observed) and the six that must not.Still open, and the last of the three: addressing the registry by IP from inside the engine, so the name never has to resolve there at all. That would remove the failure mode rather than surviving it.
Closed 2026-09-08, and the diagnosis above was wrong in its second half. The name never resolved in the engine at all -- 0 of 25 lookups, against 25 of 25 for a cluster service from the same process -- so this was never contention and never a coin flip.
host.k3d.internalis an/etc/hostsentry that k3d writes on the node, not a record any nameserver serves, so more CoreDNS replicas could not have helped and neither could a longer timeout. The engine now carries ahostAliasesentry mapping the name to the node's gateway address, plusndots:1, applied byplatform/bootstrap/30-dagger-engine.shafter the Helm release so an upgrade cannot revert it. Measured after the roll: 25 of 25. Addressing the registry by IP is no longer needed -- the name resolves without a nameserver in the path, which is the same outcome by a smaller change. -
Release history is unbounded.
inkwell-enrichalone has 25ComponentReleaseobjects, andinkwell-fileshas 7ResourceReleases. -
~~The DR data plane is down.~~ Still down, and the bindings that depended on it were removed 2026-09-04. They had been failing since 2026-08-29 with
no agents found for plane dataplane/dr, and nothing distinguishes a binding that is red because the platform is broken from one that is red because a cluster was deliberately stopped.Removing them was not a clean delete, and the way it stuck is worth keeping: each binding held
openchoreo.dev/resourcereleasebinding-cleanupwaiting on a RenderedRelease whoseopenchoreo.dev/dataplane-cleanupfinalizer could never complete, because resolving the plane client needs the agent that is gone. Clearing the RenderedRelease finalizer on a live object does nothing — the controller re-adds it — so the order is delete it, then clear the finalizer while it is terminating. The two ResourceReleaseBindings then held onretainPolicy: Retain(Finalizing=True RetainHold), released by flipping the field toDelete, which is the platform's own mechanism rather than forcing a finalizer.platform/openchoreo-config/environments/production-dr.yamland everything inplatform/failover/are kept, so restoring DR means running36-dr-data-plane.shand writing the bindings again.
Round 8 — a Resource's objects are not on the API, 2026-09-04 (newest)
Reported: "Could not read rendered releases. HTTPStatusError: Client error '404
Not Found' for url .../api/v1/namespaces/default/renderedreleases" on
/resources/orchard-db-staging/detail, and, from the Now page, "when I click on
it orchard-db is red which I assume is the failure".
/renderedreleases is not a route, and never has been
The console read it because a RenderedRelease is genuinely where a binding's
rendered Kubernetes objects and their per-object healthStatus are recorded —
it is what /releasebindings/{name}/k8sresources/tree reads on a component's
behalf. But the OpenChoreo API registers no such path. The string rendered
does not occur anywhere in its generated router
(internal/openchoreo-api/api/gen/server.gen.go), and the live API
discriminates it plainly: every real collection answers 401 without a token
and this one answers 404.
releasebindings 401
resourcereleasebindings 401
resourcereleases 401
renderedreleases 404 <- no such route
So /resources/{binding}/detail had never rendered for anyone, on any resource,
since the day it was added. The unit tests passed because they stubbed
ocapi.rendered_releases — the function existed and was correct about the CR;
only the route was imaginary.
There is no substitute. k8sresources/tree fetches a typed ReleaseBinding
by name (services/k8sresources/service.go:261) and 404s on anything else;
ResourceRelease is an immutable snapshot of the declaration, not the
rendering; and ResourceReleaseBindingStatus carries conditions and outputs but
no object list. A Resource's concrete Kubernetes objects are unreachable from
the platform API. That is the finding. Adding
/resourcereleasebindings/{name}/k8sresources/tree, or serving RenderedRelease
read-only, would close it — either is a patch to the vendored API, like
resource-hibernation.patch.
What the console does instead
It stopped pretending. /resources/{binding}/detail has its own template now
and answers the questions the API can answer, which turn out to be the ones
people bring to a database anyway: its state, its four conditions with the
platform's own message, its outputs — host, port, and which Secret under
which key holds the password, never the password — the parameters it was cut
with, and the object kinds its type declares. The one table it will not draw
is the one whose names would have to be guessed from CEL templates.
Hibernated is not broken — the same mistake, two more places
orchard-db-staging is state: Undeploy. Its rendered objects were deleted on
purpose, so Synced=False reason=ResourcesUndeployed, no objects, no outputs.
Through any generic health rendering that is a database that fell over.
/projects/{p}/resourcesprinted "The platform reports no objects" — true, and still the wrong sentence, because it sends the reader looking for a fault. The binding's own state is carried into the page now, so it says hibernated.- The resource page draws a
Falsecondition on a hibernated binding quiet rather than red.
Found while fixing it: the compare page agreed with itself
render_diff guarded unreadable manifests with isinstance(detail, dict) — but
deploy_detail never raises. It catches the upstream failure and returns
{unavailable: ..., releases: []}, which is a dict. So the guard was dead: a
component whose tree could not be read was compared empty-against-empty, scored
zero differences, and rendered as "renders the same", counting toward the
page's headline claim that the two environments agree. On the page someone reads
before promoting to production. Two fixes: the guard reads unavailable, and
"These two environments render the same" is now conditional on at least one
object actually having been compared.
The same class of error was in the resource path — both sides came back empty
from the 404 route, so inkwell-db was reported as agreeing across staging and
production having been compared against nothing. Resources are now named as
not compared, with a link to each side's own page.
Open
- The API has no route for a Resource's Kubernetes objects. Above. Until that exists the console cannot show a database's live object health, and says so on the page rather than implying it has looked.
- Events for a Resource are unreachable for the same reason — the events
route also resolves a
ReleaseBindingand only that.
Round 5 — permissions, releases, and the nav
Confirm my permissions I am seeing this error in the console The platform does not permit releasebinding:update for REDACTED@example.com on environment=production, namespace=default, project=inkwell. That is the role binding, not the console. I should have full admin access to everything on the platform with this account, and everyone on the allowlist should be able to fully use the console minus exec.
done, and the message was wrong. The role binding was never the problem.
admins → ClusterAuthzRole/admin → actions: ["*"], cluster-wide, no scope,
no conditions; the bootstrap log says REDACTED@example.com already in admins.
The console's ocapi.may() did not speak POST /api/v1/authz/evaluates. Four
independent defects, each alone enough to refuse every control for every user,
checked against the live spec at ${OCAPI}/openapi.json:
| the contract | what was sent | |
|---|---|---|
| 1 | body is an array of EvaluateRequest |
a bare object |
| 2 | resource requires type + hierarchy |
a flat dict, neither key |
| 3 | environment is at context.resource.environment, namespace-prefixed |
bare, inside resource |
| 4 | response is an array of Decision |
isinstance(body, dict) — False for a list |
The request 4xx'd, a blanket except Exception swallowed it, why_not()
printed the swallow as a policy decision. Every gated control in the console
was dead, for admins and developers alike, and the sentence sent the reader to
look at a role binding that granted them everything.
Nothing had ever exercised the wire: every authorization test monkeypatched
ocapi.may itself. There is a test for the request body now, and evaluate()
returns allowed / asked / reason, so an unreachable platform is a different
sentence and a 503 rather than a 403. /access renders authz/profile — which
had zero callers — so "confirm my permissions" is a page.
The deny binding that could never fire
developers-nonprod-only denied releasebinding:create/update/delete on
resource.environment in ["production", "production-dr"]
The platform populates that attribute namespace-prefixed:
services/utils.go:17 FormatDualScopedResourceName(namespace, name, false)
returns namespace + "/" + name, and releasebinding/service_authz.go calls it
at lines 53, 79, 105, 131 and 158 for exactly this attribute. The value compared
is default/production, never production. The rule was inert for its whole
life, and nobody noticed because the console was refusing everything for an
unrelated reason. The working form, recorded in
platform/openchoreo-config/authz/scoped-bindings.yaml where the binding was:
expression: 'resource.environment in ["default/production", "default/production-dr"]'
It is not reinstated — the standing decision is that everyone on the allowlist can fully use the console, minus a shell. Reinstating it is a deliberate act.
developers-inkwell-scoped went too. A narrower allow beside the shipped
cluster-wide developer-binding restricts nothing — bindings are additive — so
it granted a subset of what was already granted while reading, in its own
comment, as "a developer cannot create components in someone else's project".
Developers gained project:create (as ClusterAuthzRole/project-author, rather
than editing the vendored developer role): the console's scaffold control
gates on it and the shipped role lacks it.
Exec
Unchanged, and worth stating: it is not an authorization decision. The
OpenChoreo API serves no exec endpoint, so the console prints a kubectl exec
line and the reader's own kubeconfig is the control. permissions.py used
to claim project-flow re-checked the rule at an /exec handler; project-flow
has no such handler and never did.
The releases and deploys are still confusing. They say they take time but there are no logs, no way to tell what will be rendered, what was rendered until it is live. Somehow we are running the diff check.
done. GET …/releasebindings/{b}/k8sresources/tree returns
renderedReleases[].renderedRelease — the full RenderedRelease CR — and
deploy_detail read nodes out of that response and threw the rest away.
So the desired manifests were fetched on every visit to the deploy page and
discarded at parse time. spec.resources[].object is what the control plane
rendered; status.resources[] is what the data plane made of each one.
Three comparisons become possible and none existed:
- Desired against live — drift. One-sided on purpose: only fields the
rendering asserts are checked, because Kubernetes defaults
terminationMessagePath,dnsPolicy,schedulerNameand a long tail more, and reporting those would bury the real thing. Verified againstinkwell-enrich-production: zero drift, five objects. - Desired against desired across environments — now the default on the compare page. It used to diff the live objects, which picks up whatever other controllers did after apply and presents it as a rendering difference. Both are available and the page says which it drew.
- A projection for an environment nothing is bound in yet — below.
"No logs" was also already answered and unread: ReleaseBindingStatus carries
five condition types and ~30 reasons, several of which exist to say why a
deploy is slow (ResourcesProgressing, ConnectionsPending,
RenderingFailed, ResourceApplyFailed), plus observedGeneration and
lastSpecUpdateTime — a deploy start time. The console collapsed all of it to
one word. And project-flow has served GET /evidence/binding/{name} since it
was written, returning those conditions plus 30 control-plane log lines
naming that binding; the console had the client and called it only for builds.
A promote now lands on the deploy rather than back where the button was.
if it is possible I would really like the template tool … can generate what manifests a person will see across environments without applying
partly, and the honest part is the interesting one. There is no dry-run
render on this platform. Checked rather than assumed: 105 paths on the live
OpenAPI and none renders without applying; generate-release calls
s.k8sClient.Create and produces a frozen declaration, not manifests; no
dryRun parameter anywhere; and RenderResources has exactly one caller, the
in-process ReleaseBinding controller. occ has no render command.
So: exact for any environment where the component is bound — that is the
real RenderedRelease, and it is what the deploy page and the compare page now
draw. Projected for one where it is not: derived from an environment that
does exist, with the target namespace, environment name and environment-uid
substituted and the target binding's workloadOverrides applied. Labelled a
projection in a banner and per field, because two things cannot be derived:
- content hashes —
inkwell-web-staging-379f55e8→…-production-da55eda4, and the suffix is a hash of the content being predicted. Circular, so it prints<hash>rather than a name that does not exist. The same is true of every sibling it references (…-env-configs-0961d9a0). - anything a ComponentType or trait decides conditionally on the environment. Undetectable from outside, and the reason the banner does not soften.
Validated by projecting inkwell-enrich's real staging render forward and
diffing it against what production actually rendered. That found two bugs a
fixture would not have: the environment-uid appears in five places on a real
Deployment (spec.selector.matchLabels, spec.template.metadata.labels,
spec.selector, spec.podSelector.matchLabels) and the first version
substituted only the metadata label — which would have produced a selector
matching nothing.
The template tool itself cannot project: a template describes a project that has never existed, so there is no real rendering anywhere to derive from. The page now says exactly that, and says where the answer does live.
the nav is a mess, I have yet to see evidence of diffing, I don't even know how to find it
done. The diffing was /projects/{name}/compare, reachable from one link at
the bottom of the project page — and the compare route never passed subtabs,
so arriving there dropped the reader out of the navigation entirely. Nine pages
were in that condition:
| page | why it was unreachable |
|---|---|
/transactions |
in no tab list; its one href appended a required ?q= |
/projects/{n}/builds, /deploys, /logs, /resources |
listed in project_tabs(), but no page a reader could arrive on rendered the strip — the project overview did not pass subtabs. No inbound link from any template at all. |
/projects/{n}/compare |
the diffing. Same cause |
/projects/{n}/graph |
same |
/projects/{n} |
listed as "Overview", rendered no strip: a one-way exit |
/projects/{n}/environments/{env} |
in no tab list at all |
The template tool was never removed — it was demoted from the rail to a tab under Platform, and its own detail page rendered no strip, so once inside one there was nothing on screen saying where you were.
test_every_navigation_target_is_a_real_route checked nav → route. Nothing
checked route → nav, which is exactly how these drifted and stayed drifted.
test_every_page_route_is_reachable_from_the_nav does now, with an explicit
allowlist for detail pages so exempting one is deliberate. It found a tenth
page on its first run.
Found while fixing it: a resource's release history rendered empty, always
/resources/{r}/releases passed releases= and the template read history.
Jinja's Undefined is falsy, so the page printed "No ComponentReleases recorded"
for every resource that has ever existed — on the only tab a Resource has. And
for_resource emitted changes where the template read rows, so the field
table would have been empty even once the list arrived. Neither name was checked
anywhere: the tests called for_resource directly, and the nav test asserted the
link resolved. A route can exist, resolve, return 200, and render the empty
state for data it was handed.
Found by reviewing the fix: nine defects, two of them this round's own kind
A /code-review pass over the whole change set found nine. Two were the exact
failure this round exists to remove:
- A green "Ready." for bindings that were not ready.
blockingskips theReadycondition on purpose — Ready=False summarises the others rather than being a reason of its own — and the template read "no blocking condition" as success. A binding whose conditions the controller had not written yet, and one carrying onlyReady: False / ComponentReleaseNotFound, both rendered green, the second with its failure reason beside the word as though it were the reason for success.binding_statecalled those same objectspendingandfailing. Two halves of one page disagreeing about one object. - "Renders the same" against nothing, a third time.
_objects_forreturned[]rather thanNonefor a binding with no releases at all, becauseany()over an empty list is False — so both sides compared zero objects and scored agreement, inside the guard written after the second occurrence.
And: the projection left the source environment's uid in the selector, the pod
template and the podSelector whenever the target uid was unknown (the normal
case) — the five-places bug in a second disguise, and the re-walk written to
detect it silently found nothing because a label key contains dots;
holds_everything ignored denials on the page whose purpose is that deny wins;
/access reported an outage to anyone signed out and forwarded component
without project; drift paired across kinds; and apply_overrides was dead
until wired to the promoted-but-not-yet-reconciled case.
The lesson worth keeping is that a change set built specifically to stop confident wrong answers produced three of them, in the code written to stop them. Reviewing your own fix is not optional.
Open
- The deny expression is recorded, not applied. If developers should be kept
out of production, the working CEL is in
scoped-bindings.yaml. - A projection cannot see conditional CEL. No way to detect it from outside; the banner says so rather than the page pretending otherwise.
- Still no route for a Resource's Kubernetes objects, so resources are outside both the rendered view and the drift check, and say so.
Round 9 — nine asks at once, 2026-09-06
Why can't I trace more via network headers between things? I kind of expected we would be able to easily trace traffic that passes over the internal network, in and out of the ingress by trace too.
done, and the answer was one policy object and one line of chart values.
kgateway/Envoy had no tracer, so the first span in every trace was the
application's own server span — Envoy with no tracer emits none and does not
synthesise a traceparent; it only generates x-request-id, which nothing
collected. Beyla could not cover it either: its discovery is k8s_namespace: dp-* and the proxy is in openchoreo-data-plane.
platform/openchoreo-config/gateway-tracing.yaml adds a second
HTTPListenerPolicy (the chart's own is Helm-owned) with Envoy's OTel tracer
and a JSON access log carrying the traceparent.
The part that cost the time: Envoy's OTel tracer speaks gRPC only, so it dials Alloy's 4317 — a port nothing had ever used, because every application here speaks HTTP to 4318. kgateway built the upstream cluster from the Service, defaulted to HTTP/1.1, and Alloy rejected all 24 exports. And the symptom says the opposite of what is happening:
tracing.opentelemetry.spans_sent: 37
tracing.opentelemetry.spans_dropped: 0 … with nothing in Tempo
spans_sent counts spans handed to the exporter, not delivered. One level down:
upstream_cx_http1_total: 24, upstream_cx_protocol_error: 24. The fix is
appProtocol: "kubernetes.io/h2c" on Alloy's otlp-grpc port.
Verified: {resource.service.name="gateway"} returns traces whose services are
console,gateway, and the access lines carry a joinable trace_id in Loki.
Not closed: the browser sends no traceparent (no OTel JS in the SPAs), so the
trace begins at this network's edge rather than at the user's; and Garage still
starts a fresh trace rather than continuing its caller's.
The build page should be focused on the part(s) that failed automatically… clicking a span should show its attributes and events in line in a more tightly formatted box… every major section should be collapsible and there should be a mini nav… buttons to download logs and profiles and traces in a standard format.
done. A failed build opens on the first FAILED stage in pipeline order —
cause before consequence — and on the first failed span inside it, which is the
exec carrying the command and the exit code rather than the stage span saying
"stage failed". Only when the reader asked for nothing: an explicit ?stage= is
a link somebody sent. This also made ?span= do something; it had been
accepted, documented and read by nothing.
The span detail panel moved into the waterfall. Same element, same renderer — it simply sat a screen and a half below the row that selected it. The SVG leaves a gap under the selected row the way a tree table expands a node.
Sections collapse and there is a mini nav, both read off the <h2>s already on
the page rather than written into nine templates — so it cannot desynchronise
from the markup, and it applies to the project and compare pages for free.
Downloads: logs as text or NDJSON, the trace as OTLP or as a file Jaeger's UI
imports with no server, profiles as real gzipped pprof, speedscope JSON or
folded stacks. No new upstream call — every one is a conversion of a response
the page already fetches (app/exportfmt.py), and the pprof encoder was
validated against github.com/google/pprof/profile, which is the only way to
catch leaf-first ordering.
I got a timeout trying to view the delivery cache page. Evaluate if everything is efficient, I'm open to adding a database to the console when it is required.
done. /cache read a trace and up to 4000 log lines per build,
serially, for up to sixty builds, on one thread at a 20s timeout each. 121
requests. It is arithmetic, not a mystery.
Same reads, run concurrently, with soft applied per call so one build aged
out of Tempo does not blank the other fourteen. The ceiling drops to thirty.
Three more of the same shape fixed: project_resources (one deploy_detail
round trip per binding), the transaction page's N+1 analysis, and prom.builds
— the most-called source in the console — which had no cache at all.
And a database, console-db, on the platform's own postgres resource type. It
holds facts derived from the stores the console already reads, about builds
that have finished. A miss re-derives; the store being down is a slower console
and never a broken one. It is also devtools' first Resource: the console
rendered everybody else's databases and had none of its own.
I can't test the rollback/releases history, I get Nothing to roll back to… suggesting maybe we are not recording and linking history of releases correctly.
done, and there were three separate faults.
- The console asked a different question from the button. It decided from
the history ring; project-flow decides from the
previous-releaseannotation. The ring is newer than most of this platform, so eight production bindings carried the annotation, no entries, and rendered as refused whilePOST /rollbackwould have succeeded. - Nothing recorded autoDeploy. Only
promote()androllback()wrote the annotation, and neither is the writer for the root environment — so staging, the environment every change lands in first, could never be rolled back.observe_bindings()now watches every binding and records a move it did not make, withverb: observedand the mechanism rather than an invented actor. - The rollback button ignored the environment.
flow.rollbacksent"to": ""and project-flow readsbody.get("to") or PROD_ENV, so every rollback this console ever issued moved production — including from?environment=staging, which rendered staging's plan quite correctly above it.
Plus a release picker: rollback() has accepted an explicit release since it
was written and no page ever offered one, so the only way to use it was curl.
Proven end to end on bakery-docs-production, which had no history at all that
morning:
14:22:10 promote 85fdc5cc57 -> 65fbff7d6 by auto
14:21:53 rollback 65fbff7d6 -> 85fdc5cc57 by session-verification
13:04:06 promote 85fdc5cc57 -> 65fbff7d6 by auto
13:01:51 observed (unknown) -> 85fdc5cc57 by autoDeploy or an out-of-band patch
On /projects/rideshare I see binding not readable
answered. It is the deliberate degraded path, not a rideshare fault: the node has a component and no binding name, so the object-graph and log links are suppressed rather than rendered as 404s. Bindings are token-scoped platform API reads, so signed out — or with a token that cannot see the binding — every flow view renders this.
the small medium large labels should link to the platform docs on what that means wherever it appears
done. There was no such page: the prose lived in a bash comment in
generate.sh, in each generated type's description, and in one binding's
YAML. /guides/size-classes says the thing that most needs saying — a class
is relative to the COMPONENT TYPE, so a Rails large and a Go large are
different absolute numbers — plus the two pieces of arithmetic that bite
(max(sum(containers), max(initContainers)), and maxSurge rounding up while
maxUnavailable rounds down). One sizechip macro replaces three hand-rolled
copies.
Let's remove the orchard project, document the problem it was meant to show… and then remove it from the platform.
done. Documented as /guides/three-kinds-of-slow — written with the query
or download that answers each case rather than screenshots, because a screenshot
of this platform is out of date by the next deploy and go tool pprof -top <(curl …) is not. The endpoints moved into inkwell-web, which is the component the
Ruby profiling panels already name and the one built with profileRich: true.
Found in the move: db:seed does not run in the cluster. db:prepare seeds
only when it CREATES the database; against an existing one it runs pending
migrations and stops. /widgets returned {"widgets":[]} with a 200, the table
present and empty, nothing reporting a fault anywhere. Orchard got away with it
because its database was created by the rollout that first ran its seed. The
walkthrough data is a migration now.
Then removed in dependency order; the ownerRef cascade took 69 ComponentReleases
and 33 WorkflowRuns with it. The Gitea repository is archived rather than
deleted, and the test fixtures named orchard stay — each names the object a
real bug was found on, and renaming them would turn a measurement into a story.
We need to get the staging environments working so we can see promotions work their way through and exercise manual promotion and rollback.
done, and it was one field. All twenty staging bindings were Undeploy, and
promote() requires is_ready(source) — so no promotion could succeed
anywhere on this platform. showcase and chess are awake (inkwell stays asleep;
host memory is the binding constraint). Waking showcase immediately triggered a
real auto-promotion, because it carries promotion: auto with a 2m soak — which
is the platform working, and it moved production forward by three days.
Also: sync_manifests now refuses a pin that would move a binding
backwards, comparing the two releases by creation time. Root resource pins
are applied on purpose — occ resource promote cannot create the first binding,
so the repo genuinely is the source — but a stale file and a deliberate rollback
are the same diff, and only one of them should happen by push.
there are too many links that are just the project or component name when it actually links to some sub resource… Survey the console for other instances.
done. Eighteen places, always the same shape: the page wrote down the
identity of the row's OWNER instead of the row's. /builds listed the component
three times over, each a link to a different build, so the only thing telling two
rows apart was the timestamp. There is a Build column now reading
console@a1b2c3d4, and a Deploy column on /deploys going to
/deploys/{component}/{environment}/{commit} — a route that existed the whole
time with one inbound link.
The enabling change: /components/{name} now exists. nav.component_tabs
documented its absence since it was written, and that absence is why component
names linked to /timeline or a filtered list — there was nowhere honest to send
them. A template lint keeps the rule.
We need to figure out exactly how rolling out changes to ComponentTypes will work… consuming projects pin the component type version… upgrading would happen via the same git path.
partly done — the code is written and not yet applied. See
platform/openchoreo-patches/construct-versioning.patch and the rewritten
§"Pinning a version" in VERSIONING.md.
The blocker worth knowing: Component.spec.componentType was self == oldSelf
immutable, which freezes the version along with the identity. Every pinning
scheme needs that relaxed — including the "version in the object name" fallback,
which would otherwise mean deleting the Component and its releases with it. So
the choice between schemes was about ergonomics, not about whether to patch.
Resolution is by label, not by a name suffix: metadata.name has to be
unique and the logical name has to be stable, and one string cannot be both. A
name-suffix scheme makes the FIRST version's name wrong and makes adopting v2 a
rename rather than an addition.
Every platform-authored construct now carries openchoreo.dev/{name,version},
so kubectl get clustercomponenttypes -l openchoreo.dev/name=ruby-service
already lists the versions that exist.
Open
- ~~The versioning patch is not applied.~~ Applied 2026-09-06. The CRDs
and
v1.2.3-construct-versionsare live; 712 → 713 ComponentReleases, every Active binding still Ready.apps/consoleis the first consumer to pin, and both halves are verified — the pin resolves and is frozen into the release, and an unpublished version is refused by name with the command that lists what exists. Every other reference still floats, which is the backwards-compatible default and what every manifest written before this does. upgrade-watchhas no fourth axis yet. The PR-opening path it would reuse exists (/actions/open-pr→ project-flow/git/*, with the per-projectupgrades: propt-in); what is missing is the finder that compares a consumer's pin against the newest supported version, and the contract diff for the pull-request body.- No
?from=&to=contract-diff page on a construct yet.
Round 10 — the pipeline you can see, 2026-09-07 (newest)
Nine asks at once. Underneath them is one, in the user's own words: "the links between commits and builds and releases of projects to environments and between releases in time are either not there, too hard to reach, visually hidden. In my mind navigating that chain is the 'pipeline'."
Planned as six phases in this order — queue and gate truth first, then the Now page, then build outputs and the release chain, then the delivery graph, then profiling. What follows is phases one to three.
Done — the build queue tells the truth
Builds listed in queue in UI even while I can see they have already run quite a bit of the build. We need to track time waiting in queue on each build. I was imagining something more like a table with running, in queue, with timers showing how long each had.
done, and it was a real wrong answer rather than a display preference.
run_summary() decided queued from the WorkflowRunning condition, which is
OpenChoreo's observation of the Argo workflow leaving Pending — so while
that observation was outstanding, a build with pods and scrolling logs reported
itself queued. The honest discriminant was already used twelve hundred lines
away in the same file: build_index() takes the queue's end from
min(status.tasks[].startedAt), because Argo does not schedule a step for a
Pending workflow. Both signals are kept; either one means running.
The second half is that nothing carried the wait. status.startedAt is
ADMISSION, not first work, so every elapsed time the console drew for a build in
flight was wait-plus-work labelled as the work — "Started 3m ago" on a build
forty seconds into its first step. run_summary() now serves running_since
and queued_seconds, measured from the same instant as the _queued metric
step so a live figure and a recorded one cannot disagree.
Three places show it: the front page's new build panel, /builds (a Waited
column beside Took, with Took now excluding the queue), and the build page's
banner and header. Verified end to end on two real builds —
chess-opening-run-qcheck01 waited 42s behind brochure-run-qcheck01 and
records 42s waited, 53s of work.
Two things found on the way, both fixed:
- A wait under two seconds is the lock being free, not a queue. Measured
across every WorkflowRun on this platform: unqueued builds record 0–2s and
queued ones 51–203s, so there is nothing near the line.
TRIVIAL_WAITis one constant, exposed to Jinja, so two pages cannot disagree about when a wait is worth printing. status.tasks[]is the tasks Argo has CREATED, so it grows while the build runs. "step 0 of 1" with an empty bar reads as a build stalled at the start, and a bar over that denominator goes backwards. The panel names the step and counts what has finished, and there is no fraction, because the pipeline's real length is in the ClusterWorkflow and is not on the wire.
Done — a gate says why, and what to do about it
Also the gates section should be very prominent about the reason and what you need to do to fix it, fix whatever we need to do for this message "project-flow sends only the keys of this list…"
done, and the one-line change was necessary and nowhere near sufficient.
/state did serialise pending_promotions with list(...), which sends the
keys — that is now dict(...), along with pending_reviews, which had the
identical defect. But neither writer set a reason, so the em dash would
have survived the fix. Both writers record a reason and a remedy now, written at
the branch that made the decision, because the numbers that make a reason useful
("74s of a 120s soak") exist only at the point of the refusal.
The larger finding: pending_promotions is a work queue, not a gate list.
auto_promotions() has six ways to not promote and parked exactly one of them.
A soak still running, a binding that is not Ready, a hibernated source, a failed
smoke check, a full quota and a retry backoff were each a bare continue; two of
the six reached Zulip and nowhere else. So "why has nothing promoted" had no
answer outside that process.
held_promotions is a second, separate key — separate because reconcile()
DRAINS the queue, and an entry it cannot complete would break that loop. It is a
report rather than a queue: rebuilt whole every pass and swapped in, so a gate
that has cleared is gone because it was not written again, and nothing needs a
TTL. Six gates, each with its reason, its remedy and a clock where the gate is a
clock. Eight self-test cases, and install.sh refuses to install past them.
On the console the section is cards rather than table rows — the useful content is two sentences of prose, which in a table was a wrapped paragraph beside three near-empty columns — ordered worst-first, because sorting alphabetically buries a failed smoke check under three soak timers. The badge counts blocked separately from parked: parked clears on its own, blocked needs somebody, and a soak timer lighting the badge on every ordinary promotion is how a badge stops being read.
Found while fixing it: the promotion grid's gate chip had never rendered
once. It tested f"{project}/{component}" in waiting against project-flow's
keys, which are auto:<component> — neither spelling can equal either key. The
match is on the values now, and the chip says which gate rather than the word
"waiting".
Done — the front page shows what is building
I expect the build queue and actively building to be larger and more prominent and quickly updated on the now page with a mini streaming logs panel for the running builds. Also to display builds in cards.
done. The front page showed no in-flight build at all; the only thing
rendering any of it was the badge in the rail, three at a time, inside a title=
attribute. There is a Building now section with a card per running build —
component, commit, project, the step it is on, work and wait as two numbers, and
the last eight lines from Loki — above an In queue table carrying position
and wait. Cards for the running ones because a running build has things a
finished one does not; a table for the queue because position and wait are
exactly what compares down a column.
Its own poller at /api/now/builds, and it never self-terminates, which is
the one way it differs from the build log fragment. That one ends its loop by
omitting its trigger when the build finishes, because a finished build never
starts again; this panel's whole job is to notice the NEXT build, so it slows
from 3s to 10s instead.
loki.build_tail is not build_all with a small limit: Loki applies limit in
the direction it reads, so a forward read of eight lines returns the checkout
banner for the whole life of the build.
Found while building, each caught by a test refusing to pass
Undefined > floatraises in Jinja.queued_secondsis absent on every row until project-flow rolls, and absent for good on a replayed snapshot. A threshold comparison written the obvious way turned a missing field into a 500 on the busiest pages in the console. Eleven existing tests caught it at once.- Markdown leaked into a JSON field with two consumers. The reason strings
carried backticks for Zulip and rendered as literal backticks in HTML. They are
plain text now;
_warn_oncestill writes the Zulip formatting separately. agoon a future timestamp prints an em dash, becauseagerefuses a negative duration — and on this console an em dash means "not known". A gate that says "clears —" reads as a gate whose clock nobody has, when the clock is the one thing about it that IS known.model.untilcounts forwards, and says "now" rather than "—" once the moment has passed.console-local.shcould not reach project-flow at all, so every panel that asks the reconciler rendered as "could not be asked" — honest, and useless for looking at them.PROJECT_FLOW_URLis a pass-through now, with the port-forward bound to the docker bridge rather than to 0.0.0.0.- The Zulip bot sliced a dict.
pending[:5]on the new wire shape raisesTypeError. It accepts both shapes, and reports the held gates.
The queue reading is as fresh as OpenChoreo's mirror, and that is a real limit
Watched live across two pushes. console-run-6ff8e55c queued behind
openchoreo-bot-run-7f977efc and reported its wait climbing 8s, 29s, 49s, 69s —
then flipped to running and froze at 45s, which is the measured
first_task - admitted. Both numbers are correct for what they are, and the
gap between them is the point: while a build is queued the figure is
now - admitted, and it keeps counting for as long as it takes OpenChoreo to
mirror Argo's task list into the WorkflowRun. Measured at roughly 20-30 seconds
here.
So a queued build's live wait can over-read by about half a minute, and the recorded one never does. Reading Argo directly would close it, but that is a workflow-plane read from a control-plane process for a number that only matters while a queue is minutes long. Stated rather than fixed.
The same lag is why queued cannot simply be "no tasks yet": for about half a
minute after work begins, the WorkflowRun genuinely says there are none. That is
what the WorkflowRunning condition is kept for — either signal being true means
running, and between them the window where a build looks held while it is not is
as small as this data source allows.
Done — the chain from a commit to a manifest
Starting at the build page, there should be a section listing the outputs of the build… be very explicit about what component releases are contained… For each individual release I want to see at a glance what changed in the CR, but also a diff of the Kubernetes layer changes.
done, in four places.
A build page says what the build produced. Two halves, because they are two kinds of output: the ARTIFACT (the image and its digest, what BuildKit made) and the PLATFORM OUTPUTS (the ComponentReleases OpenChoreo cut from it, which are what actually gets deployed), drawn as the same card the object graph and the estate views draw and coloured by whose change each was.
Joined on the digest, and that is the whole of why it works: the tag is
v1-<sha>, so a rebuild of the same commit publishes the same tag over
different bytes. prom.builds was dropping the digest label in its own by()
clause. Several matches are the ANSWER here — one build with siblingWorkloads
produces three releases — which is the opposite of by_image, whose job is to
name one release and which refuses to guess.
A release page says how it got here. commit → build → release → binding → rendered release, as the same DAG three other views already emit. The commit
comes off the IMAGE TAG rather than off the build, which makes it exact for a
release of any age while the build ages out of Prometheus in a week; when it
has, the build node is still drawn and says "not recorded", because leaving it
out shifts every column and implies there never was one. The build edge is
dashed: nothing records "that build cut this release", so it is a match on
content, and every solid edge is the platform saying so itself.
And a diff of the Kubernetes layer, which could not be answered at all —
releases.html had said so in prose for months. A RenderedRelease is stored
per BINDING and holds only what that binding serves now (36 here against 720
ComponentReleases) and there is no dry-run render, so the manifests of a
superseded release are gone the instant the binding moves. Not hard to find:
absent.
So the console keeps them. A new history kind, rendering — and the only one
in that table whose upstream has no retention window at all; every other kind
forgets in days. The console PULLS: project-flow posting them was the obvious
arrangement and it cannot, because from inside the cluster it gets a 403 for
console.jung.town the same way grafana.jung.town does, and the console has no
in-cluster address the control plane can name. Measured, not assumed. project-flow
serves a cheap index instead — one row per (binding, release), no objects — and
app/archive.py asks for bodies only for pairs it has not seen.
Verified on inkwell-web-production's real manifests: nine objects compared, the
two mutated fields found, nothing misclassified as environment or identity noise.
The per-component release list gained the same vocabulary: whose change each release was, where it is serving (or that it is serving nowhere, which is normal and reads as a fault when blank), and a link to its manifest diff.
Found while building it
- A short digest and a full one never match.
_digestreturns twelve characters for display; the metric label is a fullsha256:. Comparing them matched nothing, silently, and the outputs panel read as a build that produced no releases._content_digesttakes either spelling on either side. - The gateway route has to name every path, and its own file warns about this
twice — comments written after the trap caught
/locksand then/window. It caught/renderingstoo: served, self-tested, deployed, and 404 at the gateway. - Nothing had ever rendered
/releases/{name}in a test. The route was in the nav-reachability list and the handler was exercised by nothing, which is how asoft_orcall missing its requiredbecausereached a browser. passes: 0meant two things on /health/ready — the thread has not run, and the thread runs and never gets anywhere. A failed pass counts now.- A red chip for the healthy case. The release list borrowed the compare
page's
why-*colours, wheresubstantiveis the finding and red is right. A release changing what its component declared is ordinary, and rendered as an error. .srcrefisnowrap, so an explanatory sentence beside an image reference ran off the page.
Done — delivery is a rail, and a pipeline run has an address
For the delivery timeline view I was still hoping for something a little bit more like
git log --all --decorate --oneline --graph(but not ascii, more like SourceTree visually but matching our style) where timestamps and commit cards are on the left and you see the linked events that spawn off them.
done. The events were chips crammed into the right-hand side of a commit card, so a commit that built three components and deployed to two environments was five facts on one line with no order between them. Each is a row now, hanging off its commit by a curve, in the order it happened — and a build sorts before a deploy at equal times, because that is the causal order and a coarse timestamp must not print a deploy above the build that made it.
A lane is a REPOSITORY, not a branch, which is the one thing the picture must
not be read as. A project is not a repository — inkwell is six components
across four — and ancestry is only defined inside one, so a line here means "the
same repository's history, in this order" and nothing stronger.
/components/{c}/graph is the drawing that does know about branches. More
repositories than lanes says so rather than folding two into one lane, which
would read as one history.
A gate is a row, and it is the valuable one: a commit deployed to staging and held out of production looked identical to one that had finished.
The segment vocabulary and the per-row SVG are gitgraph's, deliberately — one
tall rail beside the rows points at the wrong commit the moment a row expands.
If we could identify and formalize what an individual instance of a "pipeline run" and give it a dedicated page you can reach that could be good but strongly linking all the concepts here must come first.
done, and the linking did come first — Round 10's build outputs and release chain are what made this answerable. A pipeline run is defined here:
commit -> WorkflowRun -> image+digest -> ComponentRelease(s)
-> ReleaseBinding per environment -> deploy
-> [gate] -> promotion -> ReleaseBinding -> deploy
Environments are not nodes on it; they are what a binding IS, and adding them would draw the same fact twice. A gate is a node.
/pipelines/{run} is not the build page and not a copy of it. That page is about
the BUILD — its stages, logs, profiles, what it cost, why it failed. This is about
the DELIVERY of what it produced, which is a different set of objects and the
question a reader actually has when the build is green and the change is not in
production yet. Not in the nav, for the same reason a release is not: "a pipeline
run" is not a place you navigate to without having one in mind.
Found by the template lint while writing it: a component name linking to a build, which is the exact defect eighteen places were fixed for in Round 9.
Partly done — the review-environment flow
If we could optionally show the nested review environment flow of the same (toggleable) so you would see the delivery pipeline of a PR before it was merged too in line before the squash.
partly. A commit that is not on the default branch is marked on the rail now,
and three-valued rather than two: off main, ancestry unknown (Gitea could not
be read, which says nothing either way), or neither. delivery.for_project keeps
those rows rather than dropping them, so a pull-request commit that built is
already in the picture.
What is not done is drawing the PR branch in its own lane with its review
environments, rejoining the trunk at the squash commit. It needs a Gitea walk and
a merge base per repository — gitgraph._gather does exactly that for one — and
it is left undone for a reason worth stating: there are no open review
environments on this platform right now, so it could be built and not verified.
A view of a flow nobody can look at is how a confidently-wrong drawing ships.
Open
- A project-flow restart delays every pending promotion by a full soak, and
says so plainly now that the gate report exists:
_RELEASE_SEENis seeded withnowon first sight andsince = max(seen, ready.lastTransitionTime), so the restart wins. Deriving the seed from the ComponentRelease's owncreationTimestampwould survive a restart and stay at least as conservative. Not done here because it changes when production deploys happen. - A running build has no honest progress fraction. The denominator is in the
ClusterWorkflow's
runTemplateand project-flow does not read it. One cheap cached read per workflow kind would give a real bar. - The rendering archive covers only what it has seen. Nothing is backfilled, because a superseded rendering does not exist anywhere to backfill from. A release page says which pairs it cannot reach rather than rendering an empty diff, and coverage grows from switch-on.
- The review-environment lane, above: needs an open review environment to verify against.
- Which HALF of the profiling path leaks.
PYROSCOPE_ENABLEDgates the Pyroscope agent andPlatformTelemetry::SpanProfilertogether, only because the initializer was written that way. They are separable and the experiment that identified the path (below) cannot say which.inkwell-worker-profiledin staging is the instrument for the next round. inkwell-workerproduction is still running with profiling OFF. It was an experiment and the experiment has answered; the switch is a cluster-only patch that nothing in the repository holds, so nothing will undo it.- Go tests have no per-test flame graph.
pyro.profile_statematches a profile to a span by span NAME, and the Go stage still pushes one merged pprof taggedspan_name=go-ci/test, so every Go test span reportsnone. Fixing it means citest taking overpprof.StartCPUProfileper test -- only one CPU profile can be active at a time -- and dropping-cpuprofilefrom the loop. Coherent, and not needed for anything the oracles check.
Phase six — profiling, and what the oracles caught
The profiling work landed. What is worth recording is not that it works but what it found, because two of the three findings were things that had been running, green, for some time.
The Python memory switch was wired to nothing on six of seven services.
memoryProfiling had been on the continuous-profiling trait since it was
written; the profiling dashboard offered it and docs/OBSERVABILITY.md
documented it. Exactly one service read PYROSCOPE_MEM_ENABLED. The other six
carried a near-identical profiling.py, and the console had an eighth copy
under another name. Deleting the duplication into platform_telemetry 0.6.0
fixed it everywhere at once, and the trait's default flip to true is a MAJOR
by docs/VERSIONING.md -- so continuous-profiling-v2 at 2.0.0, with the
default label moved off v1, and 2.1.0 adding the two heap knobs the file's own
prose had been promising before anything could read them.
Ruby has no Process.getrusage, and a rescue turned that into a column of
zeroes. The new "ceiling" column -- ru_maxrss delta, the one per-test memory
number that cannot be negative -- was written with the Python and Go spelling of
a method Ruby does not have. rescue StandardError catches NameError like
anything else, so every example of every Ruby build reported a ceiling of
exactly 0, the attribute was emitted, and the panel would have ranked a Rails
suite entirely by zeroes while looking completely normal. Nothing reported it.
What reported it, on the first build after it was written, was the oracle
spec landing in the same change: an example that asserts a number it already
knows (64 MiB held, ~1s of CPU in one frame) and has no rescue to hide behind.
It failed with the NameError in plain sight. That is the entire argument for
oracle tests and it paid for itself immediately. All three languages now have a
pair, and all three check out against the panels: Ruby reports 64.1 MiB for the
64 MiB example, Go reports 64.0 MiB of churn and 1.0000s of wall for its two.
Go had no per-test records at all, so its oracle was invisible when written.
platform/gomod/citest is the third platform-owned library beside gems/ and
pylib/. The interesting part is that Go's testing package has no per-test
hook and never has -- testing.M keeps its test list in an unexported field and
MainStart's deps argument is an unexported interface, so a wrapper cannot
rebuild an M from outside either. Of the three ways round it, running
m.Run() per test would have broken the merged CPU profile the CPU oracle
depends on, and parsing go test -json cannot see memory at all. So it reaches
the field by type through reflect and unsafe, degrades to today's behaviour if
the layout ever changes, and buys the risk back with a test that asserts the
wrap took against the toolchain in use.
And the leak was the profiler. The controlled experiment started on
2026-09-06 has an answer: switching profiling off took inkwell-worker from
+47.0 MiB/h to +1.9 MiB/h with its traffic unchanged, while the untouched
control kept leaking and was OOMKilled four times in a day. Written up in
docs/FINDINGS.md. It has a direct consequence: inkwell-worker-profiled was
built to profile live traffic harder, so on this evidence it will leak at least
as fast as the thing it diagnoses. It is bound and running in staging, and
its production binding is written, sized and deliberately not promoted.
Round 11 — the corrections
Nine asks arrived as one message. Three of them turned out to be bugs with a single root cause each, and those are done; the rest are planned and open. What follows is the ledger for the three, including what was found to be already true while looking, because two of the items in this file were stale.
Done — the delivery timeline's git rails were sticky
base.html declared .rail unqualified, carrying position:sticky; top:0; z-index:10 and six more properties. The delivery timeline and the commit graph
each draw one <svg class="rail"> per row — 112 of them on
/projects/inkwell/delivery — so every git rail was pinned to the top of the
viewport at the header's own z-index and rode over it on scroll. svg.rail
further down reset display and flex; the rest leaked.
Renamed to .toprail rather than reset. A reset has to null every property the
header rule will ever grow, and the next one added would quietly reach the git
rails again.
A test holds it: any bare, positioning class selector in base.html must
match one kind of element. Scoped to positioning deliberately — .mono and
.faint are utility classes meant to work on anything, and a check that flagged
those is one nobody reads. Verified by reintroducing the bug: it reports
rail positions <div>, <svg>.
Done — the timeline clipped where it should have scrolled
Rows are one flex line at a fixed --rowh, and they have to be: each draws its
own rail SVG exactly that tall, and the lanes only join up because every row
agrees on the height. So a row cannot wrap, and overflow:hidden on it meant
the chips, the environment pill and the timestamp were cut off on a phone with
no way to reach them. The container scrolls now and rows carry a min-width.
Found beside it: table { display:block; overflow-x:auto } existed only below
640px, so between 640 and 900 a wide table pushed the whole page sideways —
the exact symptom that rule exists to prevent, on a tablet.
Done — every bot link lands in the console
for_transaction is appended to every reply from every bot on this platform,
and two of its four links went to Grafana. The argument against that was already
written down when [transaction] moved: Explore shows one signal at a time,
needs a query, and does not open at all for a Grafana Viewer — it silently
redirects to the home dashboard. Nothing in that argument was specific to
[transaction]. [profiles] and [component] were an unfinished change.
Also rewired: project-flow's four hand-built GRAFANA_URL announcements (the
constant is deleted, not left unused), the Gitea commit statuses — Argo while
pending, the Grafana build dashboard when finished, both now the console build
page with a failed stage deep-linking to ?stage=&span= — the walkthrough and
conductor bots, TechDocs, and five hand-encoded Explore URLs in the console's
own templates using Explore's superseded ?left={json} form.
Four of those five were trace ids, and /traces/{id} had zero inbound links
anywhere in the console. It was built precisely because every bot link went to
Grafana, and then the console's own links were never rewired.
The alert relay's [logs] link has been a 404 since it was written.
{CONSOLE}/logs?project=… is not a route — logs are /projects/{project}/logs
— so every alert this platform has posted carried a dead link, and the query
parameters it assembled so carefully were read by nothing. Its [component]
link pointed at the project.
platform/dev/check-links.py now checks three failure modes: a Grafana
dashboard URL built outside links.py, a console URL naming a route that does
not exist, and a query key FastAPI would silently discard. The third found a bug
written minutes earlier — ?project= on a route that reads no such parameter —
which is the argument for having it. # links-ok marks a deliberate escape
hatch per statement; four survive and each says why.
It is a script and not a test because it must see the whole repository at once and nothing here can: each test container mounts only its own directory, and two of the three offending builders live outside all of them.
Done — the stage tiles lagged the log, and it was an absence not a lag
A Dagger stage span reaches Tempo only when the stage ends — the module force-flushes per stage — so a running stage has no span, no tile, and the panel showed the pipeline as it was one stage ago while the log beside it streamed that stage's output.
Three sources know different halves, and each is now used for what only it knows:
| source | knows | freshness |
|---|---|---|
| Tempo | which stages have finished | accurate, late |
Loki ci_stage stream label |
which stage is writing now | live |
| Argo task phases, via project-flow | which workflow step is running | live, coarse |
Loki's stage counts only when Tempo has not already closed it. Measured on
console-run-cca9f975: test wrote 584 lines over 45s, so its label tracks in
real time — but lint, audit, image and publish wrote one to three lines
each, all at the instant the stage returned, because what carries ci_stage
for those is the stage function's return value. Reading that as "still running"
would be a worse lie than the staleness it replaced.
Verified live on console-run-ae795ab1: the running tile read lint 2.3s — a
Dagger stage name, which Argo never uses, so it can only have come from the log
— while Tempo still had no span for it. The finished tiles then filled in behind
it: [] → [lint] → [lint, audit] → [lint, audit, test].
The poll is 2s and the live cache TTL is 2s because they are one decision.
A 3s poll against a 5s cache re-rendered identical bytes two times in three: the
page worked harder, the upstreams saw nothing extra, the reader gained nothing.
builds.FAST_POLL_SECONDS is now derived from _cache.LIVE_SECONDS. The cost
is bounded by the build semaphore — one build at a time, one shared cache — so
this is one trace read and one log read every two seconds however many tabs are
open on it.
No progress fraction, still — and now by choice rather than by absence.
The strip first drew only the tasks Argo had CREATED, which grow as a build runs,
so boxes appeared one at a time and it could not say what was still to come.
project-flow now serves the WorkflowRun's spec.workflow.name — which was on the
object all along and served to nobody — and the console reads that
ClusterWorkflow's runTemplate, so the whole pipeline is drawn from the first
tick with the steps ahead dashed and dim.
The denominator therefore exists, and a bar is still the wrong drawing: "2 of 3"
says less than three named boxes with one lit, and it implies the steps are equal
when build-and-publish is most of a build and generate-workload-cr is seconds.
Two traps in resolving it. A WorkflowRun references fastapi-builder while the
object is fastapi-builder-v1, because constructs here are versioned and carry
their unversioned identity in the openchoreo.dev/name label — matching on
metadata.name finds nothing, silently, and an outline that never matches
renders exactly like a console that was never given one. And the outline is
optional throughout: the rail's live badge has no token and must not acquire a
platform-API read to draw a count, so without one argo_steps degrades to the
behaviour it had before.
Watched on a real build:
t+12s checkout-source:running build-and-publish:pending generate-workload-cr:pending
t+36s checkout-source:succeeded build-and-publish:running generate-workload-cr:pending
t+96s checkout-source:succeeded build-and-publish:succeeded generate-workload-cr:pending
with the Dagger stage inside build-and-publish moving independently across the
same window — audit, then test, then publish — which is the two layers doing
what each is for.
Two items in this file were stale, and are struck
NEXT-SESSION.md§2, "the cheapest real win left" — wiringsearch/component/minutesinto the project log page. The route already honours all four; only the link to it was broken (see the alert relay, above). Nothing was rebuilt.- The CI skill's note that Gitea does not enforce version immutability. True
of the rubygems registry it describes; not true of the PyPI one, which
answers
--allow-overwritewith Conflict.platform-telemetry0.6.2 was published and then reformatted, so rather than leave a wheel that could not be rebuilt from its own source the version moved on. 0.6.4 is published and verified byte-identical to the tree.
Found while building, and worth keeping
tempo._fetchdiscards the resource attributes and span kind that identify a network hop. It keeps exactly three —service.name,service.namespace,deployment.environment— and drops everything else includingtelemetry.sdk.name, along withspan.kind. So the console cannot currently tell a Beyla/eBPF span from an application SDK span, a gateway span is identifiable only by the string"gateway"happening to be its service name, and SERVER-vs-CLIENT is not in the span dict at all. Gateway tracing, Beyla and Hubble are all correctly configured and working; none of it is displayable. This is the substance of "verify network level tracing is included in what we display correctly linked and correlated" and it is the next thing to do.- The release-chain trace already exists end to end. One trace id —
sha256(run)[:32]— already spans project-flow's push span, the Argo build root, every Dagger stage, and project-flow'sdeploy/promotechildren, andbuilds.pipeline()already parses those deploy spans. No page renders it as a chain. The gaps are a later promotion falling outside the trace and Tempo's 48h window, not missing data.
Done — a trace reads as a network path, and its id survives being pasted
/traces/{id} was serving BUILD pages for real traces. Tempo's search API
returns trace ids with leading zeros stripped, and on this platform that is not
a curiosity: across 120 traces from the three producers, not one came back at
32 characters — 28 to 31, with the gateway's clustering at 31 and the SDKs' at
29. Verified against the bytes, since a count like that needs proof rather than
a theory: search returns d5f6a58d34279732276bed266c29 and the span inside that
trace carries AADV9qWNNCeXMidr7SZsKQ==, which is 16 bytes hexing to
0000d5f6a58d34279732276bed266c29.
looks_like_trace_id anchored on {32}, and the route redirects what it does
not recognise to /builds/{id} — reasonably, since a build run name is the
thing most often pasted in its place. So nearly every real trace id rendered a
build page under a trace's heading, reporting "0 spans" and "Outside
retention". Found by opening a gateway trace, not by a test.
Length is no longer what separates the two; a run name carries a dash and hex does not. Ids canonicalise to the padded form and the page 301s onto it.
tempo._fetch discarded what makes a span a network hop. It kept three
resource attributes — service.name, service.namespace,
deployment.environment — and dropped the rest including
telemetry.sdk.name, along with span.kind. So a Beyla span and an
application span were indistinguishable in the console, a gateway span was
identifiable only because "gateway" happens to be its service name, and
SERVER-vs-CLIENT was not in the span dict at all. Gateway tracing, Beyla and
Hubble were all configured, working, and undisplayable.
Both are kept now, offered as facets even when unanimous — a trace where every
span is beyla is telling you something — and the waterfall is coloured by hop.
Verified on a real 30-span trace: amber Envoy ingress and router, green
GET /projects/{name}/delivery from the console's SDK, blue for the 21 outbound
calls it made. inferred is drawn quietly and separately on purpose: Beyla sees
sockets and not intent, so drawing it like an SDK span would promise something
it cannot deliver.
What the trace cannot show is written under it rather than left to be rediscovered: the browser, an object-store hop, and context through an uninstrumented process.
Done — network tracing verified as a table
platform/dev/check-network-tracing.py, over four hours on this platform:
envoy 200 traces gateway only
opentelemetry 200 traces bakery-worker, chess-*, console, inkwell-*, ledger
beyla 200 traces brochure, storefront, zulip, zulip-redis, bakery-docs
hubble_drop_total present 2699 dropped flows in 4h
No service appears under more than one producer, which is the result that matters — the SDK and Beyla covering one process would double-count it, and three separate mechanisms exist to stop that. Beyla covers exactly the services with no SDK, including the two SPAs that carry no OTel JS.
A service with no coverage is printed and is not a fault: an idle service looks identical from here and this cannot tell them apart, so it says so.
Done — the release chain, in time
Most of it already existed and was invisible. One trace id already spans the push span, the build root, every stage and project-flow's deploy children. Two things stop it being one trace and both are real: a promotion made later is a different causal chain, and Tempo keeps forty-eight hours while the deploys are kept fourteen days and the release for ever.
So /pipelines/{run} gets a timeline of segments, each naming the trace it
belongs to. inkwell-enrich-run-d7480a7d reads: pushed, six minutes queued,
one minute built, staging, production — 21m end to end. The waits are drawn
to the same scale as the work, because on that chain the waiting is the story.
Its times come from Prometheus rather than the WorkflowRun, which Argo prunes
after a day — so for six days in seven the durable set is the only set, and this
page lost its whole middle without it. seconds is measured from admission and
already contains the queue, so queued_seconds is subtracted: 512s elapsed
is 415s waiting and 97s of work.
Done — pull requests get a page, and review environments stop being estate
/pull-requests and /pull-requests/{slug}~{n}, under Delivery rather than
beside it: a pull request is the part of delivery that is somebody's own work.
A review environment holds more than the pull request changed, which is the
finding that made the page honest. Verified on a real pull request rather than a
fixture — apps/ledger #1 produced an environment with nine components. One,
ledger-pr-1, was forked from the branch; the other eight are inkwell's, bound
from their own staging releases, because ledger is a component of the
inkwell project and project-flow forks only components in the same
repository. A fork from another repository would have no Workload and no push
to build it. Listed flat, that reads as a pull request that changed eight things
it never touched.
The count needed its cap. /host has review_envs_open and /state has
config.max_review_envs; "0 open" says opposite things at a cap of 1 and a cap
of 8. This host allows two, not the documented default of one.
Round 10 recorded the PR lane as designed-but-unverified because there were no open review environments to draw against. There is no longer an excuse: opening one is four API calls and it tears itself down on merge.
Verified on 2026-09-08 against a real pull request — apps/brochure #4, opened, watched, and closed. The page did its job. What it found was four things in the platform underneath it, none of them in the console:
| what | where |
|---|---|
two repositories declaring one Project, last push wins — five components stuck DeploymentPipelineNotFound for fifteen hours while the Project reported Ready |
apps/bakery/openchoreo/project.yaml; FINDINGS.md |
| the capacity line said "4.6 GB free" while the node was at 99% of CPU requests and nothing in the review environment could schedule | project-flow /host, pull_requests.html |
| the filtered delivery link filtered the timeline and not the current-state table, and the Order/Show controls dropped the filter on the first click | delivery.py, delivery.html |
| a binding created 52 seconds after its own teardown, orphaned for four hours, from a build in flight when the pull request closed | teardown_pr(); FINDINGS.md |
The parts that were right were right: the forked/inherited split reads exactly
as designed ("Built from this branch 1" against "Around it 4"), the pre-claimed
Undeploy binding kept the fork out of staging and rendered as hibernated, the
build was granted its semaphore at priority 0 and finished in 63s, and teardown
on close removed the environment, the fork, the cell and every binding.
Done — the rest of Round 11
Build QoS: the ceiling stays at one, and priority does the work. Measured before deciding, because per-project lanes do nothing while the global semaphore is 1:
dagger engine working set, over 6h 2.53 -> 6.43 GiB
node memory available, same 6h 6.13 -> 2.28 GiB
build POD peak, over 7d 0.29 GiB
The ranges are anti-correlated — the engine's peak is the host's trough. One
build already takes this host from 6.1 GiB free to 2.28, and the pod is not
where the memory goes. A second would not buy throughput either:
max-parallelism = 4 is the real limit and one Rails build fills it.
So lanes are inert here and deliberately not implemented, and priority is
the mechanism — the one that works at a ceiling of one. A main build renders at
100 and a pull-request fork at 0, decided by CEL on the component name.
runTemplate turns out to be CEL rather than string interpolation, and when the
expression is the whole value the engine returns its native type, so priority
stays an integer. It must be quoted in YAML: the ternary contains ": ".
Verified with a discriminating test — the PR build was created first, so creation-time ordering would have granted it the lock:
console-run-2c91dada p=100 Succeeded
console-run-abc37722 p=100 Running <- granted
ledger-pr-2-run-c50f81ea p=0 Pending
Both console call sites carried a comment asserting "every build here has the default priority", which had become false, and both sorted the queue by creation time and called the result the position.
Trace chains are searchable. /traces — nine named interactions, each one
run against this Tempo before it was written down and each recording what came
back, so a chip that stops matching reads as a change rather than as a quiet
afternoon. Plus a TraceQL box for the question the list does not cover. Every
operation row on /transactions now links to the chains behind it.
A hyphen in an environment name was 400ing every estate-wide query.
namespace_pattern used Python's re.escape, which escapes - as \-;
Prometheus matches with RE2, which rejects that as an invalid escape.
development, production and staging have no hyphens, so this was invisible
until the first review-pr-<n> existed — and then outlasted it, because
Prometheus keeps the label for the window's length. The test covering that
function asserted the bug: it required my\-app in the output.
A runtime span gets a flame graph, and the note that said it could not was
half right. It has no build run; it does have a span name, and Pyroscope's
labels here are service_name and span_name. The same note claimed span_id
was "reachable" as a per-sample label — it is not among this Pyroscope's label
names, so the page now says what is actually drawn: the service's profile over
the span's window, not the span's own samples.
The manifest diff came to the release list, lazily, and three of its answers stopped lying — see the entry above.
Object storage: no, and STORAGE.md is the evaluation. The largest blob stored is 5.6 kB against a 1 MB cap and the whole database is 8.6 MB; what is actually scarce is the host disk at 92%, which object storage on the same disk would not help. The real finding is an asymmetry in retention rather than size.