Decisions

ADR-154: SaaS inter-service link graph (templates declare contracts; factory composes + validates)

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…

#154

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 of rest | graphql | event | signal. Phase 0 fixes these four; new kinds require an ADR.
  • version (exposed) and version_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 and NetworkPolicy generation.
  • 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) or external (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:

  • consumer is <service-name>.<consumed-name> (the consumed-block name from the consumer's template).
  • producer is <service-name>.<exposed-name> (the exposed-block name from the producer's template) OR a top-level external: true entry.
  • Multi-binding is allowed: two consumer: lines may name the same producer: (service A and service B both consume contract X from service C). Two producer lines for the same consumer: is rejected — a consumer binds to exactly one producer per consumed contract.
  • An external: true service declares url, auth_secret, schema_ref, version. It satisfies a consumed: edge but produces no cluster manifests itself; ingress and NetworkPolicy egress 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.

  1. Coverage. Every consumed: entry on every service has exactly one matching binding. Every binding's producer: resolves to either an exposed: entry on a deployed service or an external: true entry. An unresolved consumer is a hard error.

  2. Type compatibility. The producer's schema_ref is a structural superset of the consumer's schema_ref for 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.

  3. Version compatibility. Producer's version satisfies consumer's version_constraint (semver range). A consumer pinning ^1 against a producer at 2.0.0 fails — the migration path (see §6) is the only resolution.

  4. Auth alignment. Consumer auth.mode MUST match producer auth.mode. Mismatches are hard errors (no implicit auth-stripping). For bearer, the consumer's issuer (resolved via intent substitution) MUST match the producer's accepted issuer set.

  5. Network reachability. Each binding produces a directed edge (consumer-namespace, producer-namespace). The validator checks that the cluster's NetworkPolicy defaults (per warp#1588 cluster-bootstrap) admit the edge OR generate an explicit allow-rule (see §4). External-service edges check that egress to the declared url is 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 Allow rule 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 to external: true services target the declared url.
  • Ingress routes. One ingress rule per exposed: entry with visibility: external. Cert-manager Issuer annotations + rate-limit defaults from the contract's rate_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 external url). Env-var name derived deterministically from consumed: 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 declared auth_secret name.
  • 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: graphql services co-exist with visibility: external, the factory emits an Apollo Router federation manifest stitching the two graphql-public contracts. 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 their consumed: entry to the new contract name + version on their own schedule. When all consumers have migrated, the producer ships a PR removing the old exposed: 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.yaml policy.migration_window_days. The factory writes the deprecation timestamp into the rendered NetworkPolicy annotation 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@version to its template.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.contract and 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, run factory 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 validate time — 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: true escape 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: and consumed: 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_ref is the contract surface, and drift between the file and the running code is its own bug class. ADR-117 module guidelines extends to cover template-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 validate becomes 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.yaml files with schema-file in-tree and CI checks for schema validity.
  • A versioned intent.yaml schema (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-drift cron that re-runs factory saas validate for 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-deadlines dashboard 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: