ADR-154: SaaS inter-service link graph (templates declare contracts; factory composes + validates)
Status: Proposed Date: 2026-05-06 Related: ADR-013 (templates in dark-factory), ADR-123 (templates as generator), ADR-117 (module guidelines), warp#1592 (parent EPIC), warp#1551 (sister: sitemap-as-truth), warp#1559 (sister: EFF-7 contract-gate), warp#1407 (sister: changelog versioning discipline)
Context
Sub-ticket of EPIC warp#1592 Phase 0. Phase 0 ships three sister ADRs that lock the SaaS template library's contracts before any Phase 1 template ships: T0.1 names the template format (template.yaml), T0.2 names the per-SaaS intent spec (intent.yaml), and this ADR (T0.4) names how those two compose into a verifiable inter-service link graph.
Operator (jon, 2026-05-06) framed the architectural insight: "an automatic creation of the infra and the templates for the saas and links between them" + "we can create the k8s from the templates and data which is made live". The k8s manifests, ingress, network policies, secret bindings, and observability wiring are NOT separately maintained alongside templates. They are derived outputs of (templates × intent × link-graph). A SaaS author writes intent; the factory composes templates + intent through the link graph; the link graph emits the cluster-shaped artefacts.
Today the same problem is solved by hand-crafted helm + per-service deploy reviews. Each new service authors its own NetworkPolicy, picks its own service-discovery convention, manages its own secret mounts, and relies on operator memory to verify that consumer A can actually reach producer B. The failure modes are all the same shape: a consumer ships against a producer's contract that has drifted; a NetworkPolicy is missing the egress rule; a secret mount references the wrong key; a tracing parent-child relationship is silently broken. Each is invisible until the wire is live.
The link graph closes that gap structurally. Templates declare their exposed: and consumed: contracts at template-author time. The SaaS's intent.yaml declares the bindings — which service consumes which exposed contract. The factory composes the graph at factory saas bootstrap time and validates it before emitting any cluster artefact. Sister to warp#1551 sitemap-as-truth (the same gate-shape applied to routes), warp#1559 EFF-7 (the same fail-fast contract gate philosophy), and warp#1407 changelog discipline (the same versioning rule, applied to contracts).
This ADR locks the link graph's shape so T1.1–T1.4 (Phase 1 templates: bff-graphql-go, backend-rest-go, frontend-sveltekit, worker-go) implement against a stable contract.
Decision
Templates declare contracts. Intent declares bindings. Factory composes a link graph and validates it before emitting any cluster artefact. A SaaS that fails link-graph validation does not produce manifests; the factory exits non-zero and reports the unsatisfied edge.
The shape has four parts: contract declarations in templates, binding declarations in intent, validation rules at compose time, and generated artefacts on validation pass.
1. Template-declared contracts (in template.yaml)
Every template declares exposed: and consumed: blocks. Each entry is a named contract with a typed shape, an auth model, and a version. The names are the join keys for the link graph.
# saas-templates/bff-graphql-go/template.yaml
name: bff-graphql-go
version: 1.0.0
exposed:
- name: graphql-public
kind: graphql # rest | graphql | event | signal
version: 1
schema_ref: schema/public.graphqls # template-relative path
transport:
protocol: https
port: 8080
path: /graphql
auth:
mode: bearer # none | bearer | mtls | hmac
issuer: ${INTENT.auth.issuer} # intent-supplied
visibility: external # internal | external
rate_limit:
requests_per_min: 600
- name: health
kind: rest
version: 1
schema_ref: schema/health.openapi.yaml
transport: { protocol: http, port: 8080, path: /healthz }
auth: { mode: none }
visibility: internal
consumed:
- name: customer-api
kind: rest
version_constraint: "^1" # semver range
schema_ref: schema/customer-api.openapi.yaml
auth: { mode: bearer }
timeout_ms: 2000
retry: { attempts: 3, backoff: exponential }
- name: events-bus
kind: event
version_constraint: "^2"
schema_ref: schema/events.avsc
auth: { mode: mtls }
Field rules:
name— globally unique within the template family;[a-z][a-z0-9-]{1,38}[a-z0-9]. Used as the join key.kind— one ofrest | graphql | event | signal. Phase 0 fixes these four; new kinds require an ADR.version(exposed) andversion_constraint(consumed) — semver. Producers declare a single emitted version; consumers declare a constraint range. Compatibility is checked at validation.schema_ref— template-relative path. The schema file MUST exist; the validator parses it and uses it for type-compatibility checks.transport— protocol + port + path (REST/GraphQL) or topic (events). Informs ingress andNetworkPolicygeneration.auth— declares the auth model the producer enforces / the consumer presents. Modes are fixed (none | bearer | mtls | hmac); custom modes are a separate ADR.visibility(exposed only) —internal(cluster-only, no ingress) orexternal(ingress + cert-manager + rate-limit defaults).${INTENT.x.y}— intent-supplied substitution; the validator refuses unsubstituted placeholders at compose time.
2. Intent-declared bindings (in intent.yaml)
The SaaS's intent.yaml declares which services it deploys, which template each uses, and the bindings: block that wires consumers to producers.
# customer-portal/intent.yaml
saas: customer-portal
version: 0.4.0
services:
- name: portal-bff
template: bff-graphql-go@1.0.0
values:
replicas: 2
auth: { issuer: https://auth.example.com }
- name: customer-api
template: backend-rest-go@1.2.0
values:
db: { engine: postgres, name: customers }
- name: events-bus
template: external # not satisfied by a service in this SaaS
external: true
url: nats://events.shared.svc.cluster.local:4222
auth_secret: events-mtls-cert
schema_ref: external/events.avsc
version: 2.3.0
bindings:
- consumer: portal-bff.customer-api
producer: customer-api.rest-public
- consumer: portal-bff.events-bus
producer: events-bus # external — name resolves at top level
Binding-block rules:
consumeris<service-name>.<consumed-name>(the consumed-block name from the consumer's template).produceris<service-name>.<exposed-name>(the exposed-block name from the producer's template) OR a top-levelexternal: trueentry.- Multi-binding is allowed: two
consumer:lines may name the sameproducer:(service A and service B both consume contract X from service C). Two producer lines for the sameconsumer:is rejected — a consumer binds to exactly one producer per consumed contract. - An
external: trueservice declaresurl,auth_secret,schema_ref,version. It satisfies aconsumed:edge but produces no cluster manifests itself; ingress andNetworkPolicyegress rules are emitted instead.
3. Validation rules (at factory saas bootstrap and factory saas validate)
The factory composes the link graph from intent + templates and runs five validation passes. All five MUST pass before any manifest is emitted; failure modes report the unsatisfied edge with <service>.<contract> precision.
Coverage. Every
consumed:entry on every service has exactly one matching binding. Every binding'sproducer:resolves to either anexposed:entry on a deployed service or anexternal: trueentry. An unresolved consumer is a hard error.Type compatibility. The producer's
schema_refis a structural superset of the consumer'sschema_reffor that contract version. Concretely: for REST/GraphQL, every field the consumer reads MUST exist on the producer with a compatible type; the producer MAY emit additional fields (forward-compatible). For events, the consumer's required Avro fields MUST exist with compatible types on the producer; producer-side optional additions are allowed. Reverse direction (consumer requires a field producer does not emit) is a hard error.Version compatibility. Producer's
versionsatisfies consumer'sversion_constraint(semver range). A consumer pinning^1against a producer at2.0.0fails — the migration path (see §6) is the only resolution.Auth alignment. Consumer
auth.modeMUST match producerauth.mode. Mismatches are hard errors (no implicit auth-stripping). Forbearer, the consumer'sissuer(resolved via intent substitution) MUST match the producer's accepted issuer set.Network reachability. Each binding produces a directed edge
(consumer-namespace, producer-namespace). The validator checks that the cluster'sNetworkPolicydefaults (per warp#1588 cluster-bootstrap) admit the edge OR generate an explicit allow-rule (see §4). External-service edges check that egress to the declaredurlis permitted by the namespace's egress allow-list.
4. Generated artefacts (link-graph as derivation engine)
On validation pass, the factory emits cluster manifests as a function of the link graph. The graph is the source of truth; the manifests are derived. A change to a template contract or an intent binding re-derives the manifests on the next bootstrap.
- NetworkPolicies. One
Allowrule per validated binding edge. Default-deny baseline (per warp#1588) gives a deny-all stance; the link graph's edges are the only allow-rules. Egress rules toexternal: trueservices target the declaredurl. - Ingress routes. One ingress rule per
exposed:entry withvisibility: external. Cert-managerIssuerannotations + rate-limit defaults from the contract'srate_limit:block. Internal-visibility contracts get NO ingress. - Service-discovery DNS / env. For each binding, the consumer's deployment receives
<CONTRACT_NAME>_URL=https://<producer-service>.<namespace>.svc.cluster.local:<port><path>(or the externalurl). Env-var name derived deterministically fromconsumed:entry name (uppercase, hyphens-to-underscores). - Secret mounts. For each binding requiring auth (bearer / mtls / hmac), the consumer's deployment mounts the producer's auth secret at a deterministic path:
/etc/gyrum/contracts/<contract-name>/<credential-key>. External services use the declaredauth_secretname. - Observability traces. The link graph declares the distributed-tracing parent-child relationships. The factory emits OpenTelemetry resource attributes (
gyrum.contract.consumer,gyrum.contract.producer,gyrum.contract.name) on every binding so traces are link-graph-correlatable. Sister to ADR-110's observe-and-file pattern: invariant violations on a binding (auth fail, type-compat fail at runtime) emit a warp ticket via the observer channel. - GraphQL federation. When two
kind: graphqlservices co-exist withvisibility: external, the factory emits an Apollo Router federation manifest stitching the twographql-publiccontracts. Stitching is at the contract layer, not at hand-edited manifest layer.
5. External dependencies
A consumed: edge that no in-SaaS service exposes is satisfied by an external: true entry in intent.yaml. The external entry MUST declare:
url— fully-qualified target.auth_secret— name of a Kubernetes Secret holding the credential; the factory verifies it exists (or is declared in the SaaS's Secret-creation manifest) at validate time.schema_ref— local copy of the upstream contract schema, version-pinned. The factory still runs type-compat (rule 2) against this schema.version— the upstream version pinned for compatibility checks.
External entries do NOT auto-generate ingress (no inbound traffic) but DO generate egress NetworkPolicy rules and Service-discovery env entries.
6. Versioning + breaking-change migration
Contracts versioned per semver. Producer bumps version; consumers' version_constraint ranges are checked at every factory saas validate. Sister to warp#1407 changelog gate.
- Patch / minor. Producer-side additive fields, new optional event fields, new endpoints. No consumer change required; type-compat rule (2) admits the change because consumers see a structural superset.
- Major (breaking). Producer publishes the new major version on a NEW exposed contract name (e.g.
customer-api-v2) running in parallel with the old contract. The old contract's exposed entry stays declared for the migration window; both endpoints serve traffic. Consumers migrate theirconsumed:entry to the new contract name + version on their own schedule. When all consumers have migrated, the producer ships a PR removing the oldexposed:entry. Validation fails at that point if any consumer still binds to the old name — fail-fast, the same gate-shape as sitemap-truth. - Migration window. Default: 30 days. SaaS-specific override via
intent.yamlpolicy.migration_window_days. The factory writes the deprecation timestamp into the renderedNetworkPolicyannotation so cluster-side observability can flag stale consumers. - Breaking-change PR template. A PR that drops or renames an
exposed:entry MUST include a list of consumers (resolved by querying the link graph across all known SaaS) and a target removal date. The persona reviewer (per ADR-115) refuses the PR if the deprecation contract isn't satisfied.
7. Validator implementation surface
The factory's validation runs in factory saas validate <name>. Implementation lives in factory-cli/internal/linkgraph/ (Phase 2, T2.2). The validator:
- Parses every
services[].template@versionto itstemplate.yaml. - Builds the directed graph: nodes =
(service, contract-name), edges = bindings. - Runs the 5 validation passes (coverage, type-compat, version, auth, reachability).
- On pass: emits manifests to
gyrum-clusters/<saas>/...and opens a PR (per Phase 2 T2.1). - On fail: prints the unsatisfied edges as
consumer.contract -> [unresolved | type-incompat: <field>] -> producer.contractand exits non-zero.
CI hook: every SaaS repo runs factory saas validate on PR. The validate-step is the contract gate; review-pr cannot pass it via --admin (per feedback_admin_override_with_audit.md, audited override only with operator-supplied written reason).
Consequences
What becomes easier:
- New SaaS authoring drops to: write
intent.yaml, runfactory saas bootstrap. Manifests, NetworkPolicies, ingress, secrets, and observability wiring derive automatically — no hand-crafting. - A consumer-vs-producer drift (a missing field, a renamed endpoint, a stale auth issuer) is caught at
factory saas validatetime — pre-deploy, in the PR — not as a 500-runtime in production. - Network-policy auditing is structural: every allow-rule traces to a declared binding. An undeclared edge cannot exist in the cluster.
- Distributed tracing is correlated by construction: every binding emits the same link-graph identifiers as OTel resource attributes.
- Breaking-change rollouts are gated: a producer cannot drop an exposed contract until every consumer has migrated. The link graph is the consumer registry.
- The
external: trueescape hatch is explicit and rare — in-SaaS services should bind to in-SaaS producers; external bindings stand out in code review.
What becomes harder:
- Templates carry a real authoring burden — every template MUST declare its
exposed:andconsumed:accurately, with versioned schema files committed in-tree. T1.1–T1.4 each carry this work. - Schema-file maintenance is now first-class: a template's
schema_refis the contract surface, and drift between the file and the running code is its own bug class. ADR-117 module guidelines extends to covertemplate-contract-authoring@v1. - The validator at
factory-cli/internal/linkgraph/is non-trivial code (parsing template.yaml, resolving versions, type-compat across REST/GraphQL/Avro). T2.2 carries the implementation; the regression suite is the load-bearing test. - Migration windows for breaking changes constrain producer release cadence — a producer cannot rev-major without 30 days of dual-running. Operators feel this as slower breaking-change rollouts; the tradeoff is no consumer breakage.
- External dependencies require a local schema copy that the SaaS pins. Drift from upstream is a SaaS-side responsibility; the factory cannot detect upstream drift, only consumer-vs-pinned-schema drift.
factory saas validatebecomes a P0-blocking CI gate for every SaaS. Outage of the validator is an outage of SaaS releases. Sister to warp#1559's review-pr gate operability.
What we sign up to operate:
- Versioned
saas-templates/<name>/template.yamlfiles with schema-file in-tree and CI checks for schema validity. - A versioned
intent.yamlschema (T0.2 ADR) that the validator consumes — schema bumps go through the same regression-corpus discipline as template bumps. - The link-graph validator implementation (T2.2) plus its regression corpus. Drift detection nightly per ADR-110 observer cadence.
- A
linkgraph-driftcron that re-runsfactory saas validatefor every active SaaS against the current template versions and files anomalies as warp tickets when a previously-passing SaaS starts failing (e.g. a template bump breaks a consumer). - A
breaking-change-deprecation-deadlinesdashboard surfacing the open migration windows and the consumers still bound to deprecated contracts.
Alternatives considered
- Hand-write NetworkPolicies, ingress, and secrets per service (today's status quo). The drift problem stays. Every new service repeats the boilerplate; consumer-vs-producer drift is invisible until production. Loses on every axis except short-term author effort.
- Use service-mesh sidecars (Istio / Linkerd) for traffic policy without a link-graph. Mesh handles runtime policy but not author-time validation: a consumer-vs-producer schema drift still ships, and the mesh discovers it as a 500 at request time. Mesh is complementary (it could enforce link-graph-derived policies at the data plane) but not a substitute for the author-time contract gate.
- GraphQL-only federation as the universal contract. Forces every service into GraphQL whether it fits or not (workers, event consumers, REST backends with strong OpenAPI shapes). Federation is the right answer for the GraphQL slice (§4 uses it for that slice) but the wrong universal substrate.
- Adopt OpenTelemetry's semantic conventions as the contract layer. Conventions describe trace shape, not API contract — they're complementary to the link graph (we emit them as resource attributes on derived traces) but don't validate consumer-producer wiring at compose time.
- Defer the link graph; ship Phase 1 templates first and add validation later. Phase 1 templates without contract declarations bake the inconsistency in; retrofitting
exposed:/consumed:to four shipped templates is harder than authoring it once at the start. Phase 0 ordering is intentional. - Cross-SaaS link graph from day 1 (Phase 4-deferred). Two SaaS's services binding across cluster / SaaS boundaries needs identity, multi-cluster network policy, and federation choreography. The bulk of the value lands at the in-SaaS level first; cross-SaaS is real but Phase 4-shaped.
Supersedes: none Superseded by: