← Back to Skills

Audit Trail on Tawa

import { Septor } from '@insureco/septor'

Ruleconfigure

About

import { Septor } from '@insureco/septor'

Skill Content

This is the raw markdown that gets installed as a Claude Code rule.

# Audit Trail on Tawa

## The Short Version

```typescript
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`](https://git.tawa.pro) (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`:

```yaml
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:

| Var | Value for a non-K8s host |
|-----|--------------------------|
| `SEPTOR_URL` | `https://septor.tawa.pro` — the **external** URL. Cluster DNS (`septor.septor-prod.svc.cluster.local`) is NOT routable off-cluster. |
| `BIO_ID_URL` | `https://bio.insureco.io` |
| `BIO_CLIENT_ID` / `BIO_CLIENT_SECRET` | a 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:

```bash
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:

```typescript
// ✅ 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](/reference/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.

| | |
|---|---|
| Ceiling | **600 emits/min per caller** (`SEPTOR_EMIT_RATE_LIMIT`) |
| Window | 60s, fixed |
| On rejection | `429` + `Retry-After` + `RateLimit` (draft-7) headers |
| Response code | `rate_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

| Category | Required Events |
|----------|----------------|
| Payments | `payment.created`, `payment.completed`, `payment.failed`, `payment.refunded` |
| Policies | `policy.bound`, `policy.endorsed`, `policy.cancelled`, `policy.renewed` |
| Compliance | `ofac.cleared`, `ofac.flagged`, `kyc.verified`, `kyc.failed` |
| Authorization | `user.login`, `permission.granted`, `permission.revoked` |
| Data Access | `record.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

```typescript
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

```typescript
// 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 `invalidate`d (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

```typescript
// ❌ 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'))
```

Install

Copy the skill content and save it to:

~/.claude/rules/tawa-septor-audit.md
Download .md

Coming soon via CLI:

tawa chaac install tawa-septor-audit

Details

Format
Rule
Category
configure
Version
1.0.33604
Tokens
~2,863
Updated
2026-10-13
platformauditcomplianceseptorhash-chainimmutable