Cross-repo: a hub and its spokes

A common shape: one hub application repo, and many spoke deployment/config repos that each pin a version of it and override its configuration. Roteiro joins their per-repo graphs at the workspace — a query-time join, never a merged store, so each repo's isolation holds — and answers the questions that boundary raises: which deployment overrides serve.addr, and to what? which reference config keys the app no longer defines? and are they drifting from the version they actually deploy? (ADR-0009).

A spoke, as Roteiro sees it

A deployment repo is mostly config plus a pinned version — nothing to hand-author. roteiro sync extracts it into graph nodes:

# deploy-web/ — a spoke repo
prod.env               # SERVE_ADDR, SERVE_TOOLS, …    → config_key nodes
k8s/deployment.yaml    # image, env, ConfigMap data    → config_key nodes
Dockerfile             # FROM registry/app:1.4         → image_ref  (the version it pins)
app/ (+ .gitmodules)   # the hub vendored @ <sha>       → submodule  (a version pin)
roteiro.toml           # [[links]] and [pins]          (optional)

YAML is mined, not blindly flattened: a Kubernetes manifest yields the container image, its literal env, and ConfigMap/Secret data — the settings a deployment actually overrides — while a Helm values.yaml flattens like any config. Secret values are redacted. Inspect exactly what was extracted with roteiro query --kind config_key (or --kind image_ref / --kind submodule).

See the overrides & drift at a glance

Point links at your workspace. --matrix renders every hub key against each spoke that overrides it, plus a drift list of keys the app no longer defines — as a text table, --json, or a self-contained --html page you open in a browser:

roteiro links --matrix --hub app --workspace ~/code

# cross-repo config overrides (hub: app, 2 spoke(s))
#
#   serve.addr = 127.0.0.1:8017
#     ≠ deploy-web:   0.0.0.0:8443     (0.90)   ← a real override
#     = deploy-batch: 127.0.0.1:8017   (0.98)   ← redundant restatement
#
#   drift — 1 orphan key(s):
#     deploy-web: DB_PASSWORD = <redacted>      ← the app doesn't define it

Gate it in CI, or let it infer

For links that must never silently drift (a TLS path, the served model), declare them in the spoke and fail the build when a target vanishes — the authored-vs-reality check of roteiro check, run between repos:

roteiro links --workspace ~/code   # resolves every [[links]] entry; exits non-zero on drift
roteiro links --workspace ~/code --write
                                   # …and persist them as durable `authored`
                                   #    edges — gold, not candidates

# Or skip the authoring — match config keys automatically:
roteiro links --infer --hub app    # correspondences + orphan (drift) keys, confidence-scored
roteiro links --infer --write      # …and persist them as durable inferred
                                   #    cross-repo edges that survive re-sync

Without --write, links only reports — it is a CI gate, and a gate that mutates as a side effect is a surprise, the more so because it writes into the other repositories' graphs. With it, each resolved declaration becomes an authored edge that survives re-sync and is replaced wholesale on the next run, so deleting a [[links]] entry removes its edge.

The authored and inferred layers are independent: running one never reclassifies or deletes the other's edges, which is what makes the authored / inferred distinction worth reading. A declaration with no from anchor has nothing local to attach an edge to; it is still reported, and the run says how many were skipped for that reason.

Resolve against the version actually deployed

A spoke rarely runs the hub's latest — it deploys a pinned version. Measuring drift against the hub's HEAD is then wrong twice: a key renamed since the spoke deployed looks like drift when it's fine, and a key the deployed version dropped is missed. --pinned reads each spoke's own pin (its submodule sha, or its Docker image tag) and resolves that spoke against exactly the hub version it vendors — materialising the hub graph at that commit in memory, no checkout:

roteiro links --infer --pinned --hub app --workspace ~/code

# deploy-web — 4 match(es), 0 orphan(s)  @ 4e0d5a6afd (via submodule app)

# …or pin one explicit version for the whole workspace:
roteiro links --infer --hub app --hub-rev v1.4

The same resolution works on the matrix — the side-by-side view — which is where it earns the most: spokes on different hub versions are exactly the case a single shared version misreports. Each column names the version it was measured against, and every cell is compared to that version rather than to HEAD:

roteiro links --matrix --pinned --hub app --workspace ~/code

# cross-repo config overrides (hub: app, 3 spoke(s))
#   resolved per spoke against the hub version each pins (2 of 3 pinned one):
#     deploy-dev @ HEAD (no pin detected)
#     deploy-eu @ v2.1.0
#     deploy-web @ 4e0d5a6afd (via submodule app)

A spoke that pins nothing is named, not omitted, and the count says how many pinned anything — so a workspace where nothing is detectable reads as 0 of 3 pinned one rather than looking identical to an ordinary run. The hub column stays a single column showing HEAD and says so; a cell measured against a different version carries that version beside it.

--pinned cannot be combined with --hub-rev: one version for every spoke and each spoke's own version are opposite requests.

When image tags don't match your git tags, map them in [pins] (app = "release-{tag}", shown in Config). And if the hub's CI publishes a graph artifact per release, resolution loads that instead of re-extracting — so it resolves even against a shallow clone that lacks the pinned commit's blobs.