examples · atomic · 4 / 5
4. Audit event (row + FTS)
← Atomic multi-shapewhat this does
The simplest multi-shape recipe. One event row in audit.events plus a BM25 full-text index over the human-readable payload. No vector, no graph - just structured event + searchable text.
when to use it
- Compliance / SOC 2 audit logs you'll search by free text later ("who changed the carbon_plate column?").
- Activity feeds where the structured fields (
actor_id,action,ts_ms) are the filter and the payload text is what humans read. - Application logs where you want both fast time-range scans and grep-style search over the message.
the schema
Two indexes: one for "find all events by this actor in this window" and one for global time-range queries.
# audit/events.toml
namespace = "audit"
table = "events"
primary_key = ["id"]
[[columns]]
name = "id"
ty = "str"
required = true
[[columns]]
name = "actor_id"
ty = "str"
required = true
[[columns]]
name = "action"
ty = "str"
required = true
[[columns]]
name = "payload_text"
ty = "str"
[[columns]]
name = "ts_ms"
ty = "u64"
required = true
[[indexes]]
name = "by_actor_and_time"
columns = ["actor_id", "ts_ms"]
[[indexes]]
name = "by_time"
columns = ["ts_ms"] call 1 of 2 - the event row
POST /v1/tenants/:t/rows/audit.events
curl -X POST "$ORIGINCHAIN_URL/v1/tenants/$T/rows/audit.events" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "evt-2026-06-10-00042",
"actor_id": "user.alice",
"action": "schema.update",
"payload_text": "Added column carbon_plate to shop.products",
"ts_ms": 1749500000000
}'db.rows.put("audit.events", {
"id": "evt-2026-06-10-00042",
"actor_id": "user.alice",
"action": "schema.update",
"payload_text": "Added column carbon_plate to shop.products",
"ts_ms": 1749500000000,
})// The TypeScript SDK does not wrap row writes yet
// (shipping in the next release). Use `fetch` for now.
await fetch(`${BASE_URL}/v1/tenants/${TENANT}/rows/audit.events`, {
method: "POST",
headers: {
"Authorization": `Bearer ${OC_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
id: "evt-2026-06-10-00042",
actor_id: "user.alice",
action: "schema.update",
payload_text: "Added column carbon_plate to shop.products",
ts_ms: 1749500000000,
}),
});// The Go SDK does not wrap row writes yet
// (shipping in the next release). Use net/http for now.
body, _ := json.Marshal(map[string]any{
"id": "evt-2026-06-10-00042",
"actor_id": "user.alice",
"action": "schema.update",
"payload_text": "Added column carbon_plate to shop.products",
"ts_ms": uint64(1749500000000),
})
req, _ := http.NewRequestWithContext(ctx, "POST",
BASE_URL+"/v1/tenants/"+TENANT+"/rows/audit.events",
bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+OC_TOKEN)
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close() call 2 of 2 - the keyword index
Index the sanitized payload text. Same doc_id as the row's primary key so search hits join back to the event.
POST /v1/tenants/:t/fts/audit.events/index
curl -X POST "$ORIGINCHAIN_URL/v1/tenants/$T/fts/audit.events/index" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"field": "payload_text",
"doc_id": "evt-2026-06-10-00042",
"text": "Added column carbon_plate to shop.products"
}'# Sanitize first - never index raw PII.
text = redact_pii(payload_text) # your redaction step
db.fts.index(
"audit.events",
"payload_text",
doc_id="evt-2026-06-10-00042",
text=text,
)// Sanitize first - never index raw PII.
const text = redactPII(payloadText);
await db.ftsIndex("audit.events", {
field: "payload_text",
docId: "evt-2026-06-10-00042",
text: text,
});// Sanitize first - never index raw PII.
text := RedactPII(payloadText)
err := db.FTSIndex(ctx, "audit.events", originchain.FTSIndexRequest{
Field: "payload_text",
DocID: "evt-2026-06-10-00042",
Text: text,
}) about atomicity
The row write and the FTS index are separate calls. There is no single "write everything" endpoint. Each is atomic by itself. The SDKs auto-attach an Idempotency-Key, so if the FTS index fails after the row succeeded, you can safely retry just the FTS call - re-doing the row write would not duplicate the event.
common mistakes
- Indexing PII without redaction. Anything that lands in the FTS index is searchable forever. Sanitize emails, phone numbers, tokens, internal IDs before calling
db.fts.index- never after. - Mutating audit events. Audit logs should be append-only. Don't re-put the row to "correct" an event - emit a new event that supersedes it.
- Embedding raw JSON in
payload_text. FTS tokenization treatsand"as noise. Render the payload as a human sentence first so the index actually has searchable words. - Forgetting an
id. If the row write generates an id server-side and the FTS call uses a different one, search hits won't join back. Set the id client-side (UUID or a sortable like ULID) and use the same string for both calls.