Skip to content

Tagging Standard

This is the umbrella standard for structured metadata in pitlab: every system that supports tags, labels, or inventory fields carries values drawn from a controlled, low-cardinality, single-source-of-truth vocabulary, and adding a value is a deliberate one-line edit to that source file — never an ad-hoc string. It exists because RULE 10 names a “tagging standard” as authoritative but only docs front-matter was ever written down; every other metadata surface (Loki, Prometheus, Zabbix, Terraform, Ansible, ADO, Grafana, container labels) was implicit, drifting, or absent. This standard makes RULE 10 checkable by binding each surface to its vocabulary file and its required keys. The rationale and per-surface state live in 0077-tagging-standard-unified-vocabulary; it is the cross-surface companion to the documentation-standard tagging section and the logging-standard Loki taxonomy that already partially implement it.

Every metadata surface has one source-of-truth vocabulary file

Section titled “Every metadata surface has one source-of-truth vocabulary file”

A tag value is only governed if there is exactly one file that defines the allowed set and one place edits happen. Scattering the same vocabulary across multiple configs guarantees drift — the same role spelled app in one place and apps in another retrieves and groups as two things. Each surface below names its source-of-truth file; that file is authoritative and every consumer implements it, never the reverse.

Metadata surfaceSource-of-truth fileRequired keys
Docs front-mattermkdocs.yml extra.tag_vocabularyone kind + one or more domain; draft while in _drafts/
Loki stream labelsansible/standards/loki-labels.yml (v1.4)env, job, host, group, device_type; compose_project on job=docker
Prometheus metric labelsdocker-stacks/stacks/observability/templates/alloy/config.alloyjob, host, group, env, device_type; blackbox: service, domain
Container labels(target) compose labels per stack — currently a gap, see belowservice, domain, device_type as com.pitbun.* labels
Zabbix host/item/triggerhost inventory fields + host groups + event tagsinventory populated; group Linux-Ansible-LXC/VM/Physical; tags e.g. pipeline_id, kb
Terraform resources(target) provider tags/labelscurrently zero, see belowmanaged-by=terraform, owner, env=homelab, service
Ansible inventory varsansible/inventory/hosts.yml (values governed by loki-labels.yml)alloy_device_type, zabbix_agent_hostgroup
ADO work itemsthe System.Tags closed vocabularyEpics: one domain tag (smart-home/pitlab) + at most one lifecycle tag (groomed/grilled/hypercare/done); children inherit and carry none
Grafana dashboardsper metrics-dashboards-standarddashboard tags incl. service + domain
implemented bymirrored bygoverns valuesdeploysbuild-hook enforcedservice,domainansible/standards/loki-labels.yml(authoritative taxonomyv1.4)config.alloyLoki stream labelsconfig.alloyPrometheus metric labelsAnsible inventory varsalloy_device_type /zabbix_agent_hostgroupZabbix host groups +inventorymkdocs.ymlextra.tag_vocabularydocs front-matterkind+domainGrafana dashboardsprobe_success{service,domain}Container labels(regex in config.alloytech debt)Terraform resources(zero tags today)
implemented bymirrored bygoverns valuesdeploysbuild-hook enforcedservice,domainansible/standards/loki-labels.yml(authoritative taxonomyv1.4)config.alloyLoki stream labelsconfig.alloyPrometheus metric labelsAnsible inventory varsalloy_device_type /zabbix_agent_hostgroupZabbix host groups +inventorymkdocs.ymlextra.tag_vocabularydocs front-matterkind+domainGrafana dashboardsprobe_success{service,domain}Container labels(regex in config.alloytech debt)Terraform resources(zero tags today)

Low cardinality is the rule — high-cardinality values are not labels

Section titled “Low cardinality is the rule — high-cardinality values are not labels”

A tag/label value is an index key. In Loki each unique stream-label combination is a separate stream; in Prometheus each unique label set is a separate time series; the same blow-up applies to any indexed metadata. A high-cardinality value — a request ID, timestamp, user, full URL, container instance ID — multiplies the index and degrades every query. The rule of thumb from loki-labels.yml: if adding a label would multiply stream/series count by more than ~10×, it does not belong as a label. Put it in structured metadata (Loki), a metric value or exemplar (Prometheus), or an event field — out of the index key. Tags answer “which class of thing”; they never carry per-event identity.

Adding a vocabulary value is a deliberate edit, not a free-form string

Section titled “Adding a vocabulary value is a deliberate edit, not a free-form string”

The guardrail against tag sprawl is that no surface accepts an arbitrary new value inline. A new device_type, a new docs domain, a new Zabbix host group is added by editing the source-of-truth file for that surface — one reviewable line — and then implementations pick it up. This is why docs tags fail the build off-vocabulary (the mkdocs.yml build hook, documentation-standard) and why a new Loki label value is a commit to loki-labels.yml, not a one-off in an Alloy block. The edit is the audit trail: it is where the reviewer asks “do we actually need a new class, or does an existing one fit?” Inventing a value at the call site bypasses that question and is how app vs apps drift starts.

If a surface cannot be tagged programmatically, flag it and raise an ADO item

Section titled “If a surface cannot be tagged programmatically, flag it and raise an ADO item”

RULE 10 forbids silently skipping a tagging opportunity. Where a system supports metadata natively (Zabbix host inventory, Grafana dashboard tags, ADO System.Tags, Terraform provider tags), it MUST be populated as standard practice, not optionally. Where a value genuinely cannot be applied via code today — an API limitation, a provider with no tag field, a surface with no code path — the gap is named explicitly in the work and an ADO item is raised to track it. A flagged gap is acceptable; a silent skip is not.

Docs front-matter tags come from the mkdocs vocabulary, build-hook enforced

Section titled “Docs front-matter tags come from the mkdocs vocabulary, build-hook enforced”

Every governed page in pitlab-docs carries exactly one kind tag (reference, runbook, adr, standard, policy, overview, project, generated) and one or more domain tags, all drawn from mkdocs.yml extra.tag_vocabulary, plus draft while in _drafts/. The build hook (ADR-0035) fails the build on any off-vocabulary or non-lowercase tag. This is the one tagging surface that already has full enforcement and is the model the others aim at. Full detail in the documentation-standard.

Loki stream labels come from the authoritative loki-labels.yml taxonomy

Section titled “Loki stream labels come from the authoritative loki-labels.yml taxonomy”

Every log stream carries the label set defined in ansible/standards/loki-labels.yml (v1.4, the authoritative taxonomy): env, job, host, group, device_type on every stream, plus compose_project on job=docker streams as the per-service join key. Values are governed by that file — a new device_type or job is a commit to it, not an ad-hoc Alloy string. Adding a label must keep cardinality low; high-cardinality fields go in structured metadata. Full detail and the ingestion paths in the logging-standard.

Prometheus metric labels mirror the Loki taxonomy; blackbox probes carry service and domain

Section titled “Prometheus metric labels mirror the Loki taxonomy; blackbox probes carry service and domain”

Metric labels mirror the Loki axes — job, host, group, env, device_type — configured in docker-stacks/stacks/observability/templates/alloy/config.alloy, so logs and metrics share query axes and join cleanly. Blackbox probes additionally carry service and domain: the probe_success{service,domain} scheme is the backbone of the metrics-dashboards-standard (ADR-0045) — every service registers a probe target and surfaces in the dynamic rollup on those two labels. Known drift: the job label is spelled with a hyphen on some sources and an underscore on others between the Loki and Prometheus paths; normalise on use and align when next touching config.alloy.

Container labels are the standing gap — device_type is hardcoded regex in config.alloy

Section titled “Container labels are the standing gap — device_type is hardcoded regex in config.alloy”

Today containers carry no governed metadata labels of their own. device_type for the ~48 docker01 containers is assigned by container_name regex allowlists inside docker-stacks/stacks/observability/templates/alloy/config.alloy (the docker_labels relabel rules, ADO #453). This is fragile and already stale — e.g. tandoor entries that no longer match cleanly — because classification lives in the observability config, not on the container it describes. Target state: each container declares service, domain, and device_type as compose labels (com.pitbun.service, com.pitbun.domain, com.pitbun.device_type) in its stack, and Alloy relabels from those labels instead of from a name regex. Migrating off the regex coupling is tracked as follow-on work in 0077-tagging-standard-unified-vocabulary; until then the regex allowlist is the source of truth and edits go there.

Zabbix carries tags as host groups, populated inventory fields, and event tags

Section titled “Zabbix carries tags as host groups, populated inventory fields, and event tags”

Zabbix metadata lives on three surfaces, all mandated: host groups classify by tier (Linux-Ansible-LXC, Linux-Ansible-VM, Linux-Ansible-Physical), driven from the Ansible zabbix_agent_hostgroup inventory var; host inventory fields (native structured fields) MUST be populated, not left blank — RULE 10 requires native metadata fields be filled where a system offers them; and item/trigger event tags (e.g. pipeline_id, kb) carry correlation and runbook keys onto events. A Zabbix host with empty inventory fields is non-conforming.

Terraform resources are the second standing gap — zero tags today across all four repos

Section titled “Terraform resources are the second standing gap — zero tags today across all four repos”

No resource in tf-proxmox, tf-azure, tf-backblaze, or tf-cloudflare carries any tags today. Target state: every resource whose provider supports tags/labels carries a common set — managed-by=terraform, owner, env=homelab, and service where meaningful — so cloud and infra resources are attributable and queryable the same way logs and metrics are. Backfilling this tag set across the four repos is follow-on work tracked in 0077-tagging-standard-unified-vocabulary. Detail and module conventions belong in iac-deployment-standard.

Ansible inventory vars are the upstream source for fleet tag values

Section titled “Ansible inventory vars are the upstream source for fleet tag values”

The fleet’s tag values originate in ansible/inventory/hosts.yml: alloy_device_type sets a host’s Loki/Prometheus device_type, and zabbix_agent_hostgroup sets its Zabbix host group. These vars are governed by ansible/standards/loki-labels.yml — an inventory alloy_device_type must be a value defined in the taxonomy, not a new string. Setting these correctly in inventory is what makes the downstream Loki, Prometheus, and Zabbix tags consistent for a host, because they all flow from the same inventory fact.

ADO Epics carry exactly one domain tag plus at most one lifecycle tag — six values, two closed axes

Section titled “ADO Epics carry exactly one domain tag plus at most one lifecycle tag — six values, two closed axes”

System.Tags on an Epic is a closed vocabulary of six values across two orthogonal axes. Anything else on an Epic is a violation to strip.

AxisValuesCardinalityMeaning
Lifecyclegroomed · grilled · hypercare · donezero or onewhat stage the Epic is at
Domainsmart-home · pitlabexactly onewhich discipline the outcome serves

The lifecycle axis was closed by 0399-epic-tag-vocabulary-closed-to-lifecycle-tags after free-form topic tags grew without bound — one new tag string per subject, minted at will. The domain axis was added by 0469-epic-domain-tag-smart-home-vs-pitlab and is closed the same way: two values, fixed ceiling, conformance-checkable. A closed axis is not a topic tag; the prohibition ADR-0399 established is on unbounded vocabulary, not on structured metadata as such.

Domain is decided by SUBJECT, not by discipline. If the outcome lands in the house it is smart-home, however much pipeline, monitoring and infrastructure work delivered it — domestic hot water monitoring is an SLO with Prometheus rules and a Grafana dashboard, and it is smart-home, because what it delivers is hot water somebody can rely on. Classifying by the skill exercised instead was tried and rejected: it put two Epics of 110 in smart-home and produced no usable split.

No dated tag, ever. A hypercare review date lives in the Epic title as a trailing (HC DD-MM-YY), because a dated tag mints one single-use string per delivery — the exact unbounded growth both ADRs exist to prevent.

Titles are brief and punchy — no [Claude] prefix, no — YYYY-MM-DD stamp, no status commentary.

Issues and Tasks carry no domain tag — they inherit it from their parent Epic

Section titled “Issues and Tasks carry no domain tag — they inherit it from their parent Epic”

The domain axis is Epic-level only. A child Issue or Task belongs to exactly one Epic and takes its domain from that parent, so tagging children would add one tag per work item for no new information and reintroduce precisely the sprawl the closed vocabulary constrains.

Two exceptions to the “no tags on children” rule are conventions rather than vocabulary: the [SNAG LIST] title prefix that marks an Epic’s single findings Issue, and the [std-gap] prefix on children of the standards gap register. Both live in the title, not in System.Tags.

The tag vocabulary must survive the GitHub migration, which is why it is tags and not Area Path

Section titled “The tag vocabulary must survive the GitHub migration, which is why it is tags and not Area Path”

ADO Area Path is the textbook field for a domain split — hierarchical, first-class, filterable, and it would leave the tag vocabulary untouched. It was rejected in 0469-epic-domain-tag-smart-home-vs-pitlab for one reason: GitHub Issues have flat labels and no area-path concept, so an Area Path is discarded at the migration boundary while a tag maps one-to-one onto a GitHub label.

The general rule this sets for every metadata surface in this estate while the migration is in flight: prefer the representation that crosses the boundary. A cleaner mechanism in the system being left is not the better mechanism.

Grafana dashboard tags follow the Dashboard Standard

Section titled “Grafana dashboard tags follow the Dashboard Standard”

Every per-service dashboard carries tags including its service and domain, authored as code in the committed dashboard JSON, so dashboards are discoverable by service and tier and the dynamic rollup can assemble them. Governed by the metrics-dashboards-standard (ADR-0045).

Conformance checklist for tagging a new resource

Section titled “Conformance checklist for tagging a new resource”

A new resource — service, container, host, dashboard, doc, or cloud resource — conforms when all applicable items hold:

  • Every tag/label value comes from the surface’s source-of-truth vocabulary file (table above) — no inline ad-hoc strings.
  • All required keys for the surface are present (e.g. env/job/host/group/device_type for a Loki stream; service/domain for a blackbox probe and dashboard).
  • Values are low-cardinality — no per-event identity (request ID, timestamp, user) used as a tag/label; high-cardinality data is in structured metadata, not the index key.
  • Any new vocabulary value was added as a deliberate edit to the source-of-truth file (committed), not invented at the call site.
  • Native metadata fields are populated, not left blank — Zabbix host inventory, Grafana tags, ADO System.Tags, Terraform provider tags where supported.
  • Every Epic carries exactly one domain tagsmart-home or pitlab, decided by what the outcome serves. A card with none is as much a violation as one carrying a stray topic tag.
  • If a surface cannot be tagged programmatically today, the gap is flagged explicitly in the work and an ADO item is raised (RULE 10 — never silently skip).
  • Tagging was applied as part of the change, in the same commit as the resource — not deferred to a follow-up.

The machine floor of this standard is two deploy-time gates: docs front-matter tags are gated against the mkdocs.yml vocabulary, and the blackbox service+domain probe registration is gated by the dashboard-conformance check. Every other surface — Loki/Prometheus label taxonomy, Zabbix/Grafana/ADO/inventory metadata, and the cross-cutting single-source-of-truth, low-cardinality, native-field-population, gap-flagging, and same-commit obligations — has no standing machine validator that resolves shipped values against a vocabulary, so those clauses fall to named /wrapup conformance checklist items and /code-review dimensions. The container-label and Terraform-tag rows are declared standing gaps with no current pass/fail signal and are advisory until their backfill lands. No obligation degrades silently on a time axis, so every Dead-man is n/a.

ObligationClassLayerMechanismDead-man
Docs front-matter carries exactly one kind + ≥1 domain from mkdocs.yml extra.tag_vocabulary, lowercase-kebab, no off-vocabulary valuemachinedeployvalidate_doc_tags.py (wired into pitlab-docs/.githooks/pre-push, runs over changed docs/**/*.md, exits 1 on a missing kind or off-vocabulary tag); the wikilinks MkDocs build hook over the full assembled tree is the authoritative CI floor it mirrors byte-for-byte (ADR-0035/0054)n/a
Every service is registered as a probe_success{service,domain} blackbox target and blackbox probes carry service+domain (Prometheus required keys)machinedeploycheck_dashboard_conformance.py (docker-stacks-observability Validate stage, gates Deploy) parses the Alloy probe inventory for service=/domain= and fails an un-probed or un-dashboarded servicen/a
Loki stream labels carry the required keys (env/job/host/group/device_type, plus compose_project on job=docker) and conform to the loki-labels.yml taxonomyreview/wrapup logging conformance checklist label line + /code-review of loki-labels.yml/Alloy edits (no standing validator resolves shipped stream labels against the taxonomy — per logging-standard Enforcement)n/a
Prometheus metric labels mirror the Loki axes (job/host/group/env/device_type) in config.alloyreview/code-review of the config.alloy relabel rules (no gate validates emitted metric labels against the taxonomy; the known hyphen/underscore job drift is a review catch)n/a
Grafana dashboard tags include service and domainreview/wrapup Pillar-2 dashboard conformance checklist + /code-review of the dashboard JSON (the dashboard gate checks UID/probe registration, not tag contents — per dashboard-standard Enforcement)n/a
Zabbix carries host groups (Linux-Ansible-LXC/VM/Physical), populated host-inventory fields (not left blank), and item/trigger event tagsreview/wrapup Pillar-1 monitoring conformance + /code-review of monitoring_config.yml / inventory zabbix_agent_hostgroup; the .api.ping service tags are auto-converged by zabbix_tag_api_ping.py but no gate fails an empty inventory fieldn/a
Ansible inventory vars (alloy_device_type, zabbix_agent_hostgroup) draw from the loki-labels.yml taxonomy — a defined value, not a new stringreview/code-review of hosts.yml/group_vars edits (no validator resolves inventory values against loki-labels.yml)n/a
ADO work items carry a controlled System.Tags set ([Claude]/[Idea] + topic tags), applied at creationreview/wrapup RULE-2 ADO tracking reconciliation (ADO tags are not a repo diff; applied by the agent / /idea skill at creation, per ado-work-item-standard)n/a
Every tag value is drawn from the surface’s single source-of-truth vocabulary file, and a new value is a committed edit to that file — never an inline ad-hoc stringreview/code-review of the source-of-truth-file edit per surface + /wrapup tagging checklist; the one fully machine-enforced instance is docs (row 1)n/a
Tag/label values are low-cardinality — no per-event identity (request ID, timestamp, user, instance ID) used as a tag/labelreview/code-review label-cardinality dimension + /wrapup tagging checklist (no standing cardinality gate resolves shipped values)n/a
Native metadata fields are populated where the system offers them, not left blank (Zabbix inventory, Grafana tags, ADO Tags, Terraform tags)review/wrapup Pillar conformance checklist + /code-review (RULE 10 — native fields filled as standard practice)n/a
A surface that cannot be tagged programmatically today has its gap flagged explicitly and an ADO item raised — never a silent skipreview/wrapup RULE-10 tagging-gap / best-practice check + /code-review; the gap is named in-work and an ADO item createdn/a
Tagging is applied as part of the change, in the same commit as the resource — not deferredreview/code-review of the resource commit (tags present in the same diff) + /wrapup atomic-change checklistn/a
Container labels — each container declares service/domain/device_type as com.pitbun.* compose labelsadvisoryjustification: declared standing gap — current source of truth is the config.alloy name-regex allowlist (ADO #453); the compose-label target state is follow-on work with no pass/fail signal todayn/a
Terraform resources carry managed-by=terraform/owner/env=homelab/service tagsadvisoryjustification: declared standing gap — zero tags across all four TF repos today; backfill is tracked follow-on work in 0077-tagging-standard-unified-vocabulary with no current pass/fail signaln/a