Audit Trail on Tawa

The Short Version

import { Septor } from '@insureco/septor'

const septor = new Septor({
  apiUrl: process.env.SEPTOR_URL,
  namespace: process.env.SERVICE_NAME || 'my-service',
})

// Fire-and-forget — NEVER await septor on the critical path
septor.emit('payment.created', {
  entityId: orgSlug,
  data: { amount, referenceId: paymentId },
  metadata: { who: userId },
}).catch((err) => logger.error({ err }, 'Septor emit failed — audit event lost'))

Setup

Declare nothing. SEPTOR_URL is auto-injected on every deploy, and since iec-builder 9e0a2d0 (2026-06-22) the builder also applies the tawa.pro/direct-dep.septor=true NetworkPolicy label on every deploy, whether or not you declare septor. The same holds for iec-queue.

In the builder, ALWAYS_DIRECT_DEP_SERVICES = ['septor', 'iec-queue'] is unioned into the namespace's direct-dep labels before the catalog is even consulted, so computeDirectDepServices(undefined) still returns both.

If you declare septor anyway, the builder warns and ignores the entry:

septor is an auto-injected platform service — SEPTOR_URL and its NetworkPolicy direct-dep label are applied on every deploy. Declaring it in spec.dependencies is harmless but unnecessary; the entry will be ignored.

That warning is correct — act on it.

Superseded guidance. Before 9e0a2d0 this page said you MUST declare septor in internalDependencies or lose the audit trail, and told you to ignore the builder's warning. That was true until 2026-06-22 and is now wrong on both counts. The declaration is dead weight.

One caveat: the label is applied at deploy time. A service that never declared septor and has not deployed since 2026-06-22 does not carry the label yet — its next deploy adds it.

Authentication (REQUIRED — SDK ≥ 1.4.0)

Septor verifies a Bio-ID token on every emit/query. The SDK (≥ 1.4.0) handles this automatically: when no apiKey is passed, it mints a client_credentials token from BIO_CLIENT_ID / BIO_CLIENT_SECRET / BIO_ID_URL and refreshes it ahead of expiry. No per-app token code is needed.

This means your service MUST have Bio-ID credentials. Declare spec.auth in catalog-info.yaml so the builder injects BIO_CLIENT_ID / BIO_CLIENT_SECRET:

spec:
  auth:
    mode: sso            # or: service-only (no user login, creds still injected)

Without spec.auth, those vars are absent and every Septor emit fails with Authorization header required (the audit trail is silently lost since emits are fire-and-forget). See https://tawa.insureco.io/reference/convention-septor

Pin @insureco/septor to ^1.5.0. Earlier versions do not authenticate and will be rejected by Septor.

Pin an explicit version — never latest. A latest dependency silently changes under the service on the next image build. bio-graph depended on latest, which resolved to 0.1.1 at build time (a version that no longer authenticates and is no longer published) while the registry's latest tag had long since moved to 1.5.0.

Verify at startup, not on the request path. SDK 1.5.0 adds assertConfigured(), which throws when the client cannot authenticate. Call it from a boot check or health probe so a missing credential is loud immediately — never from a request handler.

Non-Kubernetes services (PM2, cron hosts, CI)

A service that does not run as a K8s pod receives none of the builder-injected env, so nothing above happens for free. It must set all four itself:

VarValue for a non-K8s host
SEPTOR_URLhttps://septor.tawa.pro — the external URL. Cluster DNS (septor.septor-prod.svc.cluster.local) is NOT routable off-cluster.
BIO_ID_URLhttps://bio.insureco.io
BIO_CLIENT_ID / BIO_CLIENT_SECRETa Bio-ID OAuth client with the client_credentials grant and an orgSlug — Septor derives the event's owning org from that claim, and rejects a token without one.

iec-builder is the reference case: it runs under PM2 on the tawa-builder host and had none of these, so it recorded zero audit events from inception until 2026-08-31.

Proving events actually land

A green deploy is not proof. Confirm the namespace appears:

koko query events -s septor -e prod --json -f '{"namespace":"<your-namespace>"}' -l 3

A first-ever event returns chainIndex: 0. If the namespace is absent entirely, the service has never successfully authenticated.

Fire-and-Forget (REQUIRED)

A Septor outage must NEVER break your service. Always fire-and-forget:

// ✅ CORRECT
septor.emit('payment.created', { ... })
  .catch((err) => logger.error({ err }, 'Septor emit failed — audit event lost'))

// ❌ WRONG: blocking your user on an audit write
await septor.emit('payment.created', { ... })

Who Pays for Audit Events

The InsurEco system wallet pays the gas for audit events (decision, 4 Oct 2026). Janus treats POST /v1/events/emit on septor as a system-paid route (the gas.systemPaidRoutes setting): the emitting service and its org are never billed and an emit is never refused for lack of gas. Do not add gas handling, caching or batching to your emit calls for billing reasons.

Not live yet. Services call Septor directly through SEPTOR_URL, a cluster URL that bypasses Janus, so today an emit costs the emitter nothing and the system wallet is not charged either. The system-paid rule takes effect when a builder change routes SEPTOR_URL through Janus. Nothing changes in your code when it does. See Gas & Wallet.

Emit Rate Limit

POST /v1/events/emit is limited per authenticated caller, not per IP. The key is the caller's Bio-ID identity (orgSlug + sub), so it is stable across pod restarts and rescheduling, and two services sharing an egress path never share a bucket.

Ceiling600 emits/min per caller (SEPTOR_EMIT_RATE_LIMIT)
Window60s, fixed
On rejection429 + Retry-After + RateLimit (draft-7) headers
Response coderate_limit_exceeded

600 is derived from observed production load — the busiest caller peaks at ~46 emits in its worst minute — so normal fan-out bursts never approach it. Anonymous traffic is rejected by auth before the limiter, so this ceiling only ever bounds a runaway first-party client.

If Septor does refuse an emit it logs AUDIT_EVENT_REJECTED server-side with the namespace, event type, entity ID and caller, so a dropped compliance record can be identified and replayed. Do not rely on your own logs to catch this — the fire-and-forget .catch() in your service is the only signal you get, and the SDK does not retry a 429 (see below).

The SDK does not honour Retry-After. @insureco/septor (through 1.4.0) surfaces a 429 as a thrown error with no retry. If you emit in a tight fan-out loop, stagger it or batch it rather than assuming the client backs off.

What MUST Be Septor-Wired

CategoryRequired Events
Paymentspayment.created, payment.completed, payment.failed, payment.refunded
Policiespolicy.bound, policy.endorsed, policy.cancelled, policy.renewed
Complianceofac.cleared, ofac.flagged, kyc.verified, kyc.failed
Authorizationuser.login, permission.granted, permission.revoked
Data Accessrecord.accessed, report.exported, data.modified

Automatically written by the platform (no code needed):

  • Deploy events (builder writes these)
  • Gas events (Janus writes these)
  • Job/cron events (iec-queue/iec-cron write these)
  • Credential rotations (builder writes these)

Event Naming

Use {resource}.{action} dot notation, lowercase:

payment.created       ✅
policy.bound          ✅
ofac.screen.cleared   ✅  (nested resource.sub.action is OK)

payment-created       ❌  (hyphens — use dots)
createPayment         ❌  (camelCase)
PAYMENT_CREATED       ❌  (uppercase)

Querying the Audit Trail

const { data } = await septor.query({
  entityId: orgSlug,
  eventType: 'payment.created',  // optional filter
  from: '2024-01-01',
  to: '2024-01-31',
  limit: '100',
})

for (const event of data.events) {
  console.log(event.eventType, event.createdAt, event.metadata.who)
}

Verifying Chain Integrity

// Run in background jobs, not request handlers — this is O(n)
const { data } = await septor.verify(orgSlug)
if (!data.valid) {
  logger.error({ brokenAt: data.brokenAt }, 'Audit chain broken!')
}

Chain Anchoring — Proving an Event Off-Platform

Septor's hash chain is tamper-evident, but only to someone who trusts Septor's database. So every night Septor builds a Merkle root over every event recorded the previous UTC day and anchors it on both PolicyBlockchain chains — Settlement 1117710 and InsureBio 1140615 — in AnchorRegistry at 0x16d42af095560822034255b22360c5f9BEBafE80 (same address on both). Only the root goes on chain, never event data. Nothing to declare: every namespace is included.

GET /v1/proofs/:eventId     service (septor:query)
  → { eventHash, leaf, leafIndex, proof[], root, batchId,
      anchors: [{ chainId, txHash, blockNumber, explorerUrl }] }
GET /v1/batches/:batchId    service — one batch with its leaf list
GET /v1/anchors?from=&to=   public — anchored roots and tx hashes, no event data

An event becomes provable after the next nightly batch (00:10 UTC; retried hourly to 03:10; a 04:05 check emits anchor.missed if a chain was not reached). Before that, /v1/proofs answers 404 not_batched.

Verify without trusting Septor: call AnchorRegistry.verify(batchId, eventHash, proof) on either chain, or run backend/scripts/verify-proof.mjs from the septor repo, which recomputes the root from the proof and compares it with getAnchor(batchId) over RPC. Leaf = keccak256(keccak256(abi.encode(bytes32 eventHash))); internal nodes are sorted pairs, so OpenZeppelin MerkleProof.verify accepts the proof as is.

  • A batch's root never changes once anchored; re-running a night is a no-op.
  • verify returns false for a batch the admin has invalidated (a compromised publisher key).
  • Explorers (free Bio-ID sign-in): Settlement https://scan.policyblockchain.com/tx/<hash>, InsureBio https://scan.insurebio.com/tx/<hash>.

Source: septor#21/#22, iec-contracts#7.

Key Facts

  • Each event gets a cryptographic hash linked to the previous — tamper-evident chain
  • entityId is the primary index — use a stable identifier (orgSlug or userId)
  • Events are immutable — once written, cannot be modified or deleted
  • Each service sees only its own events (namespace scoping)
  • Missing .catch() is a compliance gap — failures must be logged

What NOT to Do

// ❌ WRONG: blocking on audit write
await septor.emit(...)

// ❌ WRONG: silent failure
septor.emit(...).catch(() => {})  // swallows the error — compliance gap

// ❌ WRONG: vague event type
septor.emit('event.happened', ...)

// ✅ CORRECT
septor.emit('payment.refunded', {
  entityId: orgSlug,
  data: { amount, reason, refundId },
  metadata: { who: userId, why: 'Customer requested refund' },
}).catch((err) => logger.error({ err }, 'Septor emit failed — audit event lost'))

Last updated: October 6, 2026