Skip to content

The developer API

The developer-facing API is deliberately small. Because the kernel supplies phase, participant, budget, actor, access, and correlation automatically, the API only has to accept the things the platform cannot infer: a message, a level, and optional classification. There is no buffer to flush, no builder to construct, and no delivery mode to pick — those are the platform’s job.

Logging lives on the ambient log object available in the effectful tier — inside record-triggered automation, calc-function diagnostics, background jobs, and integration handlers. One method per level:

log.error(message, fields?)
log.warn(message, fields?)
log.info(message, fields?)
log.debug(message, fields?)
log.trace(message, fields?)

The minimal call is one line, and it is already fully contextual:

log.info('Reached deep-discount branch');
// Persisted with: level=INFO, message, phase=effect, participantKey,
// correlationId, actor, tenant, budgetAtEntry — none of which the developer typed.

Structured fields are an optional typed object, not string concatenation, so they stay machine-queryable:

log.warn('Discount below margin floor', {
invoiceId: record.id,
discountedTotal: record.discounted_total,
marginFloor: record.cost * 1.10,
});

Logging an error attaches the error envelope automatically when one is in scope:

log.error('Pricing engine refused the line', { lineId: line.id });
// If this runs while handling a computation error, the entry carries the
// error envelope (code, class, origin) with no extra call.

A scenario names the unit of work a log belongs to — invoice-recalc, nightly-gp-sync, order-import — so related transactions across time can be filtered as a set. It is a first-class field on the Log, not a tag.

The scenario is defaulted from the entry point and rarely set by hand: a save from the Invoice recalc path defaults to invoice-recalc, a scheduled job to its job key, an API request to its route. Code overrides it only when a finer name is useful:

log.setScenario('invoice-recalc.bulk-reprice');

Scenarios are also the natural key for per-scenario configuration — a level threshold or a retention window that differs from the tenant default. A noisy bulk-reprice path can log at WARN and up while an interactive save logs at INFO, without touching code, because the threshold is configuration data keyed by scenario. This is the useful core of Nebula’s LogScenarioRule__mdt, kept.

We do not adopt Nebula’s option to require a scenario before any logging is allowed. Requiring a scenario to log is a guardrail against Apex code that logs with no organizing context; CAOS entries are never context-free (they always carry phase, participant, and correlation), so making a developer name a scenario before they may log adds friction without adding signal.

A tag is a typed, cross-cutting label — pricing, integration, security-relevant — for classification that cuts across scenarios and objects. Tags are added inline:

log.info('External price fetched', { vendor }).tag('integration', 'pricing');

Tags are a closed, deploy-managed vocabulary by default — a tag is a metadata-declared value, so it is spell-checked at author time, governed, and reportable, rather than a free-string that fragments into pricing, Pricing, and price across a codebase. Where an org genuinely needs open-ended tagging, tags may be promoted to their own object with a junction; that is a retention/reuse decision, not the default. This keeps Nebula’s rule-based tagging idea — tags applied automatically by a declared rule — as configuration, while dropping the free-string default that makes tag data messy.

Structured fields over string interpolation

Section titled “Structured fields over string interpolation”

The fields argument is the deliberate replacement for building a message string out of concatenated values. A structured field is typed, queryable, and safe to mask; a value baked into a message string is none of those. The message is for a human reading the entry; the fields are for a query filtering thousands of them.

// Prefer this — queryable, maskable, typed:
log.info('Invoice priced', { invoiceId, total, marginPct });
// Over this — a string a query cannot filter on:
log.info(`Invoice ${invoiceId} priced at ${total} (${marginPct}% margin)`);

Because fields are structured, a field can be masked by a declared rule before the entry is persisted — a value matching a sensitivity classification is redacted at write time, not after. This is Nebula’s LogEntryDataMaskRule__mdt idea, kept, but expressed against CAOS’s data classification rather than a bespoke regex list, so masking is consistent with the platform’s existing notion of sensitive fields.

Logging is an effectful-tier capability, so it obeys the tier boundary:

  • Allowed in ADJUST (before-write automation), EFFECTS (after-write automation), POST-COMMIT, background jobs, integration handlers, and calc-function diagnostic output.
  • Not allowed as a side effect inside a pure SHAPE or VALIDATE participant — a formula, roll-up, or validation rule may not log, because pure participants must be side-effect-free and statically analyzable. A failed validation still produces a log entry, but the kernel emits it from the envelope; the rule body does not call log.

This is stricter than Nebula, which lets any Apex log anywhere, and the strictness is intentional: it preserves the property that pure logic is replayable and deterministic.

The same intent, in both systems:

// Nebula Logger (Apex)
Logger.info('Invoice priced')
.setRecord(invoice.Id)
.setField(LogEntryEvent__e.Margin__c, marginPct)
.addTag('pricing');
Logger.saveLog(); // must not be forgotten, or nothing is saved
// CAOS
log.info('Invoice priced', { marginPct }).tag('pricing');
// related record, phase, participant, budget, correlation, and persistence: automatic

The CAOS call is shorter and carries more durable context, because the parts Nebula makes explicit — the record link is often the triggering row, the persistence call, the transport choice — are things the kernel already knows or already does.