NxtSoftLabs
← All writing

Cross-service contracts: env vars, tables, headers, JWTs

October 13, 2026·11 min read

Two services can be coupled without either one calling the other. One writes a table the other queries, one signs a JWT claim the other reads, one sends an X-Tenant-ID header the other trusts, and both read the same ACCOUNTS_URL. Since bin-v0.8.1, CGraph records each of those as a contract in the repository's own graph, and cgraph seam discover joins them across repositories, so a cross-service impact query starting from a table or a token signer reaches the code in the other services that depends on it.

HTTP endpoints were only the first contract

The first seam post covered one kind of edge between services: an HTTP endpoint that one repository serves and another calls. That's the coupling everyone draws on the architecture diagram. The ones that cause trouble are usually the ones nobody draws:

  • A reporting worker runs SELECT ... FROM invoices against a table another service's migration owns.
  • A web app decodes roles from a token the accounts service signs.
  • A client sends X-Tenant-ID and a server reads it to pick a tenant.
  • Two services read the same DynamoDB table, one writing and one reading.
  • Several services read ACCOUNTS_URL, which names one of the other services.

Rename the column, the claim, or the header, and nothing in the caller's repository changes. A per-repo graph can't see the edge, because the edge isn't in either repository. It's in the agreement between them.

Five more contract kinds

The CGraph work behind this shipped as Phase 3 of its cross-service plan: a shared foundation (#158, bin-v0.7.3), then one extractor per kind: env reads (#161), tables and graph labels (#162), headers (#163), and JWT claims (#164), all in bin-v0.8.0. DynamoDB tables (#168) and issuer-scoped claims (#169) followed in bin-v0.8.1.

Every extractor records the same two facts: this code provides a contract, or this code uses one. Each contract becomes a node with a plain id. What differs is when two repositories' ids are allowed to meet:

KindIdProvided byUsed byJoins across repos
Headerheader:x-tenant-ida server reading ita client sending itby name, except standard HTTP headers
JWT claimclaim:rolesa signer (jsonwebtoken, jose SignJWT, jjwt, PyJWT)a typed decoder and its readsby name, except standard claims; scoped by --issuer
SQL tabletable:local:invoicesa .sql migration that creates ita query string, a mirrored Drizzle modelonly inside a --database you declare
DynamoDB tabledynamo:sessions-deva write (PutCommand, ...)a read (GetCommand, ...)by name, no declaration
Env varenv:ACCOUNTS_URLthe service you nameevery readonly when --env declares it

Neo4j graph labels follow the table rule, as label:<db>:User.

The rules are deliberately uneven. Headers and claims carry their meaning in their name, so x-tenant-id in two repositories is very likely the same agreement. Table names and env names aren't: every service reads NODE_ENV, and two repositories with a users table may be talking to different databases. Nothing in the code proves those join, so CGraph doesn't guess. You declare them where repositories meet, and the per-repo graph never changes.

A three-service example

To show this end to end, we wrote three small services and ran the released bin-v0.8.3 binary over them. accounts (TypeScript) owns the data and signs tokens. web (TypeScript) calls it. reports (Python) queries its database. The contract-relevant lines:

// accounts/migrations/001_invoices.sql
CREATE TABLE invoices (id uuid PRIMARY KEY, tenant_id text NOT NULL, total numeric NOT NULL);

// accounts/src/tenant.ts (called from the GET /invoices route in src/server.ts)
export function tenantOf(request: Request): string | null {
  return request.headers.get('x-tenant-id');
}

// accounts/src/token.ts
return new SignJWT({ tenant_id: user.tenantId, roles: user.roles })
  .setSubject(user.id)
  .setExpirationTime('1h')

// accounts/src/sessions.ts
const SESSIONS_TABLE = process.env.SESSIONS_TABLE || 'sessions-dev';
await client.send(new PutCommand({ TableName: SESSIONS_TABLE, Item: session }));
// web/src/api.ts
const ACCOUNTS = process.env.ACCOUNTS_URL;
fetch(`${ACCOUNTS}/invoices`, { headers: { 'X-Tenant-ID': tenantId, Authorization: `Bearer ${token}` } });

// web/src/session.ts
export interface SessionClaims { sub: string; tenant_id: string; roles: string[] }
export function readSession(token: string): SessionClaims {
  return jwtDecode<SessionClaims>(token);
}

// web/src/nav.ts
const session = readSession(token);
return session.roles.includes('billing');

// web/src/sessionCache.ts
await client.send(new GetCommand({ TableName: SESSIONS_TABLE, Key: { id } }));  // same const as accounts
# reports/reports/job.py
ACCOUNTS = os.environ["ACCOUNTS_URL"]
cur.execute("SELECT SUM(total) FROM invoices WHERE tenant_id = %s", (tenant_id,))
requests.get(f"{ACCOUNTS}/tenants/{tenant_id}", headers={"X-Tenant-ID": tenant_id})

Both TypeScript services also read process.env.NODE_ENV, and accounts reads the standard authorization header. Those are there on purpose, to check that they stay home.

Each repository gets its own graph, built the usual way (cgraph --root accounts --out out/accounts, and so on). In accounts, the contract edges look like this (the contains edges and the endpoint's own edge are left out):

table:local:invoices -handled_by-> sql_table_invoices
dynamo:sessions-dev -handled_by-> src_sessions_ts_savesession
header:x-tenant-id -handled_by-> src_tenant_ts_tenantof
claim:exp -handled_by-> src_token_ts_issuesession
claim:sub -handled_by-> src_token_ts_issuesession
claim:tenant_id -handled_by-> src_token_ts_issuesession
claim:roles -handled_by-> src_token_ts_issuesession
src_sessions_ts -CONSUMES-> env:SESSIONS_TABLE
src_tenant_ts_isdev -CONSUMES-> env:NODE_ENV

There's no header:authorization. Standard headers aren't recorded at all. setSubject and setExpirationTime are recorded as claim:sub and claim:exp, which matters below.

Discover with no declarations

seam discover reads the three graphs and writes a seam fragment. With nothing declared:

cgraph seam discover --graph accounts=out/accounts/graph.json \
  --graph web=out/web/graph.json --graph reports=out/reports/graph.json --out seam-bare
  matched 1 endpoints (served by one service, consumed by another or itself); 0 consumed with no provider among these graphs; 0 served with no consumer
  other contracts (tables, graph labels, headers, claims, env, DynamoDB tables): matched 4; 0 used with no provider among these graphs; 0 provided with no user
seam discover: wrote "seam-bare/chunk_00.json" (18 nodes, 23 edges)

The four are the contracts that join by name: header:x-tenant-id, claim:tenant_id, claim:roles and dynamo:sessions-dev. The invoices table and ACCOUNTS_URL aren't joined. After seam fuse, they stay scoped to each repository (accounts::table:local:invoices, reports::table:local:invoices, web::env:ACCOUNTS_URL, reports::env:ACCOUNTS_URL), and an impact query from the table stops at its own repository:

total=1
  d1 handled_by accounts::table:local:invoices

That's the correct answer for what you told it. Nothing in the code says reports connects to the database accounts migrates.

Declare what the code can't prove

The same command with three declarations:

cgraph seam discover --graph accounts=out/accounts/graph.json \
  --graph web=out/web/graph.json --graph reports=out/reports/graph.json \
  --database billing=accounts,reports \
  --env ACCOUNTS_URL=accounts \
  --issuer accounts=accounts,web \
  --out seam-iss
  other contracts (tables, graph labels, headers, claims, env, DynamoDB tables): matched 6; 0 used with no provider among these graphs; 0 provided with no user
  database billing (accounts,reports): 2 repo-local tables and labels joined at the database's id
  issuer accounts (accounts,web): 4 claims joined at the issuer's id
seam discover: wrote "seam-iss/chunk_00.json" (24 nodes, 32 edges)

Each flag says one thing the code can't:

  • --database billing=accounts,reports: these two repositories use the same database, so each one's table:local:invoices becomes table:billing:invoices where they meet.
  • --env ACCOUNTS_URL=accounts: this variable addresses the accounts service, which becomes its provider (SERVED_BY service:accounts).
  • --issuer accounts=accounts,web: accounts mints the tokens web reads, so their claims meet at claim:accounts:<name> and never join a repository outside that issuer.

The joined contracts in the seam, with every edge that touches them:

table:billing:invoices -HANDLED_BY-> sql_table_invoices
table:billing:invoices -CONSUMED_AT-> reports_job_py_monthly_total
header:x-tenant-id -HANDLED_BY-> src_tenant_ts_tenantof
header:x-tenant-id -CONSUMED_AT-> src_api_ts_listinvoices
header:x-tenant-id -CONSUMED_AT-> reports_job_py_fetch_tenant
claim:accounts:roles -HANDLED_BY-> src_token_ts_issuesession
claim:accounts:roles -CONSUMED_AT-> src_session_ts_sessionclaims_roles
claim:accounts:roles -CONSUMED_AT-> src_nav_ts_canseebilling
claim:accounts:tenant_id -HANDLED_BY-> src_token_ts_issuesession
claim:accounts:tenant_id -CONSUMED_AT-> src_session_ts_sessionclaims_tenant_id
dynamo:sessions-dev -HANDLED_BY-> src_sessions_ts_savesession
dynamo:sessions-dev -CONSUMED_AT-> src_sessioncache_ts_loadsession
env:ACCOUNTS_URL -SERVED_BY-> service:accounts
env:ACCOUNTS_URL -CONSUMED_AT-> src_api_ts
env:ACCOUNTS_URL -CONSUMED_AT-> reports_job_py

(SERVED_BY and the service-level CONSUMES edges are left out above, except for the env var, where SERVED_BY is the whole point of the declaration.)

seam fuse has to be given the same declarations. Leave them off and it refuses rather than splitting the seam in half:

seam fuse: ERROR: 9 seam contract edge(s) would split: seam contract table:billing:invoices (HANDLED_BY in accounts) is held by accounts under none of the ids fuse's declarations give it; discover and fuse were given different --database declarations, pass both the same

With them, it writes one fused graph (46 nodes, 86 edges).

The impact query now crosses

Here are the same three questions, asked of the accounts graph alone and then of the fused graph, using cgraph seam query --graph <graph.json> impact '{"id": ..., "direction": "dependents"}'.

Changing the invoices table (sql_table_invoices). Alone, the only dependent is the table's own contract node (total=1). Fused:

  d1 HANDLED_BY table:billing:invoices
  d2 CONSUMES reports::reports_job_py_monthly_total
  d2 CONSUMES service:reports
  d3 contains reports::reports_job_py

Changing what issueSession puts in the token. Alone, total=5: the file and the four claim nodes. Fused, total=11:

  d1 HANDLED_BY claim:accounts:roles
  d1 handled_by accounts::claim:exp
  d1 handled_by accounts::claim:sub
  d1 HANDLED_BY claim:accounts:tenant_id
  d2 CONSUMES web::src_nav_ts_canseebilling
  d2 CONSUMES web::src_session_ts_sessionclaims_roles
  d2 CONSUMES web::src_session_ts_sessionclaims_tenant_id
  ...

canSeeBilling is the function in web that breaks if roles is renamed, and it's now two hops from the signer. The contains edges for each file and service:web are in the full output; they're cut here.

Changing how tenantOf reads the tenant header. Fused, total=11. Both senders of the header are in it, along with the endpoint whose handler calls tenantOf:

  d1 HANDLED_BY header:x-tenant-id
  d2 CONSUMES web::src_api_ts_listinvoices
  d2 CONSUMES reports::reports_job_py_fetch_tenant
  d2 HANDLED_BY endpoint:GET /invoices

In this fixture reports neither serves nor calls a resolved endpoint (discover logs service reports: serves 0 endpoints, consumes 0). Its only links to accounts are the table, the header, and the env var, so a seam built from endpoints alone would have had no edge between them at all.

What stays home

The same run shows what didn't join, and each case is a rule, not an accident:

  • Standard headers. authorization isn't recorded. The foundation's list is the IANA permanent HTTP field names plus common infrastructure headers (x-request-id, x-forwarded-*, traceparent). Every service uses those for its own reasons, so a shared name proves nothing.
  • Registered JWT claims. claim:sub and claim:exp stay accounts::claim:sub and accounts::claim:exp in the fused graph, even inside the declared issuer. Every token carries them. Outside an issuer, the wider set of standard claims (email, name, scope, ...) doesn't join either. Inside one, those do.
  • Undeclared env names. NODE_ENV and SESSIONS_TABLE are read in two repositories and joined nowhere.
  • Undeclared tables. table:local:invoices stays local until a --database puts two repositories together.

This is the same principle as refusing to guess a call target: a missed edge is visible and fixable with one flag, but a wrong edge sends an impact query into code that has nothing to do with the change.

Two things building the example taught us

Neither of these is a bug, but both cost us a rebuild, and they'll cost you one too if you don't know them.

A header read nothing calls provides nothing. In the first version of accounts, tenantOf read x-tenant-id but no route called it. The graph had no header:x-tenant-id node, and stats.json said why: "contract_reads_unreached":1. A header read counts only in code that something reaches (a call, an import, a reference, or a route handler), because a helper nobody calls serves no request (contracts.cpp, step 8). Adding the GET /invoices route that calls it produced the node and dropped the count to 0.

A decoder needs a type the extractor can follow. In the first version of web, readSession had no declared return type. The SessionClaims fields became claim uses, but session.roles in nav.ts didn't, so canSeeBilling was missing from the seam. A decoder's claims type comes from a returned x as T, or from the declared return type when it's one same-file type (claim_contracts.cpp, js_decoder_types). Adding : SessionClaims fixed it.

Limits

These come from the PRs' non-goals and the README, and all of them are still true in bin-v0.8.3:

  • Env: no pydantic BaseSettings fields, no non-literal names (process.env[key]), and no .env, docker-compose, or Kubernetes templates.
  • SQL: only upper-case SQL that opens with SELECT / INSERT / UPDATE / DELETE / WITH is read. Lower-case SQL and JPQL in a JPA @Query are skipped. String literals in test files are never read.
  • Headers: a header name in a constant imported from another file isn't followed. Only same-file constants are.
  • DynamoDB: only TypeScript and JavaScript. Python boto3 isn't read. A dynamo: name joins every repository you pass, whatever AWS account or region each one runs in, so two services in different accounts that each have a sessions table would join.
  • Claims: no Python consumers (jwt.decode(...)["x"]), no untyped decoders, and no issuer inferred from signing keys or JWKS URLs. You declare it.

Try it

Build a graph for each service that shares something with another, run cgraph seam discover with no flags, and read the other contracts line. That tells you what already joins by name. Then add one --database or --env for a coupling you know exists, and ask impact about the migration or the signer you're most nervous about changing. The seam and contracts sections of the CGraph README list every shape each extractor reads, and the first seam post covers fusing and querying in more depth.