Hamburger Cross Icon
Backstage Guardrails - Lunar Policy for Repository And Ownership

Backstage Guardrails

✓ Policy Beta Repository And Ownership

Validates Backstage catalog-info.yaml entries for completeness and compliance. Checks that the catalog file exists and is valid, owner is set, lifecycle stage is defined, and system grouping is assigned. Optionally skips components with no catalog file instead of failing them.

Add backstage to your lunar-config.yml:
uses: github://earthly/lunar-lib/policies/backstage@v1.0.5

Included Guardrails

This policy includes 15 guardrails that enforce standards for your repository and ownership.

Guardrail

catalog-info-exists

Verifies that a catalog-info.yaml file exists in the repository. Every Backstage-managed service must have a catalog definition file. Fails if the backstage collector reports no catalog file found.

backstage catalog-info service catalog file exists
View Guardrail
Guardrail

catalog-info-valid

Verifies that the catalog-info.yaml file is syntactically valid and passes Backstage descriptor schema checks (no lint errors reported by the collector). Fails if the file is missing or has lint errors.

backstage catalog-info lint schema validation
View Guardrail
Guardrail

owner-set

Validates that the owner field (spec.owner) is populated in the catalog-info.yaml. Ownership is required for incident routing and accountability. Fails if no catalog file is present.

backstage ownership owner accountability
View Guardrail
Guardrail

lifecycle-set

Validates that the lifecycle stage (spec.lifecycle) is defined in the catalog-info.yaml. Lifecycle stages (production, experimental, deprecated) inform operational expectations and SLO requirements. Fails if no catalog file is present.

backstage lifecycle production deprecated
View Guardrail
Guardrail

system-set

Validates that the system grouping (spec.system) is defined in the catalog-info.yaml. System assignment enables dependency mapping and architectural visibility. Fails if no catalog file is present.

backstage system grouping architecture
View Guardrail
Guardrail

domain-exists

Verifies that the domain referenced by spec.domain in catalog-info.yaml actually exists in the Backstage catalog (referential integrity). Reads the collector's lookup result at .catalog.native.backstage.refs.domain. Fails when the declared domain is absent from Backstage (collector recorded exists=false). Passes when no domain is declared (nothing to cross-check — system-set/*-set own "should it be set"). Skips (passes) when referential integrity was not performed — i.e. the backstage collector is not configured with backstage_url (no .refs.checked), or the lookup errored transiently (.refs.domain.error) — so an unconfigured collector or a Backstage outage never turns this red. Note spec.domain lives on System entities, so this check does work only for kind: System catalog files (or Components carrying a custom domain).

backstage domain referential integrity catalog cross-reference
View Guardrail
Guardrail

system-exists

Verifies that the system referenced by spec.system in catalog-info.yaml actually exists in the Backstage catalog (referential integrity). Reads the collector's lookup result at .catalog.native.backstage.refs.system. Fails when the declared system is absent from Backstage (collector recorded exists=false). Passes when no system is declared (nothing to cross-check — system-set owns "should it be set"). Skips (passes) when referential integrity was not performed — i.e. the backstage collector is not configured with backstage_url (no .refs.checked), or the lookup errored transiently (.refs.system.error) — so an unconfigured collector or a Backstage outage never turns this red.

backstage system referential integrity catalog cross-reference
View Guardrail
Guardrail

system-domain-exists

Verifies that the domain the component's system belongs to actually exists in the Backstage catalog (transitive referential integrity). Reads the collector's lookup result at .catalog.native.backstage.refs.system_domain. In the Backstage model a Component has no domain of its own — domain membership is carried by its System (System.spec.domain) — so this is the check that answers "is my domain real?" for an ordinary kind: Component catalog file, where domain-exists has nothing to inspect. Fails when the system's domain is absent from Backstage, naming the offending System (the entity to fix is the System's own catalog file, typically owned by another team, not this component's). Passes when there is nothing to cross-check — no spec.system declared, the system itself did not resolve (that is system-exists's failure to report), or the system belongs to no domain (that is system-domain-set's). Skips (passes) when referential integrity was not performed — the backstage collector is not configured with backstage_url (no .refs.checked) — or the lookup errored transiently (.refs.system_domain.error), so an unconfigured collector or a Backstage outage never turns this red.

backstage domain system referential integrity catalog cross-reference transitive
View Guardrail
Guardrail

system-domain-set

Verifies that the system the component belongs to is itself assigned to a domain (System.spec.domain), read from the collector's .catalog.native.backstage.refs.system.has_domain. Fails when the system resolves in Backstage but declares no domain, naming the System to fix. Passes when no spec.system is declared or the system does not resolve (system-set and system-exists report those). Skips when the collector has no backstage_url, the system lookup errored, or the collector predates has_domain.

backstage domain system referential integrity catalog transitive domain assignment
View Guardrail
Guardrail

required-annotations

Verifies that catalog-info.yaml declares a configurable set of required annotation keys (e.g. backstage.io/source-location), and optionally that their values satisfy typed constraints — a declared data type (integer, number, boolean, string) with numeric bounds, string length, a regular-expression pattern, or an allowed-value set. Configure via the required_annotations input. Skipped when no annotations are required (the default). Fails if the catalog file is missing, a required key is absent or empty, or a value violates its declared constraint.

backstage annotations metadata required type validation value constraints
View Guardrail
Guardrail

required-tag-patterns

Verifies that catalog-info.yaml carries at least one tag matching each configured glob pattern (e.g. location/*, runs-on/*). Configure via the required_tag_patterns input. Skipped when no patterns are required (the default). Fails if the catalog file is missing or any pattern is unmatched by the component's tags.

backstage tags tag patterns glob required metadata
View Guardrail
Guardrail

required-link-types

Verifies that catalog-info.yaml has a metadata.links entry of each configured link type (e.g. runbook, dashboard), matched exactly on the link's type. Configure via the required_link_types input. Skipped when no link types are required (the default). Fails if the catalog file is missing or any required type has no link.

backstage links link type runbook dashboard required metadata
View Guardrail
Guardrail

disallowed-annotations

Verifies that catalog-info.yaml declares none of a configurable set of forbidden annotation keys. Configure via the disallowed_annotations input. Skipped when no annotations are forbidden (the default). Fails if any forbidden annotation key is present in metadata.annotations.

backstage annotations metadata disallowed forbidden
View Guardrail
Guardrail

disallowed-tag-patterns

Verifies that none of the component's tags match a configured glob pattern. Configure via the disallowed_tag_patterns input. Skipped when no patterns are forbidden (the default). Fails if any tag matches any forbidden pattern.

backstage tags tag patterns glob disallowed forbidden
View Guardrail
Guardrail

dependencies-documented

Verifies that catalog-info.yaml declares the component's dependencies: a non-empty spec.dependsOn, or a non-empty comma-separated value in the annotation named by the dependencies_annotation input. Targets are not looked up in the catalog. Skipped unless require_dependencies is "true". Fails if the catalog file is missing or declares no dependencies.

backstage dependencies dependsOn dependency mapping metadata
View Guardrail

How Guardrails Fit into Lunar

Lunar guardrails define your engineering standards as code. They evaluate data collected by integrations and produce pass/fail checks with actionable feedback.

Policies support gradual enforcement—from silent scoring to blocking PRs or deployments—letting you roll out standards at your own pace without disrupting existing workflows.

Learn How Lunar Works →
1
↓ Integrations Gather Data
Collectors extract metadata from code, CI pipelines, tool outputs, and scans
2
{ } Centralized as JSON
All data merged into each component's unified metadata document
3
✓ Guardrails Enforce Standards This Policy
Real-time feedback in PRs and AI workflows

Required Integrations

This policy evaluates data gathered by one or more of the following integration(s). Make sure to enable them in your lunar-config.yml.

Configuration

Configure this policy in your lunar-config.yml.

Inputs

Input Required Default Description
skip_when_no_catalog_info Optional false Skip every check in this policy on a component with no catalog-info.yaml, instead of failing it. Set to `"true"` when only some repositories in scope are registered in Backstage, so importing the policy broadly does not turn the rest red. One switch for the whole family, and the only place their missing-file behaviour is described. With the default (`"false"`) a missing catalog file fails `catalog-info-exists`, `catalog-info-valid`, `owner-set`, `lifecycle-set` and `system-set`; fails `required-annotations`, `required-tag-patterns`, `required-link-types` and `dependencies-documented` when those are configured; passes the two `disallowed-*` deny-checks, since nothing forbidden can be present without a file; and skips the referential-integrity checks, which have nothing to cross-check. With `"true"` every check skips instead — the `disallowed-*` pair included, so a component that is not in the catalog reports no green passes it did not earn. Only the file's absence is affected: a component that has a catalog-info.yaml is held to every check either way.
required_annotations Required — Annotation keys that must be present (and non-empty) in catalog-info.yaml `metadata.annotations`. Two forms are accepted. Comma-separated keys (presence-only, unchanged), e.g. `backstage.io/source-location,pagerduty.com/integration-key`. Or a YAML list, where each entry is either a bare key (presence-only) or a mapping with a `key` and optional value constraints: `type` (string|integer|number|boolean, default string), `min`/`max` (integer/number bounds), `min_length`/`max_length` (string length), `pattern` (full-match regex), and `enum` (allowed values). For example, a `key: example.com/service-tier` with `type: integer`, `min: 0`, `max: 5` requires that annotation to parse as an integer in 0–5. Annotation values are strings in Backstage, so `type` validates that the value *parses* as the declared type (`"2"` satisfies `integer`, `"2.5"` does not). `enum` inherits `type`: its entries are coerced to the declared type before comparison, so `type: integer` with `enum: [1,2,3]` compares integers while the default `type: string` compares strings. Constraints must match the declared type (`min`/`max` for integer/number; `min_length`/`max_length`/`pattern` for string). A value that violates a constraint fails the check; a malformed constraint (unknown type, min greater than max, invalid regex, a constraint on the wrong type, or an enum entry not of the declared type) makes it error. Empty (the default) disables the check.
required_tag_patterns Required — Comma-separated list of glob patterns; the component's `metadata.tags` must include at least one tag matching each pattern. Example: `location/*,runs-on/*`. Matching is case-insensitive. Empty (the default) disables the `required-tag-patterns` check.
required_link_types Required — Comma-separated list of link types; catalog-info.yaml `metadata.links` must include an entry whose `type` is each of them. Example: `runbook,dashboard`. Matching is exact and case-sensitive, and only the `type` is compared, not the link's title or URL. Empty (the default) disables the `required-link-types` check.
disallowed_annotations Required — Comma-separated list of annotation keys that must NOT be present in catalog-info.yaml `metadata.annotations`. Example: `backstage.io/skip-checks`. Empty (the default) disables the `disallowed-annotations` check.
disallowed_tag_patterns Required — Comma-separated list of glob patterns; the component's `metadata.tags` must NOT include any tag matching these patterns. Example: `deprecated/*,internal-only`. Matching is case-insensitive. Empty (the default) disables the `disallowed-tag-patterns` check.
require_dependencies Optional false Set to `"true"` to enforce `dependencies-documented`. Off by default, so importing the whole policy doesn't fail every component that declares no dependencies.
dependencies_annotation Required — Annotation key that also counts as declared dependencies when its value is a non-empty comma-separated list, for catalogs that record dependencies there instead of in `spec.dependsOn`. Example: `example.com/dependencies`. Empty (the default) checks `spec.dependsOn` only.

Documentation

View on GitHub

Backstage Guardrails

Enforce Backstage service catalog standards for catalog-info.yaml completeness.

Overview

Validates that Backstage catalog entries include required metadata for service ownership, lifecycle management, and system architecture; pair it with the backstage collector.

The five core checks fail when no catalog-info.yaml is present — set skip_when_no_catalog_info and every check skips instead, for a fleet where only some repositories are catalogued. The opt-in checks (required-*, disallowed-*, dependencies-documented) skip until configured. The referential-integrity checks (domain-exists, system-exists, system-domain-exists, system-domain-set) check the domain and system a component points at against the live Backstage catalog; they too skip until the collector has a backstage_url, so enabling them unconfigured never turns a component red.

Policies

This plugin provides the following policies (use include to select a subset):

Policy Description
catalog-info-exists Verifies catalog-info.yaml exists in the repository
catalog-info-valid Verifies catalog-info.yaml passes lint/schema checks
owner-set Validates that spec.owner is populated
lifecycle-set Validates that spec.lifecycle is defined
system-set Validates that spec.system is defined
domain-exists Verifies the declared spec.domain exists in Backstage (needs collector backstage_url)
system-exists Verifies the declared spec.system exists in Backstage (needs collector backstage_url)
system-domain-exists Verifies the domain that the component's system belongs to exists in Backstage — the transitive check for an ordinary kind: Component file (needs collector backstage_url)
system-domain-set Verifies that the component's system belongs to a domain at all (needs collector backstage_url)
required-annotations Validates that configured annotation keys are present, and optionally that their values match typed constraints (opt-in via the required_annotations input)
required-tag-patterns Validates that the component's tags match configured glob patterns (opt-in via the required_tag_patterns input)
required-link-types Validates that metadata.links has an entry of each configured link type, e.g. runbook (opt-in via the required_link_types input)
disallowed-annotations Fails if any forbidden annotation key is present (opt-in via the disallowed_annotations input)
disallowed-tag-patterns Fails if any tag matches a forbidden glob pattern (opt-in via the disallowed_tag_patterns input)
dependencies-documented Validates that spec.dependsOn, or a configured annotation, lists at least one dependency (opt-in via the require_dependencies input)

Required Data

This policy reads from the following Component JSON paths. The presence of .catalog.native.backstage indicates that a catalog-info file was found; its absence means no file exists.

Path Type Provided By
.catalog.native.backstage object backstage collector (namespace present ⇔ file found)
.catalog.native.backstage.valid boolean backstage collector
.catalog.native.backstage.errors[] array backstage collector
.catalog.native.backstage.spec.owner string backstage collector
.catalog.native.backstage.spec.lifecycle string backstage collector
.catalog.native.backstage.spec.system string backstage collector
.catalog.native.backstage.metadata.annotations object backstage collector (read by required-annotations / disallowed-annotations)
.catalog.native.backstage.metadata.tags array backstage collector (read by required-tag-patterns / disallowed-tag-patterns)
.catalog.native.backstage.metadata.links array backstage collector (read by required-link-types)
.catalog.native.backstage.spec.dependsOn array backstage collector (read by dependencies-documented, which also reads the dependencies_annotation annotation when set)
.catalog.native.backstage.refs.checked boolean backstage collector — true when backstage_url is configured; the RI checks skip (pass) when absent
.catalog.native.backstage.refs.domain object backstage collector — { name, exists } (or { name, error } on a transient lookup failure) for spec.domain; read by domain-exists
.catalog.native.backstage.refs.system object backstage collector — { name, exists } (or { name, error }) for spec.system, plus has_domain once the system resolves; read by system-exists and system-domain-set
.catalog.native.backstage.refs.system_domain object backstage collector — { name, exists, via_system } (or { name, error, via_system }) for the domain the component's system belongs to; read by system-domain-exists

Note: Ensure the backstage collector is configured before enabling this policy. The domain-exists, system-exists, system-domain-exists and system-domain-set checks additionally require the collector to be configured with a backstage_url (and, for authenticated instances, a BACKSTAGE_TOKEN secret); without it they skip (and pass) rather than fail, since referential integrity cannot be verified. (A durable "pending" state isn't available — post-collection the SDK resolves a data-less check to fail/error, not pending — so these checks skip to pass when unverified, mirroring the opt-in required-* / disallowed-* checks.)

Installation

Add to your lunar-config.yml:

policies:
  - uses: github://earthly/lunar-lib/policies/backstage@v1.0.0
    on: ["domain:your-domain"]
    enforcement: report-pr
    # include: [catalog-info-exists, owner-set]  # Only run specific checks
    with:
      # Leave the rest of the fleet alone when it has no catalog-info.yaml:
      skip_when_no_catalog_info: "true"
      # Opt in to the configurable checks by setting their inputs:
      required_annotations: "backstage.io/source-location"
      required_tag_patterns: "location/*,runs-on/*"
      disallowed_annotations: "backstage.io/skip-checks"
      disallowed_tag_patterns: "deprecated/*"
      required_link_types: "runbook"
      require_dependencies: "true"

skip_when_no_catalog_info defaults to "false": a component with no catalog-info.yaml fails, because importing the policy is itself the statement that the component should be catalogued. Set it to "true" to scope enforcement to the repositories that are already in Backstage — every check then skips on a component with no catalog file, and a component that has one is held to every check exactly as before.

The list inputs are comma-separated; leave one unset (the default) and the corresponding check is skipped. required_annotations additionally accepts a YAML list for validating annotation values against typed constraints — see Typed value constraints below. Tag patterns are glob-style (location/* matches location/us-east-1), matched case-insensitively. required-tag-patterns needs each pattern matched by at least one tag; disallowed-tag-patterns fails if any tag matches any pattern. required-annotations needs each key present and non-empty; disallowed-annotations fails if any forbidden key is present at all. required-link-types needs a metadata.links entry whose type exactly equals each listed type; the link's title and URL aren't checked.

dependencies-documented is off until require_dependencies is "true", so a whole-policy import doesn't fail every component that lists no dependencies. Once on, it passes on any non-empty spec.dependsOn (targets are not looked up in Backstage) or, when dependencies_annotation names an annotation, on a non-empty comma-separated value there. A component with nothing to depend on fails too, so leave such components out with on:.

Examples

Passing Example

{
  "catalog": {
    "native": {
      "backstage": {
        "valid": true,
        "errors": [],
        "path": "catalog-info.yaml",
        "apiVersion": "backstage.io/v1alpha1",
        "kind": "Component",
        "metadata": { "name": "payment-api" },
        "spec": {
          "type": "service",
          "owner": "team-payments",
          "lifecycle": "production",
          "system": "payment-platform"
        }
      }
    }
  }
}

Failing Example (spec fields missing)

{
  "catalog": {
    "native": {
      "backstage": {
        "valid": true,
        "errors": [],
        "path": "catalog-info.yaml",
        "apiVersion": "backstage.io/v1alpha1",
        "kind": "Component",
        "metadata": { "name": "payment-api" },
        "spec": {
          "type": "service"
        }
      }
    }
  }
}

Failing Example (no catalog-info.yaml)

{}

The .catalog.native.backstage namespace is simply absent. The five core checks fail. The required-* checks and dependencies-documented fail too if configured; the disallowed-* checks pass (nothing forbidden can be present without a file). All of them are skipped if unconfigured.

With skip_when_no_catalog_info: "true" every check skips on this component instead — including the disallowed-* pair, which would otherwise report a green pass on a repository that has no catalog file at all.

Failure messages:

  • "No catalog-info.yaml found"
  • "catalog-info.yaml has lint errors: <details>"
  • "Owner (spec.owner) is not set in catalog-info.yaml"
  • "Lifecycle stage (spec.lifecycle) is not set in catalog-info.yaml"
  • "System (spec.system) is not set in catalog-info.yaml"

Configurable checks: required and disallowed annotations / tag patterns

With required_annotations: "backstage.io/source-location", required_tag_patterns: "location/*,runs-on/*", disallowed_annotations: "backstage.io/skip-checks", and disallowed_tag_patterns: "deprecated/*" configured, this component passes all four of those checks:

{
  "catalog": {
    "native": {
      "backstage": {
        "metadata": {
          "annotations": { "backstage.io/source-location": "url:https://github.com/acme/payment-api" },
          "tags": ["location/us-east-1", "runs-on/self-hosted", "tier1"]
        }
      }
    }
  }
}

Remove the backstage.io/source-location annotation and required-annotations fails: "catalog-info.yaml is missing required annotation(s): backstage.io/source-location". Drop every runs-on/* tag and required-tag-patterns fails: "catalog-info.yaml has no tag matching required pattern(s): runs-on/*". Conversely, add a backstage.io/skip-checks annotation and disallowed-annotations fails; add a deprecated/legacy tag and disallowed-tag-patterns fails: "catalog-info.yaml has tag(s) matching disallowed pattern(s): deprecated/* (deprecated/legacy)".

Configurable checks: typed links and declared dependencies

With required_link_types: "runbook,dashboard" and require_dependencies: "true" configured, this component passes required-link-types and dependencies-documented:

{
  "catalog": {
    "native": {
      "backstage": {
        "metadata": {
          "links": [
            { "url": "https://wiki.example.com/payment-api/runbook", "title": "Runbook", "type": "runbook" },
            { "url": "https://grafana.example.com/d/abc123", "title": "Service dashboard", "type": "dashboard" }
          ]
        },
        "spec": {
          "dependsOn": ["resource:payments-db", "component:auth-service"]
        }
      }
    }
  }
}

Drop the runbook link and required-link-types fails: "catalog-info.yaml has no metadata.links entry of type: runbook. Present link types: dashboard." A link titled "Runbook" with no type doesn't count. Empty spec.dependsOn and dependencies-documented fails: "catalog-info.yaml declares no dependencies." With dependencies_annotation: "example.com/dependencies" set, an annotation such as example.com/dependencies: "resource:payments-db,component:auth-service" passes it instead.

Referential integrity: domain-exists, system-exists, system-domain-exists and system-domain-set

These checks read the .refs block the backstage collector writes when it is configured with a backstage_url. The .refs.checked marker (always written when the collector is configured) is what lets the checks tell "configured" from "not configured."

Which check fires depends on the entity kind. In Backstage, spec.system lives on Component entities and spec.domain lives on System entities. A Component has no domain of its own — it reaches one only through its system (a spec.domain written directly on a Component is inert; Backstage generates no domain relation from it). So for the common one-Component-per-repo case the everyday checks are system-exists and system-domain-exists, while domain-exists only does work when the repo's catalog-info.yaml is itself a kind: System (or a Component carrying a custom spec.domain). Each check passes silently when its reference isn't declared.

Component → system. The declared system is a typo that doesn't resolve in Backstage:

{
  "catalog": {
    "native": {
      "backstage": {
        "kind": "Component",
        "spec": { "system": "typo-platform" },
        "refs": {
          "checked": true,
          "system": { "name": "typo-platform", "exists": false }
        }
      }
    }
  }
}

system-exists fails: "System 'typo-platform' referenced in catalog-info.yaml does not exist in the Backstage catalog". domain-exists passes — no spec.domain is declared on this Component, so there's nothing to cross-reference.

System → domain. A kind: System catalog file whose declared domain does resolve:

{
  "catalog": {
    "native": {
      "backstage": {
        "kind": "System",
        "spec": { "domain": "payments" },
        "refs": {
          "checked": true,
          "domain": { "name": "payments", "exists": true }
        }
      }
    }
  }
}

domain-exists passes (payments exists in Backstage).

Component → system → domain (transitive). The everyday case: the component's system resolves fine, but the domain that system belongs to does not exist. system-exists passes and the component declares no spec.domain of its own, so without the transitive entry nothing would catch this:

{
  "catalog": {
    "native": {
      "backstage": {
        "kind": "Component",
        "spec": { "system": "orphan-system" },
        "refs": {
          "checked": true,
          "system": { "name": "orphan-system", "exists": true },
          "system_domain": {
            "name": "ghost-domain",
            "exists": false,
            "via_system": "orphan-system"
          }
        }
      }
    }
  }
}

system-domain-exists fails: "System 'orphan-system' (referenced by spec.system) belongs to domain 'ghost-domain', which does not exist in the Backstage catalog." The message names the System deliberately — this component's catalog-info.yaml has nothing to correct, because spec.domain lives on the System entity, whose catalog file is typically owned by another team.

system-domain-exists passes whenever there is nothing to cross-check: no spec.system declared, the system itself didn't resolve (that is system-exists's failure to report, not a second one), or the system belongs to no domain at all.

Component → system with no domain. The system resolves, but it isn't assigned to any domain, so there is no domain for system-domain-exists to verify:

{
  "catalog": {
    "native": {
      "backstage": {
        "kind": "Component",
        "spec": { "system": "team-tools" },
        "refs": {
          "checked": true,
          "system": { "name": "team-tools", "exists": true, "has_domain": false }
        }
      }
    }
  }
}

system-domain-set fails: "System 'team-tools' (referenced by spec.system) does not belong to any domain." Like system-domain-exists, it names the System, because spec.domain is set on that entity. It skips when has_domain is absent from a resolved system, which is what a collector older than this check writes.

Not configured / transient outage — they skip (pass). When the collector has no backstage_url, .refs is absent entirely (no .refs.checked) and every referential-integrity check skips (passes) — referential integrity couldn't run, so they don't fail. If the collector is configured but a lookup hits a transient Backstage error, that ref is recorded as { "name": "...", "error": "..." } and the corresponding check skips (passes) too, rather than false-failing on an outage:

{ "catalog": { "native": { "backstage": {
  "refs": { "checked": true, "system": { "name": "payment-platform", "error": "502 Bad Gateway" } }
} } } }

Typed value constraints on required annotations

required-annotations can also assert that an annotation's value meets a constraint, not just that the key is present. Pass required_annotations as a YAML list instead of a comma-separated string; each entry is either a bare key (presence-only, as before) or a mapping with a key and one or more constraints:

with:
  required_annotations: |
    - key: example.com/service-tier      # integer in 0–5
      type: integer
      min: 0
      max: 5
    - key: example.com/contact-email     # must look like an email address
      type: string
      pattern: '^[^@]+@[^@]+\.[^@]+$'
    - key: example.com/environment       # one of a fixed set
      enum: [production, staging, development]
    - backstage.io/source-location       # bare key = presence-only

Supported constraints:

Constraint Applies to Meaning
type all string (default), integer, number, or boolean. The value is coerced to this type before the other constraints run.
min / max integer, number Inclusive numeric bounds.
min_length / max_length string Inclusive length bounds.
pattern string Full-match regular expression. Quote it with single quotes ('...') so backslashes pass through literally.
enum all The value must be one of the listed values (compared after coercion — see below).

Backstage annotation values are strings, so type validates that the value parses as the declared type: "2" satisfies type: integer, "2.5" does not.

enum inherits type. Enum entries are coerced to the declared type before comparison, so the value and the allowed set are always compared in the same domain. This matters because YAML types the entries on parse: enum: [1, 2, 3] yields integers, but with the default type: string the annotation value is a string, so the entries are coerced to "1", "2", "3" and "2" matches. Use type: integer to compare as integers instead. An enum entry that can't be coerced to the declared type (e.g. type: integer with enum: [1, two, 3]) is a misconfiguration.

Constraints must match the declared type. min/max apply to integer/number; min_length/max_length/pattern apply to string; enum and type apply to any type. Pairing a constraint with the wrong type (e.g. pattern on an integer, or min on a string) is a misconfiguration, not a silent no-op.

A value that violates its constraint fails the check with a specific message (for example, annotation "example.com/service-tier": value "7" is above maximum 5). A malformed constraint spec — an unknown type, min greater than max, an invalid regex, a constraint on the wrong type, or an enum entry not of the declared type — makes the check error rather than fail, so the misconfiguration surfaces immediately instead of silently passing.

The comma-separated form (required_annotations: "key1,key2") still works and remains presence-only; it is equivalent to a YAML list of bare keys.

Remediation

When this policy fails, resolve it by updating your catalog-info.yaml:

  1. Missing file - Create a catalog-info.yaml in the repository root following the Backstage descriptor format. If the component is not meant to be in the catalog, set skip_when_no_catalog_info: "true" on the policy import instead of excluding the checks one by one
  2. Lint errors - Review .catalog.native.backstage.errors[] in the component payload and fix the reported issues
  3. Missing owner - Add spec.owner with a valid team or user reference (e.g., team-payments)
  4. Missing lifecycle - Add spec.lifecycle with a stage: production, experimental, or deprecated
  5. Missing system - Add spec.system referencing the parent system that groups related components
  6. Referential-integrity failure (domain-exists / system-exists) - The spec.domain or spec.system value points at an entity that does not exist in the Backstage catalog. Fix the reference to match an existing entity's metadata.name, or register the missing Domain/System in Backstage
  7. System has no domain (system-domain-set) - Set spec.domain on the named System entity, in that System's own catalog file
  8. Missing link type (required-link-types) - Add a metadata.links entry with the missing type and its url
  9. No dependencies (dependencies-documented) - List the entities the component depends on under spec.dependsOn (e.g. resource:payments-db), or in the annotation named by dependencies_annotation

Open Source

This policy is open source and available on GitHub. Contribute improvements, report issues, or fork it for your own use.

View Repository

Common Use Cases

Explore how individual guardrails work with specific integrations.

+
Catalog Info Exists + Backstage Collector Verifies that a catalog-info.yaml file exists in the repository. Every...
→
+
Catalog Info Valid + Backstage Collector Verifies that the catalog-info.yaml file is syntactically valid and passes...
→
+
Owner Set + Backstage Collector Validates that the owner field (spec.owner) is populated in...
→
+
Lifecycle Set + Backstage Collector Validates that the lifecycle stage (spec.lifecycle) is defined in the...
→
+
System Set + Backstage Collector Validates that the system grouping (spec.system) is defined in the...
→
+
Domain Exists + Backstage Collector Verifies that the domain referenced by spec.domain in catalog-info.yaml actually...
→
+
System Exists + Backstage Collector Verifies that the system referenced by spec.system in catalog-info.yaml actually...
→
+
System Domain Exists + Backstage Collector Verifies that the domain the component's system belongs to actually exists in...
→
+
System Domain Set + Backstage Collector Verifies that the system the component belongs to is itself assigned to a domain...
→
+
Required Annotations + Backstage Collector Verifies that catalog-info.yaml declares a configurable set of required...
→
+
Required Tag Patterns + Backstage Collector Verifies that catalog-info.yaml carries at least one tag matching each...
→
+
Required Link Types + Backstage Collector Verifies that catalog-info.yaml has a `metadata.links` entry of each configured...
→
+
Disallowed Annotations + Backstage Collector Verifies that catalog-info.yaml declares none of a configurable set of forbidden...
→
+
Disallowed Tag Patterns + Backstage Collector Verifies that none of the component's tags match a configured glob pattern....
→
+
Dependencies Documented + Backstage Collector Verifies that catalog-info.yaml declares the component's dependencies: a...
→

Ready to Automate Your Standards?

See how Lunar can turn your AGENTS.md, engineering wiki, compliance docs, or postmortem action items into automated guardrails with our 200+ built-in guardrails.

Works with any process
check AI agent rules & prompt files
check Post-mortem action items
check Security & compliance policies
check Testing & quality requirements
Auto-ID My Guardrails
Paste your AGENTS.md or manual process doc and get guardrails in minutes
Book a Demo