ADR-014: Stable UIDs as the dashboard idempotency key
Status: Accepted Date: 2026-04-21
Context
Grafana identifies a dashboard by its uid — a short stable slug that, together with the organisation, uniquely names the dashboard. A POST to Grafana's dashboards API with an existing uid updates that dashboard in place; a POST with a new uid creates a new one.
Two workflows need this to be boring:
- The scaffold CLI (
gyrum-dashboard-scaffold <service> --sync) regenerates Tier-0 dashboards into a service'sobservability/grafana/<service>/directory. If it writes a newuidon each run, Grafana grows duplicates. If it writes the sameuid, Grafana updates in place. - Phase 3 deploy-stage provisioning POSTs the instantiated JSON to Grafana on each deploy. Same problem at a different layer — the idempotency is the UID.
A failure mode to avoid: the engineer hand-edits a panel title in Grafana's UI, the next deploy overwrites the edit. This is acceptable if the dashboard file in source control is the source of truth (and the edit is noise). It is unacceptable if the dashboard file UID has drifted and the deploy creates a duplicate while the edit survives — now there are two dashboards, one is in code, one is in Grafana, and nobody can tell which a given link points to.
Decision
Every dashboard — service template and platform dashboard — has a stable UID committed in the JSON file. The UID never changes once it's merged.
Naming scheme:
- Service templates:
service-<type>-TEMPLATEin the file (e.g.service-overview-TEMPLATE,service-errors-TEMPLATE). TheTEMPLATEsuffix is a tombstone that proves substitution happened. The scaffold CLI rewrites the suffix:service-overview-TEMPLATE→service-overview-<service>, e.g.service-overview-distill. - Platform dashboards:
platform-<type>(e.g.platform-overview,platform-redaction-events). No suffix, no substitution — one copy fleet-wide.
Rules:
- UIDs are lowercase kebab-case, end-anchored with the service slug for Tier-0 and the platform-type slug for Tier-1.
- A service slug's character set matches
[a-z0-9-]. Services with underscores in their name get them translated to hyphens at scaffold time. - Renaming a dashboard's UID is a deliberate migration, not a drift: the old UID must be explicitly deleted from Grafana or it will double up. The scaffold CLI does not attempt to clean up renamed UIDs; that is a manual operation and an ADR moment.
- The
TEMPLATEsuffix appears in exactly one place — the pre-substitution template file. Post-substitution, no dashboard anywhere contains the stringTEMPLATE.
Consequences
- Idempotent regeneration. Running
gyrum-dashboard-scaffold distill --drill-key video_ida second time produces byte-identical output to the first run (modulo${GENERATED_AT}). Running--syncafter a template update updates the output file at the same path, same UID. - Grafana updates happen in place. Phase 3's deploy POST to Grafana updates the dashboard with the matching UID. No duplicates, no "which of these is the real one?" on-call confusion.
- UID collisions are detectable. Two templates with the same UID would fail
jq -s 'group_by(.uid) | map(select(length > 1))'across the templates directory. A trivial CI check. - Grafana UI edits are transient. A hand-edit made in the Grafana UI against a scaffolded dashboard will be overwritten by the next
--syncand subsequent deploy. This is the correct behaviour — JSON in source control is the source of truth — but it must be communicated. The scaffolded dashboard carries a__commentfield stating the file it was generated from and warning against hand-editing. - URL stability. Dashboard URLs in Grafana are
/d/<uid>/<slug>. A stable UID means Slack/wiki/docs links to a dashboard don't rot across scaffold re-runs or service renames (as long as the service itself is not renamed). - Renames are explicit migrations. Renaming
service-overviewtoservice-health(if we ever did) is a multi-step change: update the UID in the template, update every link, delete the old UID from Grafana. This friction is a feature — URL-rot from silent renames is the failure this ADR prevents. - Services with hyphen-containing slugs just work.
distill-gyrum-ai→service-overview-distill-gyrum-ai. Long but fine; Grafana accepts UIDs up to 40 chars.
Alternatives considered
- Let Grafana auto-generate UIDs on first POST. What Grafana does by default in the UI. Rejected: we lose idempotency — every fresh deploy creates a new dashboard with a new UID and orphans the old one. The cleanup story would be "reconcile by title", which is exactly the brittleness this ADR avoids.
- UID = hash of the JSON content. Forces the UID to change when the dashboard changes, which (a) defeats the purpose — every small edit creates a new dashboard — and (b) makes URL stability impossible. Rejected.
- UID = service + generated timestamp. Preserves service scoping but burns the idempotency. Rejected.
- UID = service + dashboard role + version. E.g.,
distill-overview-v2. Would support parallel dashboards during a transition. Rejected as over-engineered: we have no versioned-migration use case today, and adding the version segment leaves every dashboard with a-v1tail forever. Keep it simple; revisit when we have a concrete v2 migration to run.
Supersedes: none Superseded by: