Docs · Glidepath
ADR-0022: CDEvents conformance and the Glidepath event vocabulary
Status: Proposed (2026-10-06). This ADR changes no code. It records what the current
events do against the CDEvents specification, which events a rollout deployment should
generate, and a staged way to close the gaps. Checked against cdevents/spec v0.5.1
(the latest tag; v0.5.0 differs only in the specversion string) by reading the spec
text and the published JSON schemas, not from memory.
Context
Glidepath chains stages with CDEvents (ADR-0002).
Two outside reviews (chatgpt-cdevents.md, chatgpt-cdevents-broker.md) raised that the
envelope is behind the spec and that Tower could consume the stream. This ADR checks both
against the real spec and adds what they missed.
The architecture is sound. Events are used as declarative facts (“an artifact was published”) and the Trigger CEL decides what to do with them, which is the adapter pattern the CDEvents primer describes. Nothing here argues for changing that.
Nothing consumes these events through a CDEvents-aware tool today. The only consumers
are our own Tekton Triggers, which match on context.type, context.source, chainId and
a handful of fields. That is why the gaps below have cost nothing yet, and why they are
worth closing only as far as they are cheap.
What the current events do against the spec
| # | Spec (v0.5.1) | Today | Effect |
|---|---|---|---|
| 1 | context.specversion is required |
We emit context.version: "0.4.1" |
Not a valid 0.5.x envelope |
| 2 | v0.4.1 spells the chain field chain_id; 0.5.x spells it chainId |
We emit chainId with version: "0.4.1" |
A hybrid that validates against neither version |
| 3 | subject.type was removed in 0.5; the subject schema is closed |
We emit subject.type |
Rejected by a 0.5.x schema |
| 4 | subject.id identifies the subject, and later events about the same subject must reuse it |
For artifact, service, change and testCaseRun events it is the emitting PipelineRun’s name |
Two deployments of one service get different subject ids |
| 5 | subject.content is a closed schema per event (additionalProperties: false) |
Standard events carry extra fields: artifact.published has image, revision, git-url, env (the schema allows only sbom, user); service.deployed has git-url, revision; testcaserun.finished has image, revision, git-url, env, test-name; change.created has image, revision, git-url |
Rejected by any schema-validating consumer |
| 6 | Required fields | pipelinerun.started lacks uri; testcaserun.finished lacks environment; service.deployed.artifactId is a bare image ref, the spec asks for a Purl |
Incomplete |
| 7 | testcaserun.finished.outcome is one of success, failure, cancel, error |
test.yaml passes $(tasks.status) (Tekton’s words) into it; whether it is mapped first is unchecked |
Possibly invalid |
| 8 | change.created is 0.4.0 |
We emit 0.3.0 |
Wrong event version |
| 9 | The dev.cdevents. namespace is reserved for events the spec defines; extensions use dev.cdeventsx.<tool>-<subject>.<predicate> |
environment.deploying.0.1.0 and environment.deployed.0.1.0 are ours, under dev.cdevents., with a made-up version |
Collides with the reserved namespace. The spec has no event named either. |
| 10 | links and chainId are optional |
chainId yes, links never |
Causality is implicit in our own conventions |
pipelinerun.started/finished.0.3.0, artifact.published.0.3.0, service.deployed.0.3.0
and testcaserun.finished.0.3.0 are the right event types at the right versions.
Correction to the outside review: it is right about 1 to 3 and 10, but it misses 4 to 9,
which matter more for anyone validating events than the version string does. It also treats
service.deployed as the answer for rollouts; see decision 4.
customData and the cicd.yaml blob
customData.platform carries traceparent, flow_start_time, chain_slug and
config_json. The spec allows any customData, and the first three are platform
metadata, which is what it is for. config_json is different: it is the whole cicd.yaml,
carried on every event so a later stage can skip cloning the repo. That is state
transport, not event meaning. It makes every event large, copies an application’s config
into every consumer and any future store, and ties each stage to whatever config the first
stage saw. The release outcome events add a second copy as subject.content.configJson.
The outside review proposes the same fix this ADR does: keep the event thin and hold the
state under chainId. ADR-0021 already builds
that for releases (the ReleaseRecord, keyed by chain-id, on dev); this extends the idea to
the flow’s config.
Decision (proposed)
-
Target envelope: CDEvents 0.5.1.
context.specversion: "0.5.1", nosubject.type,chainIdas already spelled, event types at the versions the spec lists. Do not bump event-type versions wholesale; they are per-event (change.createdis0.4.0, most are0.3.0). -
Content discipline. A standard event’s
subject.contentcarries only the fields the spec defines for that event. Everything Glidepath needs beyond that (image, revision, git url, env, test name, app namespace, release id, flow start time, outcome detail) goes incustomData.platform. Fill the spec’s required fields:urion pipeline runs,environmenton test and service events,artifactIdas a Purl (pkg:oci/<app>@sha256:<digest>?repository_url=<registry/repo>),outcomemapped to the spec’s four values. -
Subject ids identify the subject. Artifact: its Purl. Service:
service/<app>. Environment:{id: "<cluster>/<env>"}. Pipeline run: the run name (already correct). The emitting run’s name moves tocustomData.platformwhere it is still needed. -
Events for a rollout deployment. The spec has no “deployment started” and no “deployment failed” event:
service.*events describe a successful state change, and an unsuccessful deployment is reported as a finished pipeline run withoutcomeanderrors. A rollout is therefore modelled as a pipeline run namedrollout/<app>/<cluster>/<env>(subject id: therelease-idfrom ADR-0021), plus the service events for what it changed:Moment Event Release PR opened change.created.0.4.0(content:repository,description)Release PR merged change.merged.0.3.0(optional; the Tower poller or a GitHub webhook can emit it; it is the approval anchor for lead time)Rollout begins ( Progressing)pipelinerun.started.0.3.0Canary step, pause, analysis None. These stay in the ReleaseRecord and the trace; no spec event fits and a custom one would be noise. Rollout Healthy, first release of this app in this environmentservice.deployed.0.3.0andpipelinerun.finishedwithoutcome: successRollout Healthy, a later releaseservice.upgraded.0.3.0andpipelinerun.finishedwithoutcome: successAbort, Degraded, analysis failurepipelinerun.finishedwithoutcome: failure(orcancelfor a manual abort) anderrors. No service event: the spec has none for a failed deploy.Rollback release Healthyservice.rolledback.0.3.0andpipelinerun.finishedObserved release-id matches no open release (drift) No spec event. Optional dev.cdeventsx.glidepath-release.drifted.0.1.0; decide when drift alerting is built.SLO breach and recovery (Tower’s SLO notifications) incident.detected.0.3.0andincident.resolved.0.3.0(continuous operations), which is the spec-native feed for MTTREnvironment added or removed (Tower Environments tab) environment.created.0.3.0,environment.modified.0.3.0,environment.deleted.0.3.0service.deployedversusservice.upgradedis decided from the ReleaseRecord: whether an earlier release of this app is recorded Healthy in this environment. This replacesenvironment.deployingandenvironment.deployed, which are retired once their consumers (release-progress-trigger,release-outcome-trigger) read the new events. -
Links, cheaply, later. Each stage’s events carry
links: [{linkType: RELATION, linkKind: TRIGGER, target: {contextId: <triggering event id>}}]. The triggering event’s id is alreadybody.context.idin the Trigger. The first event of a chain has no link, the last carriesEND. We build no links service: the point is that an external tool (or Tower, if it ever wants this) could. Low value until a consumer exists. -
The
cicd.yamlblob leaves the events. AFlowRecord(a ConfigMap keyed by chain-id, written once at flow start, immutable, TTL-swept) holds the config and flow start time;preflight(thenresolve-notify-config) reads it by chain-id instead of from the event. This is the same record the ReleaseRecord extends, so there is one record per chain, not two. A missing record needs a defined fallback before this ships: the stage re-readscicd.yamlfrom git, which is slower but correct. -
Not now: an event store, a NATS or Postgres gateway, or Tower as a CDEvents consumer. The outside review proposes a persistent gateway and fan-out. We have one consumer (Tekton Triggers) and one other reader (Tower), and Tower already reads PipelineRuns (with Tekton Results) and Rollouts directly, plus the ReleaseRecord for releases. A durable store is a new stateful component for no current need. The terminal ReleaseRecord is written to the release log (Loki) and to git; writing it in CDEvents shape (
service.upgradedplus links) is free and gives any later consumer the history without a new store. Revisit when a second consumer needs replay, or when Autopilot wants to subscribe rather than poll. (2026-10-30: Tower no longer reads PipelineRuns or Results itself; since Glidepath API phase 6 it reads them through glidepath-api (api.md). It still reads Rollouts directly.) -
A conformance test. A CI job renders each event the catalog emits (from fixtures) and validates it against the pinned CDEvents JSON schemas. It starts with an explicit list of expected failures (rows 1 to 9 above) and shrinks as each is fixed, so the gap is measured and cannot grow.
Consequences
- Closing rows 1 to 8 is mechanical and local to
cdevents.sh, the three event builders (cdevent_send, the hook script, the relay’s fact path) andflow-triggers.yaml, which reads a handful of non-spec content fields (revision,git-url,env, and on the outcome pathappNamespace,gitUrl). Moving those fields tocustomData.platformneeds the bindings to read both places during the change (has()), then drop the old one. - Row 9 is the only one that changes meaning: consumers of
environment.deployedandenvironment.deploying(release-outcome-trigger,release-progress-trigger,release-outcome-notify, the DORA path) move topipelinerun.finishedandservice.upgraded. It is sequenced after ADR-0021 phase 3, when the hook path that builds those events is deleted, so the migration happens once. - ADR-0021’s relay emits the legacy
environment.*events in phase 1 on purpose, to keep both paths comparable. Its new events (service.rolledback, and the rollout pipeline run) follow this ADR from the start. - Modelling a rollout as a pipeline run is a judgment call the spec does not state. The
alternative is a custom
dev.cdeventsx.glidepath-rollout.*family. It is rejected here because the spec says custom events should be a last resort andpipelineRunis defined as an execution of a delivery process, which a rollout is. Revisit if a consumer finds it confusing. - Fully validating consumers do not exist yet, so none of this is urgent. The reason to do it anyway is that the work is small now and grows with every new event and consumer.
Build order
| Phase | What | Verifiable by |
|---|---|---|
| 0 | Conformance test with the expected-failure list (decision 8) | CI shows the gap per row |
| 1 | Envelope, content, subject ids, required fields, the outcome mapping, change.created version; triggers read both places |
Failure list shrinks to row 9 and the blob; a full build-to-release flow still chains |
| 2 | FlowRecord; config_json and configJson leave the events |
An event is a fraction of today’s size; a flow still chains with a missing record |
| 3 | Links | Events validate with links; a chain can be walked by id |
| 4 | Retire environment.deploying and environment.deployed in favour of decision 4’s events (after ADR-0021 phase 3) |
Release outcome Slack, DORA and the release log unchanged on a live release |
| 5 | Incident and environment events from Tower | incident.detected on an SLO transition |
Not decided here
- Whether to publish our event shapes in the CDEvents custom-event registry. Only worth it
if we keep any
dev.cdeventsxevents. - Whether
change.mergedis emitted by a webhook, by the Tower poller, or not at all. - Whether the event
sourceshould be one stable value per cluster instead of one per pipeline run. The spec allows both; per-run is more specific and is kept until a consumer asks.