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-docsrather than re-derived.
Verified 2026-08-27 against iec-janus, iec-relay, iec-koko, iec-builder, the live cluster, and relay-prod message data.
Every internal call carries three distinct identities. Two are separated correctly; the third is misnamed and that misnaming has caused real bugs.
| Axis | Header | Actually carries | Answers |
|---|---|---|---|
| Service | X-Service-ID | OAuth client_id (e.g. policysign-prod) | Who is calling; who gets a callback |
| Billing org | X-Caller-Org | orgSlug, after X-Forward-User resolution | Who pays |
| Caller org | X-Program-ID | orgSlug — despite the name | Which org the calling pod belongs to |
WARNING:
X-Program-IDdoes not carry a program or service id. Janus setsauth.programId = orgSlugininternalProxyAuthMiddleware. 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.
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:
resolveCallerOrg() — billing org from the JWKS-verified service token, or a JWKS-verified X-Forward-User for pass-through billinginternalProxyGasCheckMiddleware → 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.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.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).
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.serviceScopesrequireScope bypasses whenever serviceScopes is undefinedAnd 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.
They are a deploy-time gate, and a real one:
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
scopesis enforced byauthzMiddlewarenormally. The gap is specific to/i/.
The word is overloaded across two systems that share no data and behave differently. Verified against koko-prod contents:
| Koko scopes | Bio-ID scopes | |
|---|---|---|
| Declared in | spec.scopes (owner) | spec.modules[].scopes (owner), spec.dependencies[].scopes (consumer) |
| Stored in | Koko available_scopes / scope_grants | Bio-ID |
| Governs | Database access — resourceType: mongodb / redis / neo4j / users | API calls |
| Real effect | A connection string env var is injected. Genuinely enforced. | Deploy-time approval gate + client-credentials provisioning |
Runtime enforcement on /i/ | n/a | None |
| Live count (prod) | 3 available, 2 grants — all database | Only relay publishes any: relay:send, relay:templates, relay:admin |
WARNING: Koko's
available_scopeshas nothing to do with API scopes. Every row is a database grant. Do not read it expecting to findrelay: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.
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.
[] trap, if anyone does turn enforcement onserviceScopes = 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.
An earlier draft of this review recommended it, on the reasoning that Janus already authenticated the caller. It would have 401'd three services.
| Service | What it does with the inbound token |
|---|---|
iec-docman | bio-auth.ts requires Bearer, verifies via Koko then Bio-ID, populates req.user (org, roles, email) |
iec-bio-vault | requireAuth rejects any request without a valid Bearer token |
iec-raterspot | Resolves 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.
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.
| Check | Result |
|---|---|
| Live webhook subscriptions | 3 — policysign-prod, ballantyne-prod, portal-prod; all keyed on a service client_id |
orgSlug on those subscriptions | insureco on all three, stored separately |
| Messages stamped with an org slug | 0 of 10,751 — the collision is real in code but has never fired |
| Messages with no caller identity | 938, 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.
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, butiec-wallet,iec-cronandiec-pulsekeep their prefix. Always check the Koko registry, never infer from the repo.
Tracked as iec-builder#80.
Read from koko-prod on 2026-08-27.
| Registry | Count |
|---|---|
| iec-builder service list | 108 |
Koko services | 97 |
| In both | 96 |
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.
Every dependency in every catalog, checked against the registry: 62 resolve, 5 do not.
| Catalog | Declares | Reality |
|---|---|---|
byte-mga | iec-bio-vault | Should 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-mga | iec-storage | No such service. Object storage is spec.storage (MinIO + Vault), which byte-mga already declares — the dependency is a category error |
policyeco-web | jci-brain | Not a Tawa service; JCI_BRAIN_URL must come from tawa config set |
iec-books-2025/iec-ledger | iec-relay | Stale tree; the live iec-ledger already fixed this |
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.
Checked against this review; flagged for anyone maintaining tawa-docs:
| Claim | Status |
|---|---|
"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: true | False, 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/ |
iec-builder#8 — service classification in Koko, default-deny egressiec-builder#80 — make unresolved dependencies loudtawa-cli#79 — preflight sourcing schema from the builderShipped to prod as build 850ccc35. PR: https://git.insureco.io/insureco/iec-janus/pulls/41. Convention: tawa-platform/reference/runtime-settings.
The gas check calls iec-wallet GET /api/wallets/:id/spendable?gasType=transaction. Spendable excludes intro tokens.
{ 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.gas_events row { charged: false, reason: 'settlement_failed' } and increments the metric iec_gas_settlement_failed_total.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.promoAmount and calleeCredited. When the debit has no breakdown, the callee is credited in full.Janus keeps its tunables in the runtime settings registry (src/services/settings.ts, src/routes/adminSettings.ts), not in env vars.
| Key | Meaning | Editor |
|---|---|---|
authz | Authorization middleware mode | json (dedicated Console editor) |
gas.systemPaidRoutes | Routes billed to the system wallet | json (dedicated Console editor) |
gas.buyUrl | Buy-gas link in the 402 response. Default empty. Replaces the GAS_BUY_URL env var | url |
GET /admin/settings returns every setting with description and editor ('boolean' | 'number' | 'string' | 'url' | 'json').sub must be listed in the JANUS_SETTINGS_OWNERS env var. When it is not set, the owner is the default BIO-E0000000000000002.tawa-platform/architecture/tawa-web-console-settings.Last updated: October 5, 2026