Why this document exists

Everything below was read against source and production data, not against documentation. That distinction earned its keep: in the course of this review, two code comments and one set of acceptance criteria were confidently wrong, and one "obvious simplification" would have taken down three services.

IMPORTANT: Where docs, code comments and the running system disagree, the running system is the authority. Anything learned here should be written back to tawa-docs rather than re-derived.

Verified 2026-08-27 against iec-janus, iec-relay, iec-koko, iec-builder, the live cluster, and relay-prod message data.

The three identity axes

Every internal call carries three distinct identities. Two are separated correctly; the third is misnamed and that misnaming has caused real bugs.

AxisHeaderActually carriesAnswers
ServiceX-Service-IDOAuth client_id (e.g. policysign-prod)Who is calling; who gets a callback
Billing orgX-Caller-OrgorgSlug, after X-Forward-User resolutionWho pays
Caller orgX-Program-IDorgSlug — despite the nameWhich org the calling pod belongs to

WARNING: X-Program-ID does not carry a program or service id. Janus sets auth.programId = orgSlug in internalProxyAuthMiddleware. Anything using it as a service identifier is reading the wrong namespace.

Wallets are org-level. deriveWalletId() is literally wallet-{orgSlug} — services do not have wallets, orgs do. That is why billing resolves to an org, and it is correct.

Rule of thumb: bill the org, because that holds the wallet. Call back the service, because that is what registered. Never let one substitute for the other because a header was missing.

Gas is resolved entirely inside Janus

A recurring wrong guess is that downstream services re-verify tokens for billing. They do not. iec-relay contains no gas, wallet or billing code of any kind.

The whole path runs before the proxy hop, from the identity established by the first verification:

  1. resolveCallerOrg() — billing org from the JWKS-verified service token, or a JWKS-verified X-Forward-User for pass-through billing
  2. internalProxyGasCheckMiddleware → resolveBilling() (src/middleware/billing.ts, since iec-janus PR #25, 4 Oct 2026) — derives wallet-{orgSlug} and checks the balance. Platform org → platform-owned service (Koko owner: insureco) is free infrastructure, as is /internal/*. A platform-owned service client with no X-Forward-User and no wallet of its own bills the InsurEco system wallet (Derek, R3 extended); any other caller with no wallet gets one via /ensure and then promo → purchased → 402. A wallet or Koko error fails closed with 503 proxy_gas_unavailable; it used to fail open, which is why no transaction gas was charged between 22 Sep and 4 Oct (iec-janus#19). Every refusal is written to gas_events with charged: false and a reason.
  3. gasSettleMiddleware — debits 1 token on a 2xx only; non-2xx and timeouts are not charged. The same resolver guards the public gateway routes (gasCheckMiddleware), where the route's declared gas is the price.

Only Janus moves money (locked, 2026-09-24)

Decided by Derek: services declare route prices in catalog-info.yaml spec.routes (gas, charge). Janus is the only thing that debits or credits a wallet. No app may meter gas, call iec-wallet to move money, or hold wallet credentials. The only other movers are the platform billing jobs: the Janus hosting worker (iec-janus src/queues/hosting-worker.ts) and iec-cron schedule charges (iec-cron src/scheduler/manager.ts, GAS_PER_RUN = 5). A service that wants money moved makes the call through Janus. Canonical text: tawa-platform/reference/gas-wallet → "Only Janus moves money (locked)".

Re-verified 2026-09-24 against iec-janus ecedb16: the flat 1 token above is still true (INTERNAL_PROXY_GAS_COST = 1, src/routes/internalProxy.ts:16), which is now a known gap against the rule. Open work: iec-janus#10 (charge the target route's declared gas and payer on /i/), iec-koko#38 + iec-builder#182 (carry charge to Janus), iec-janus#11 (routes mounted only at startup), iec-janus#12 (Express strips the mount path, so gateway routes forward /), iec-wallet#12 (wallet accepts debit/credit only from Janus and the platform billing jobs).

Scopes are NOT enforced at runtime on internal calls

This is the most important correction in this document.

iec-relay guards /send with requireScope('relay:send'). That check never fires on a normal call:

  • internalAuth sees Janus's X-Program-ID / X-Service-ID and returns next() without setting res.locals.serviceScopes
  • requireScope bypasses whenever serviceScopes is undefined

And Janus does not enforce scopes on /i/ either. Its middleware stack is auth → sandbox-relay-block → internal-path-block → gas-check → service-context → gas-settle → proxy. Its app-level authzMiddleware only acts when route.scopes is non-empty, and the /i/ route synthesizes routeConfig = { owner: serviceName } with no scopes.

The relay comment says "scopes enforced at the Janus layer." That delegation was never built.

So what do scope grants actually do?

They are a deploy-time gate, and a real one:

  • The builder blocks a cross-org deploy until the target org approves the grant
  • A scoped dependency provisions OAuth client-credentials (BIO_CLIENT_ID / BIO_CLIENT_SECRET)

They do not gate a request at runtime. Answering the common question directly: no, a service does not need relay:send in order to send. Every send routes through Janus, and the Janus path bypasses the check.

NOTE: Public routes are different. A route registered in Koko with declared scopes is enforced by authzMiddleware normally. The gap is specific to /i/.

"Scopes" means two unrelated things — this is the biggest trap in the system

The word is overloaded across two systems that share no data and behave differently. Verified against koko-prod contents:

Koko scopesBio-ID scopes
Declared inspec.scopes (owner)spec.modules[].scopes (owner), spec.dependencies[].scopes (consumer)
Stored inKoko available_scopes / scope_grantsBio-ID
GovernsDatabase access — resourceType: mongodb / redis / neo4j / usersAPI calls
Real effectA connection string env var is injected. Genuinely enforced.Deploy-time approval gate + client-credentials provisioning
Runtime enforcement on /i/n/aNone
Live count (prod)3 available, 2 grants — all databaseOnly relay publishes any: relay:send, relay:templates, relay:admin

WARNING: Koko's available_scopes has nothing to do with API scopes. Every row is a database grant. Do not read it expecting to find relay:send.

Only relay publishes API scopes. docman, bio-vault and septor publish none — so a catalog declaring scopes: [docman:generate] or [septor:emit] is naming something that exists nowhere. Those declarations are inert and nothing warns about them. byte-mga currently declares five API scopes of which exactly one (relay:send) is real.

Why real enforcement is not a small change

There is no data anywhere mapping a path on a service to a required scope. ServiceRoute.scopes covers public routes only. Nothing says "POST /send on relay requires relay:send" except a hard-coded line inside relay.

Enforcement is therefore a data-model change, not a middleware. Until that is decided, the honest move is to make requireScope log-only or remove it — unreachable code that reads as a security control is worse than no control.

The [] trap, if anyone does turn enforcement on

serviceScopes = result.scopes ?? [], and [] is truthy in JavaScript. A token that introspects successfully but carries no scopes does not hit the bypass — it falls to [].includes('relay:send') and 403s. Combined with the documented fire-and-forget .catch(logger.warn) pattern, that 403 is swallowed and the email silently never arrives. Roll out report-only first.

Do NOT strip the forwarded Authorization header

An earlier draft of this review recommended it, on the reasoning that Janus already authenticated the caller. It would have 401'd three services.

ServiceWhat it does with the inbound token
iec-docmanbio-auth.ts requires Bearer, verifies via Koko then Bio-ID, populates req.user (org, roles, email)
iec-bio-vaultrequireAuth rejects any request without a valid Bearer token
iec-raterspotResolves a RestPrincipal from the Bearer token or an X-API-Key fallback

The division of labour is deliberate and sound: Janus authenticates the calling service; the forwarded token lets the target service authorize the end user. Two different questions, both needing an answer.

The genuinely redundant piece is narrow — relay uses its Bearer branch to derive a service identity that Janus already passed in a header. That branch alone can go, inside relay.

Webhook callback routing

IWebhookSubscription stores ownerKey (routing) and an optional orgSlug (context) separately — the model already supports both axes.

callerIdentity() returns serviceClientId ?? X-Service-ID ?? X-Program-ID. The first two are a client_id; the third is an orgSlug. That fallback crosses namespaces and is an accident, not a feature — it does not let a caller choose org-scoped delivery, it swaps the namespace based on which token type was presented.

What production contains (relay-prod, 2026-08-27)

CheckResult
Live webhook subscriptions3 — policysign-prod, ballantyne-prod, portal-prod; all keyed on a service client_id
orgSlug on those subscriptionsinsureco on all three, stored separately
Messages stamped with an org slug0 of 10,751 — the collision is real in code but has never fired
Messages with no caller identity938, all 2026-06-01 → 06-23; historical, closed since June

Consequence: fixing ownerKey to be service-identity-only is not a migration. Every live subscription already uses that form.

Silent failure mode worth knowing

A dependency naming a service that is not in Koko logs a server-side warning at dependency-resolver.ts:88, skips it, and deploys anyway — no {SERVICE}_URL injected, nothing visible to the developer.

This bit iec-ledger (#282): it declared iec-relay, but the Koko service name is relay, so RELAY_URL was never injected and invite emails silently failed.

WARNING: Repo name ≠ service name, with no rule. iec-docman→docman, iec-relay→relay, iec-koko→koko, but iec-wallet, iec-cron and iec-pulse keep their prefix. Always check the Koko registry, never infer from the repo.

Tracked as iec-builder#80.

Koko registry — what the data actually looks like

Read from koko-prod on 2026-08-27.

Two registries that disagree

RegistryCount
iec-builder service list108
Koko services97
In both96

12 services exist in the builder but not in Koko. Most are disposable (my-api, test-service, test-sample-api, oauth-demo, e2e-test-site, my-san-site), but some are not: byte-mga-api, byte-mga-web, checkpoint-api, developer-portal, bio-samples, iec-builder. Anything declaring a dependency on one of those gets no URL injected and no error.

One stale entry sits only in Koko: ask-jefferson, from the retired Jefferson server.

Service records themselves are healthy — all 97 are active, none missing upstream, healthEndpoint or owner; 44 carry routes and 81 carry directDeps.

Live unresolvable dependencies

Every dependency in every catalog, checked against the registry: 62 resolve, 5 do not.

CatalogDeclaresReality
byte-mgaiec-bio-vaultShould be bio-vault. Its code reads IEC_BIO_VAULT_URL, which is never injected — the vault integration is dormant and returns IEC_BIO_VAULT_URL not configured
byte-mgaiec-storageNo such service. Object storage is spec.storage (MinIO + Vault), which byte-mga already declares — the dependency is a category error
policyeco-webjci-brainNot a Tawa service; JCI_BRAIN_URL must come from tawa config set
iec-books-2025/iec-ledgeriec-relayStale tree; the live iec-ledger already fixed this

Eleven of twenty-five collections are empty

domains, service_roles, deployments, plugin_enables, flags, promotions, plugin_docs, onboarding_specs, config_history, platformVersions, service_deploy_manifests.

Several correspond to features that exist in code and docs — promotion, deployment gates, the plugin store, onboarding specs, custom domains. Built, never populated. A future agent reading the code will reasonably assume these are load-bearing; they are not, and should be labelled aspirational or removed.

Doc claims to keep honest

Checked against this review; flagged for anyone maintaining tawa-docs:

ClaimStatus
"Every dependency goes through an approval gate" (reference/scopes)True at deploy time; the doc is silent on runtime, which is where readers assume enforcement
"Declare relay with scopes: [relay:send] … provisions the BIO credentials the SDK needs"True only for a service without auth: mode: sso; with SSO the credentials arrive anyway
branding: true maps to includeBranding: trueFalse, retracted 2026-08-27. Never implemented. Corrected in all four doc locations
"scopes enforced at the Janus layer" (relay code comment)False. No scope enforcement exists on /i/

Related

  • Full review artifact with diagrams and risk-ordered next steps
  • iec-builder#8 — service classification in Koko, default-deny egress
  • iec-builder#80 — make unresolved dependencies loud
  • tawa-cli#79 — preflight sourcing schema from the builder

Billing pre-check, settlement and runtime settings (iec-janus PR #41, 2026-10-04)

Shipped to prod as build 850ccc35. PR: https://git.insureco.io/insureco/iec-janus/pulls/41. Convention: tawa-platform/reference/runtime-settings.

Pre-check against spendable balance

The gas check calls iec-wallet GET /api/wallets/:id/spendable?gasType=transaction. Spendable excludes intro tokens.

  • The result is cached for 5 seconds per wallet and invalidated after every settlement debit.
  • When spendable is short, Janus returns 402 with { code: 'INSUFFICIENT_GAS', balance, required, buyCommand, buyUrl?, requiredBalance, currentBalance }. Since iec-janus PR #42 (https://git.insureco.io/insureco/iec-janus/pulls/42) and #43 (https://git.insureco.io/insureco/iec-janus/pulls/43), build 20a4f7c6, buyCommand is always present and is tawa wallet buy <shortfall>; the message ends "Run tawa wallet buy N to top up." buyUrl is present only when the gas.buyUrl runtime setting is set.
  • Legacy service proxy: a wallet error fails closed (503). A missing wallet counts as 0 spendable.

Settlement

  • A charged settlement that exhausts its retries writes a gas_events row { charged: false, reason: 'settlement_failed' } and increments the metric iec_gas_settlement_failed_total.
  • The debit returns a breakdown. The callee is credited earned only for the purchased plus earned share of it. The promo-paid share stays with the platform and is not credited anywhere.
  • The gas event records promoAmount and calleeCredited. When the debit has no breakdown, the callee is credited in full.

Runtime settings

Janus keeps its tunables in the runtime settings registry (src/services/settings.ts, src/routes/adminSettings.ts), not in env vars.

KeyMeaningEditor
authzAuthorization middleware modejson (dedicated Console editor)
gas.systemPaidRoutesRoutes billed to the system walletjson (dedicated Console editor)
gas.buyUrlBuy-gas link in the 402 response. Default empty. Replaces the GAS_BUY_URL env varurl
  • GET /admin/settings returns every setting with description and editor ('boolean' | 'number' | 'string' | 'url' | 'json').
  • Writes are owner-only: the caller's Bio-ID JWT sub must be listed in the JANUS_SETTINGS_OWNERS env var. When it is not set, the owner is the default BIO-E0000000000000002.
  • The page is Console → Janus Switches, see tawa-platform/architecture/tawa-web-console-settings.

Last updated: October 5, 2026