For the complete documentation index, see llms.txt. This page is also available as Markdown.

Cataloging Strategy

How to build a complete, trustworthy software catalog in Lunar — achieving full coverage, cleaning up inconsistent existing catalog data, handling monorepos, and enforcing catalog quality with guardra

Lunar decides what to enforce, and where, from your catalog. Domains determine the reporting structure, and tags determine which collectors and policies apply to which component through on expressions. A component that is missing, misfiled, or mistagged is a component your guardrails silently skip.

That makes catalog quality the first thing to work on in a Lunar rollout. In most large organizations only a fraction of repositories ship to production, handle regulated data, or fall in scope for a compliance audit, and targeting your strictest guardrails at that fraction is what keeps them credible with the teams they apply to.

You do not need a good catalog to start. Most organizations do not have one, and the steps below are designed to be run against a messy or largely absent catalog — Lunar becomes the thing that gets you to a good one, and starts returning findings while you are still working on it.

What you are starting from

Most organizations we work with already have a catalog of some kind. It typically has three problems.

It is incomplete. Registering a service was a convention, not a requirement, and nothing enforced it. A meaningful share of repositories were never cataloged at all.

It is inconsistent. Entries were filled in by individual teams over several years, against a schema that drifted. Owners point at people who left. Tags mean different things in different divisions. Nothing has ever validated the data, because nothing has ever depended on it programatically.

It does not identify monorepo components uniquely. A monorepo's catalog entries were written for a system that keys on a service name. Lunar keys on a repository URL plus an optional subdirectory, and most existing catalogs carry no field that maps cleanly onto that.

None of this was a problem before, because the catalog was a directory that people read. Lunar is the first system to make automated, consequential decisions from it, and that is a much higher bar.

The catalog data itself usually comes in one of three shapes, and the strategy below works for all of them:

Shape
Example
Source of truth

Catalog service with an API

Backstage, an internal service registry

A server you can query

Files distributed across repos

catalog-info.yaml in each repository

The repositories themselves

Central metadata repository

One repo of YAML/TOML/JSON describing every service

A single repository

Backstage is used as the running example because it is the more common, and because Lunar ships plugins for it. The same phases apply to a home-grown catalog; only the plugins differ. Where a shipped plugin does not fit your format, the AI skills for Claude Code, Codex, and Cursor are the fastest way to produce the collector, policy, or cataloger you need — they know Lunar's SDKs and conventions, so pointing one at your existing catalog schema gets you most of the way.

The shape of the operation

At the highest level this is three moves, in order:

1

Ingest every repository, ignoring the existing catalog

Get 100% of your repositories into Lunar as components before looking at any pre-existing catalog data. You immediately know the true size of the estate.

2

Validate and fix the catalog data at scale

Pull the existing catalog entries in as raw data and run guardrails against them, without letting them shape your catalog yet. Developers get feedback in their pull requests on exactly what is wrong with their own entries, and the cleanup happens in parallel across the organization instead of as a central project.

3

Ingest the clean catalog

Once the data is trustworthy, let it drive Lunar's components, domains, and tags — then enrich it with what nobody wrote down.

The steps

#
Step
Primitive
Required
Purpose

1

Ingest every repository

1.1

Discover every repository

Cataloger

Yes

Establish 100% coverage as the floor

2

Validate and fix the catalog data

2.1

Collector

Yes

Pull raw catalog entries into Lunar

2.2

Policy

Yes

Developer feedback; monorepo uniqueness

2.3

Require a catalog entry

Policy

Optional

Close the coverage gap continuously

3

Ingest the clean catalog

3.1

Ingest the catalog

Cataloger

Yes

Turn validated data into components, domains, tags

3.2

Policy

Optional

Enforce semantics beyond structure

3.3

Enrich with heuristics

Cataloger

Optional

Classify what nobody wrote down

3.4

Cataloger

Monorepos

Make change detection correct in monorepos

Phase 1. Ingest every repository

1.1 Discover every repository

Start with a cataloger that enumerates your source control and creates one component per repository. This is your coverage floor, and it is what makes 100% coverage achievable on day one rather than as the outcome of a migration.

Set default_domain. Every component then lands under a known root domain, which gives you a handle that matches everything: domain tags match hierarchically, so on: ["domain:engineering"] covers engineering and every subdomain beneath it.

Repeat the cataloger once per organization if you have several. See the GitHub Org cataloger documentation for all the available settings.

At this point every repository exists as a component, everything is in one domain, and nothing is classified. That is the correct starting state. The remaining steps add more meaning.

Monorepos at this stage. The GitHub org cataloger creates a single component for the monorepo root (github.com/acme/monorepo). Lunar has no awareness of the internal breakdown yet. Phase 2 is what begins interpreting the monorepo structure; step 3.1 is what turns it into real subcomponents.

Phase 2. Validate and fix the catalog data

2.1 Collect the existing catalog data

Write a collector that reads your existing catalog entries out of each repository and writes them into the Component JSON verbatim. Do not interpret or reshape the data here. The point is to make the raw entry visible so a policy can judge it.

Lunar ships a collector for Backstage's catalog-info.yaml; a home-grown format needs an equivalent collector of your own.

The component JSON convention is to write the raw descriptor under .catalog.native.<tool>, where the presence of the key is itself the signal: if the collector finds no entry, it writes nothing, and the absence means "not cataloged".

Use a code hook so this re-runs whenever the repository changes, which is what makes the feedback in step 2.2 immediate.

In monorepos, collect the whole tree onto the repository-level component. The subcomponents do not exist yet, so a collector scoped to a single directory would find nothing. Have the collector gather every entry in the repository and write them as a set onto the root component. That is what lets step 2.2 see all the entries at once and check them against each other. The repository-level component has no paths restriction, so it runs on any change anywhere in the monorepo.

How you gather that set depends on how the monorepo declares its components. Two layouts are common:

One entry file per component directoryservices/payments/catalog-info.yaml, services/web/catalog-info.yaml, and so on. Walk the tree and record each entry together with the path of the file it came from. That path is what makes each component's identity derivable in step 2.2, so carry it through rather than discarding it after parsing.

One shared entry file listing every component — a single root catalog-info.yaml, typically a multi-document YAML or a list of entities, declaring every service in the repository. Collect the whole file and keep every entity in it. The catch is that there is no per-component file location to fall back on here: every entry shares one path, so the subdirectory has to come from a field inside each entry. If no such field exists today, this is the layout that forces you to introduce one. See Component identity below.

The two layouts often coexist in the same organization, and can coexist in the same repository. You may handle both in the collector rather than mandating one, since consolidating a monorepo's catalog files is a migration in its own right and not necessarily a prerequisite for getting started.

A central metadata repository behaves like the shared-file layout: one location, many entries, so identity has to be carried in a field. The same collector logic applies, driven from the central repo rather than from each component's own tree.

2.2 Validate structure and component identity

Now add a policy that validates the raw entries the collector gathered. This is the step that turns catalog quality into something developers see and act on, because policies report into pull requests and catalogers do not.

The checks in that plugin are written against the catalog-info.yaml schema, so they only apply if your catalog is Backstage-shaped. For a home-grown catalog the structure of this step is identical, but the assertions are yours to write — see the Python SDK, or use the AI skills to generate a policy plugin against your own schema.

Whichever route you take, the checks in Component identity below are the ones that are not optional.

Component identity

Lunar identifies a component by repository URL plus an optional subdirectory. The grammar is positional:

On GitHub the first three segments are always host, org, and repo; everything after is the subdirectory. On GitLab, namespaces nest arbitrarily, so the /-/ marker is what separates the project from the subdirectory. See Components for the full rules.

Every component in a monorepo therefore needs its own distinct subdirectory path — github.com/acme/monorepo/services/payments, github.com/acme/monorepo/services/web. Deciding where that subdirectory comes from is the main piece of design work in a monorepo migration, and it is worth doing deliberately rather than discovering it later.

Where the subdirectory comes from

If your monorepos use one entry file per component directory, the subdirectory is inherent in the layout (it is the directory the file sits in) and you can skip to the collision danger below. The rest of this section matters when the file's location does not identify the component: a shared entry file listing many components, or a sync from the catalog API, where every entry arrives with the same location or none at all.

In Backstage, three annotations are candidates, and the one most people reach for first is the one that cannot work:

  • github.com/project-slug is repo-level by definition. Its documented format is owner/repo, with no path component, and the processor that auto-populates it derives it from the repository alone. Every component in a monorepo receives an identical value. This is also the default component_id_annotation for Lunar's Backstage catalogers, so leaving it at the default is exactly how a monorepo collapses into a single component.

  • backstage.io/source-location can carry the subdirectory, but frequently does not. Backstage's built-in location processor resolves it relative to the entity's own location, so an entry in services/payments/ auto-populates to url:https://github.com/acme/monorepo/tree/main/services/payments/. It is also documented as hand-writable, for the case where the catalog file does not sit with the source it describes. In practice, though, a great many real catalogs have this annotation pointing at the repository root anyway — hand-written once and never revisited, copied between services, or carried over from a migration. Treat it as a hint, not a fact. Validate it rather than trusting it, which is exactly what the next section is for.

  • backstage.io/managed-by-location is a weaker fallback. It is always present and points at the entry file, so its directory is recoverable. But Backstage documents it as many-to-one — a single location can be the source of many entities — and does not guarantee the value is even a url type, so it cannot be the primary key in the shared-file layout.

That leaves three realistic options:

Option
How identity is derived
When it fits

The entry file's location

The directory containing each entry file is the subdirectory

One entry file per component. Correct by construction, nothing to maintain, nothing to trust.

An existing location field

Parse source-location down to a repo-relative path

A shared entry file, or syncing from the catalog API — provided you validate the values first

A dedicated field

A new annotation holding the subdirectory verbatim

The existing fields are unreliable, or you want the value explicit and reviewable

Deriving from the entry file's location is the best option wherever it applies, precisely because it depends on no field anyone has to maintain. It is what Lunar's monorepo cataloger does.

The choice between repurposing source-location and introducing a dedicated annotation usually comes down to how much of your existing data is already correct. Measure that first — step 2.2 gives you the number — and repurpose only if the answer is "most of it". Introducing a new field costs one backfill; adopting a field that is quietly wrong for a third of your services costs you a wrong catalog that looks right.

Both annotation-based routes also need a parsing step: Lunar's API-based Backstage cataloger concatenates component_id_prefix with the raw annotation value, so it needs a field already in bare owner/repo/subdir form. A full url:https://… value cannot be pointed at directly.

Repurposing backstage.io/source-location is safe. TechDocs resolves against backstage.io/techdocs-ref, and the catalog page's view and edit links are governed by backstage.io/view-url and backstage.io/edit-url, so none of them depend on it. Several tools in the ecosystem already read this annotation for exactly this purpose — scoping themselves to a component's subdirectory within a monorepo.

The shared-entry-file layout from step 2 needs particular care here. Every entity in that file shares one location, so the auto-populated source-location is identical for all of them and points at the repository root. Auto-derivation cannot disambiguate them at all, which makes a hand-written source-location or a dedicated field mandatory in that layout rather than a preference.

Why identity collisions are dangerous

If two catalog entries resolve to the same component id, Lunar merges them into one component, and the merge is silent and lossy in a specific way:

  • Scalar fields — owner, domain, branch, description — take the value of whichever cataloger ran last.

  • Array fields — tags, paths, ciPipelines — are concatenated.

The array behavior is the one that causes real damage. Two colliding services produce one component holding the union of both tag sets, so a service that is not production can inherit production from its collision partner and pull strict guardrails onto itself, while its partner's owner is quietly overwritten with the wrong team. Nothing errors, and the result looks plausible in the UI.

Your policy should assert, over the full set of entries the collector found:

  1. Every entry resolves to a distinct component id. This is the check that makes the identity decision above real, and the only one that catches a collision before it silently merges.

  2. Whatever field you chose to carry the subdirectory is present on every entry in a monorepo. An entry that omits it falls back to the repository root and collides with every other entry that does the same.

  3. That field is actually specific to the component, not left at the repository root. This is the check that catches a stale source-location, and in a monorepo a root-pointing value is indistinguishable from a missing one.

  4. Each id is well-formed for your git platform — three segments plus a subdirectory on GitHub, /-/ separated on GitLab.

  5. The subdirectory corresponds to a real directory in the repository, and where you derive identity from entry-file locations, that it matches the file's own directory. This is what keeps identity stable when the repository is reorganized.

  6. Required fields are present and typed correctly.

Start at enforcement: report-pr so teams see the problems without being blocked, then escalate to block-pr once the backlog is worked down. Group the policies under a cataloging initiative so the effort is trackable as a unit.

2.3 Require a catalog entry

Optional, but this is the step that closes the coverage gap permanently.

Because step 1.1 made every repository a component, you can now enforce that every component has a catalog entry — the mechanism your organization previously lacked.

Roll this out along the enforcement ladder: score to measure the gap without touching anyone, report-pr to surface it in pull requests, then block-pr once coverage is high enough that blocking is fair.

Phase 3. Ingest the clean catalog

3.1 Ingest the catalog into Lunar

Now add the cataloger that reads your existing catalog and maps it onto Lunar's components, domains, and tags. The source can be the same files the collector read, or the catalog service's API if the server adds value your files do not have — resolved group hierarchies, defaulted namespaces, entity relations.

Catalogers run on a hook. A scheduled full sync is the usual backbone — catalog data changes slowly, and a nightly pass is simple to reason about and self-healing. Use a repo hook instead if your source is a central metadata repository, so the catalog refreshes on merge rather than on a schedule.

You can also pair the nightly sync with a commit-triggered cataloger where your source is repo-resident files. The Backstage catalog-info.yaml cataloger ships both by default: a scheduled augment, and an augment-on-commit that re-reads the file as soon as the repo is committed to, so an edited catalog-info.yaml lands immediately rather than at the next nightly tick.

Available Backstage catalogers

Lunar ships four catalogers relevant to this step. They are designed to layer, not to compete:

Cataloger
Source
What it does

github-org

GitHub API

One component per repository. The coverage floor from step 1.1.

backstage

Backstage REST API

Components and domains from the running server, with subdomainOf / spec.system hierarchy resolved into dotted domain paths.

backstage-catalog-info

catalog-info.yaml per repo

Augments each existing component from its own committed file.

Every catalog-info.yaml in a repo

Creates one component per file, keyed to that file's directory. The monorepo answer.

Running those two together is the intended monorepo setup: the first owns the repository-level component, the second adds the subcomponents. The monorepo cataloger's exclude_paths default skips the root entry, so the two never write the same component id.

If you sync from the Backstage API instead, component_id_annotation is the bridge between your catalog's identity and Lunar's — and, per Component identity above, its default of github.com/project-slug is repo-level only. Point it at a field that carries the subdirectory before running it against a monorepo.

For a home-grown catalog you write the cataloger yourself. Here is a basic example.

Mapping onto Lunar's model

Three mapping decisions are worth making deliberately:

Domains. Lunar's domains are hierarchical and dotted (engineering.payments.ledger). If your catalog expresses hierarchy by reference rather than by path — Backstage's subdomainOf and spec.system, for example — the cataloger has to resolve those references into a dotted path. Sync every level of the hierarchy, or a reference to an unsynced parent falls back to a bare name and the tree flattens.

Tags. Namespace imported tags with a prefix, which the shipped catalogers do by default (bs- for Backstage, gh- for GitHub topics). This keeps imported data distinguishable from tags you assign deliberately, and it doubles as gap detection: a component carrying gh- tags but no bs- tags is provably absent from Backstage, and on: supports NOT, so you can target exactly that population.

Precedence. Catalogers merge in declaration order, last one wins, and then lunar.yml 🚧 Coming Soon and lunar-config.yml are applied on top. Declare the broad, low-confidence source first and the specific, high-confidence source last.

lunar-config.yml being the highest-precedence layer is what gives the central platform team a way to augment and correct the catalog without waiting on the upstream system. The merge is per field, so setting owner on a component there overrides just that field — the domain, tags, and everything else keep tracking the cataloger. Use it to pin a value you know the upstream has wrong, or to add metadata the upstream cannot express, and leave the rest to sync.

3.2 Validate the ingested catalog

Optional.

Structural validity is not the same as being correct. Once the data is flowing, add policies for the semantics that only matter because Lunar now depends on them.

The checks worth having here are referential integrity — a component pointing at a domain or system that does not exist — and required tag patterns, which is how you force the classification decisions that scoping depends on. Requiring a data-classification/* tag makes every team state whether their service handles sensitive data, which is exactly the input you need before you can scope a compliance guardrail to it.

3.3 Enrich with heuristics

Optional, and the highest-leverage of the optional steps if large parts of your catalog are unclassified.

Some of what you need to know was never written down anywhere. Rather than asking every team to backfill it, derive it.

A component-cron cataloger can read a component's accumulated Component JSON and classify from evidence Lunar already collected — a deployment in a production Kubernetes namespace, a release pipeline that has actually run, an ingress reachable from the internet:

A component-repo cataloger with clone-code: true does the same from repository signals rather than collected data — the presence of a deploy workflow, a Helm chart, a Terraform production variable file.

Treat heuristics as a supplement, not a replacement. Where a heuristic and a declared value disagree, that disagreement is itself worth a check: a component that deploys to production but is declared experimental is a catalog defect, and often an interesting one.

3.4 Declare paths for monorepo components

Optional, and only relevant if you have monorepos.

The paths field tells Lunar which file changes affect which component. In a monorepo, this determines whether a component's collectors run at all when a commit lands.

A monorepo component automatically gets an implicit <subdir>/* pattern derived from its own name, and by default that is the whole of its trigger set. A component whose only pattern is services/payments/* will not re-evaluate when a shared library it depends on changes. Its checks stay green against stale data. The commit is recorded as handled for that component rather than left pending, so nothing hangs and nothing warns — the result is simply out of date.

paths is how you widen that set. The implicit subdirectory pattern is always present, so entries you add are additional triggers on top of it: in a monorepo, paths only ever expands what the component reacts to, and can never narrow it below its own subdirectory. Declare the shared code and configuration that should also trigger re-evaluation:

Matching is deliberately simple: a single trailing * is a string prefix match, and an entry without one must match a changed path exactly.

The one case where paths narrows rather than widens is a repository-level component — one with no subdirectory in its name, and so no implicit pattern. Those match every change by default, and setting paths on one restricts it to just the entries you list. Worth knowing, but it is the opposite of the monorepo case above and rarely what you want.

Who should own this. The people who know which shared code a component depends on are the component's owners, not the platform team. Maintaining these lists centrally in lunar-config.yml does not scale and goes stale immediately. Put the field in the catalog file the team already edits — catalog-info.yaml, lunar.yml 🚧 Coming Soon, or your internal equivalent — and wire it through the pipeline you have already built:

  • Step 2.1 collects the declared paths along with the rest of the entry.

  • Step 2.2 validates them: correct type, paths that exist, no attempt to reach outside the repository.

  • Step 3.1 emits them to .components["<id>"].paths so Lunar's change detection actually uses them.

Without that last wiring the field is inert documentation. It has to reach the Catalog JSON to have any effect.

Putting it together

See also

Last updated