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

SEPTOR_URL is auto-injected by the builder for every deployment, but it is a direct in-cluster URL and Septor's NetworkPolicy only admits namespaces that carry the tawa.pro/direct-dep.septor=true label. The builder applies that label from your declared dependencies, so you MUST declare septor in internalDependencies:

spec:
  internalDependencies:
    - service: septor

Without this, the namespace is never labeled, every Septor call is dropped by the NetworkPolicy and hangs until it times out (connect ETIMEDOUT), and the audit trail is silently lost (emits are fire-and-forget). The builder may warn "septor is auto-injected — remove it"; ignore that warning and keep the declaration — it is what applies the NetworkPolicy label.

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.4.0. Earlier versions do not authenticate and will be rejected by Septor.

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', { ... })

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!')
}

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: July 15, 2026