Hamburger Cross Icon
System Domain Exists
+
Backstage Collector

System Domain Exists + Backstage Collector

Guardrail Collector Beta Repository And Ownership

Enforce System Domain Exists using data collected by Backstage Collector. Automatically check repository and ownership standards on every PR.

Guardrail: 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. 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.
Data Source: Parses and lints Backstage catalog-info.yaml files. Writes the raw Backstage descriptor (apiVersion, kind, metadata, spec) to .catalog.native.backstage, preserving annotations as-is. Optionally verifies the referenced domain and system exist in a live catalog, via Bearer token or AWS SigV4.

How Backstage Collector Powers This Guardrail

The Backstage Collector gathers metadata from your service catalog systems. This data flows into Lunar's Component JSON, where the System Domain Exists guardrail evaluates it against your standards.

When enabled, this check runs automatically on every PR and in AI coding workflows, providing real-time enforcement with actionable feedback.

1
Backstage Collector Gathers Data Collector
Extracts metadata from code, configs, and tool outputs
2
{ } Component JSON
Data centralized in structured format for evaluation
3
System Domain Exists Checks Guardrail
Pass/fail result with actionable feedback in PRs

Quick Start Configuration

Add both the collector and policy to your lunar-config.yml to enable this guardrail.

📄 lunar-config.yml
# Step 1: Enable the Backstage Collector
collectors:
  - uses: github://earthly/lunar-lib/collectors/backstage@v1.0.5
    # with: ...

# Step 2: Enable the Backstage Guardrails
policies:
  - uses: github://earthly/lunar-lib/policies/backstage@v1.0.5
    include: [system-domain-exists]
    # with: ...

What Backstage Collector Collects

This collector gathers the following data that the System Domain Exists guardrail evaluates.

Collector code

catalog-info

Scans the repository for a catalog-info.yaml file (configurable paths) and parses its contents as-is into .catalog.native.backstage (apiVersion, kind, metadata, spec). A file may declare multiple entities separated by --- (e.g. a Component plus the APIs it provides); every entity is parsed and recorded under .catalog.native.backstage.entities[], while the primary entity (the first Component, else the first document) is hoisted to the top level so the single-entity policies keep working. Annotations are preserved verbatim with their backstage.io/ or vendor prefixes. Lints every entity for schema/syntax issues and writes the aggregated findings to .catalog.native.backstage.valid and .catalog.native.backstage.errors[]. When backstage_url is configured, it additionally performs a referential-integrity check: for each grouping reference declared in the file (spec.domain, spec.system) it queries the Backstage catalog API (GET <api_path_prefix>/catalog/entities/by-name/<kind>/<namespace>/<name>, where api_path_prefix defaults to /api; set ref_lookup: by-query to resolve via the /catalog/entities/by-query search endpoint instead, for an instance that only authorizes that one) and records the outcome under .catalog.native.backstage.refs. Whenever backstage_url is set the collector writes .refs.checked: true as the "referential integrity ran" signal, plus a per-reference entry: a definitive lookup records .refs.<kind> = {name, exists} (exists: true on a 200, false on a 404), while a transient failure (timeout/5xx) records .refs.<kind> = {name, error} so an outage stays distinguishable from a real miss. Because a Component has no domain of its own in the Backstage model — domain membership belongs to its System — a confirmed spec.system is followed one hop further: that System's own spec.domain is resolved and recorded at .refs.system_domain = {name, exists, via_system}, which is what makes the domain check meaningful for an ordinary kind: Component file. The hop reuses the System entity the system lookup already returned, so it costs one extra request and only when the system both resolves and declares a domain. The <namespace> is derived from the reference itself — a qualified ref (ns/name) carries its own, otherwise the component's own metadata.namespace is used, falling back to default — so no namespace configuration is required. When backstage_url is empty (the default) no lookups run and .refs is not written at all — behavior is identical to today, and the policy's referential-integrity checks skip (pass) rather than fail. Those lookups authenticate per auth_mode: a BACKSTAGE_TOKEN bearer header (default), or AWS SigV4 signing (auth_mode: sigv4) for a Backstage API fronted by AWS IAM auth, which rejects bearer tokens. An auth or credential-resolution failure never discards the parse and lint results — it is recorded as a per-reference {name, error} exactly like any other non-definitive lookup.

Example Data Flow

Here's an example of the data that Backstage Collector writes to the Component JSON, which System Domain Exists then evaluates.

{ } component.json From Backstage Collector
{
  "catalog": {
    "native": {
      "backstage": {
        "valid": true,
        "errors": [],
        "path": "catalog-info.yaml",
        "apiVersion": "backstage.io/v1alpha1",
        "kind": "Component",
        "metadata": {
          "name": "payment-api",
          "description": "Payment processing API",
          "annotations": {
            "backstage.io/techdocs-ref": "dir:.",
            "pagerduty.com/integration-key": "PXXXXXX",
            "grafana/dashboard-selector": "https://grafana.example.com/d/abc123",
            "backstage.io/source-location": "url:https://github.com/acme/payment-api"
          },
          "tags": ["payments", "api", "tier1"]
        },
        "spec": {
          "type": "service",
          "owner": "team-payments",
          "lifecycle": "production",
          "domain": "payments",
          "system": "payment-platform",
          "providesApis": ["payment-api-grpc"],
          "consumesApis": ["user-api", "notification-api"],
          "dependsOn": ["resource:database-payments", "resource:cache-redis"]
        },
        "entities": [
          {
            "valid": true,
            "errors": [],
            "apiVersion": "backstage.io/v1alpha1",
            "kind": "Component",
            "metadata": {
              "name": "payment-api",
              "description": "Payment processing API",
              "annotations": {
                "backstage.io/techdocs-ref": "dir:.",
                "backstage.io/source-location": "url:https://github.com/acme/payment-api"
              },
              "tags": ["payments", "api", "tier1"]
            },
            "spec": {
              "type": "service",
              "owner": "team-payments",
              "lifecycle": "production",
              "domain": "payments",
              "system": "payment-platform",
              "providesApis": ["payment-api-grpc"],
              "consumesApis": ["user-api", "notification-api"],
              "dependsOn": ["resource:database-payments", "resource:cache-redis"]
            }
          },
          {
            "valid": true,
            "errors": [],
            "apiVersion": "backstage.io/v1alpha1",
            "kind": "API",
            "metadata": { "name": "payment-api-grpc" },
            "spec": {
              "type": "grpc",
              "owner": "team-payments",
              "lifecycle": "production"
            }
          }
        ],
        "refs": {
          "checked": true,
          "domain": { "name": "payments", "exists": true },
          "system": { "name": "payment-platform", "exists": true }
        }
      }
    }
  }
}

Configuration Options

Backstage Collector Inputs

Input Required Default Description
paths Optional catalog-info.yaml,catalog-info.yml Comma-separated list of catalog file paths to check (first match wins)
backstage_url Required Base URL of the Backstage instance (e.g. https://backstage.example.com). When set, the collector cross-checks each declared spec.domain and spec.system against the Backstage catalog API and records the result under `.catalog.native.backstage.refs`. Empty (the default) disables referential-integrity lookups — the collector only parses and lints the local file, exactly as before.
api_path_prefix Optional /api Path prefix prepended before `/catalog/entities` when calling the Backstage catalog API. Defaults to `/api`, matching the standard Backstage layout (`<backstage_url>/api/catalog/entities`). Set to an empty string (`""`) when the catalog API is mounted at the root — e.g. behind an API gateway that already strips the `/api` hop, where `<backstage_url>/catalog/entities` is the live endpoint and `/api/catalog/entities` returns 403/404. A leading slash is optional and any trailing slash is ignored (`api`, `/api`, `/api/` are equivalent). Especially relevant with `auth_mode: sigv4`: an IAM-fronted Backstage is usually behind Amazon API Gateway, which is exactly the deployment shape that strips the `/api` hop — so a correct SigV4 setup can still 403 on every lookup if this is left at the default.
auth_mode Optional bearer How the collector authenticates to the Backstage catalog API. Only consulted when `backstage_url` is set. - `bearer` — send `Authorization: Bearer <BACKSTAGE_TOKEN>`. Default; unchanged behavior. - `sigv4` — sign every request with AWS Signature V4, for a Backstage API fronted by AWS IAM auth (typically Amazon API Gateway), which rejects Bearer tokens outright. Credentials are resolved at runtime from the standard AWS credential chain — IRSA (EKS), EKS Pod Identity / ECS task role, or EC2 instance profile — so they **auto-refresh and are never supplied by hand**. See the README "AWS SigV4 Authentication" section for the (one-time) IAM-role setup.
aws_region Required AWS region used to scope the SigV4 signature and to reach STS when assuming a web-identity (IRSA) role. Required when `auth_mode: sigv4`, unless `AWS_REGION` is already present in the snippet pod's environment (IRSA and most AWS runtimes set it, but not always). If `auth_mode: sigv4` and neither this input nor the `AWS_REGION` env var is set, the referential-integrity lookups are skipped and each declared reference records an `aws_region required for sigv4` error, rather than signing with an empty region (which would surface as an opaque downstream AWS error). Parsing and linting still run. Ignored for `auth_mode: bearer`.
aws_service Optional execute-api AWS service name for the SigV4 signature scope. For a Backstage API behind Amazon API Gateway this is `execute-api` (the default). Override only for other fronting (e.g. a Lambda function URL signs as `lambda`). Ignored for `auth_mode: bearer`.
ref_lookup Optional by-name Which catalog endpoint resolves each declared reference. Only consulted when `backstage_url` is set. Both modes produce an identical `.refs` shape, so switching requires no policy changes. - `by-name` — `GET <api_path_prefix>/catalog/entities/by-name/<kind>/<namespace>/<name>`. A direct key lookup whose HTTP status is the answer: 200 = exists, 404 = miss. Default; unchanged behavior. - `by-query` — `GET <api_path_prefix>/catalog/entities/by-query?limit=1&filter=kind=<kind>,metadata.namespace=<namespace>,metadata.name=<name>`. A catalog search, so existence comes from the body rather than the status: a 200 whose `items` array is non-empty = exists, an empty array = miss. Set this when the instance only authorizes the search endpoint — a gateway in front of Backstage can expose `by-query` while rejecting `by-name` outright, in which case every lookup fails however correct the auth is, and the resulting errors read as an outage rather than a misconfiguration. It is the same endpoint the Backstage *cataloger* uses, so an instance the cataloger can already read supports it.

Backstage Guardrails Inputs

Input Required Default Description
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.
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.

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
Automate Now
Paste your AGENTS.md or manual process doc and get guardrails in minutes
Book a Demo