Skip to content

Message Envelope and NatsKernelLoop

Message Envelope

Every NATS message in CKP carries a structured envelope consisting of headers and a JSON body. A consistent envelope eliminates per-kernel message format negotiations. Any client -- browser, CLI, or kernel -- can construct a valid message by following this single schema. Any observer -- log aggregator, audit system, or debugging tool -- can parse any message without kernel-specific knowledge.

Required NATS Message Headers

Every NATS message MUST include the following headers. Additional headers MAY be present but MUST NOT alter routing or dispatch semantics.

HeaderTypeRequiredDescription
Trace-IdstringREQUIREDUnique correlation ID for the request/response pair. Format: tx-{uuid}. Propagated through all downstream messages in the same transaction.
X-Kernel-IDstringREQUIREDIdentity of the sending kernel or client. For browsers: browser. For kernels: kernel class name (e.g., CK.Task).
X-User-IDstringREQUIREDIdentity of the human user. For anonymous: anonymous. For authenticated: preferred_username from JWT claims.
AuthorizationstringCONDITIONALBearer {jwt} -- REQUIRED for auth and owner level actions. MUST be omitted (not empty) for anonymous requests.
Nats-Msg-IdstringRECOMMENDEDIdempotency key for JetStream deduplication.

Why Trace-Id

Distributed tracing across a multi-kernel pipeline requires a single correlation identifier. The Trace-Id header is created by the originating client and propagated unchanged through all NATS messages in the same transaction. Log entries, instance records, and proof records all reference this trace ID, enabling end-to-end audit reconstruction.

Message Body Schema

The JSON body MUST conform to the { action, data } envelope:

json
{
  "action": "task.create",
  "data": {
    "target_kernel": "ckp://Kernel#Finance.Employee:v1.0",
    "description": "Onboard new employee record"
  }
}
FieldTypeRequiredDescription
actionstringREQUIREDAction name to invoke on the target kernel. MUST match an entry in the kernel's SKILL.md action catalog or an effective action from edges.
dataobjectREQUIREDAction-specific payload. Schema varies per action. MAY be empty ({}).

Result Message Format

Result messages published to result.{kernel} use the same envelope with additional fields:

json
{
  "action": "task.create",
  "data": {
    "instance_id": "i-tx-3fff0e38-1773518402",
    "status": "created"
  },
  "trace_id": "tx-a8f3c1d2-e4f5-6789-abcd-ef0123456789",
  "kernel": "CK.Task",
  "timestamp": "2026-04-05T16:37:25Z"
}
FieldTypeRequiredDescription
trace_idstringREQUIREDEchoed from the request Trace-Id header
kernelstringREQUIREDKernel class name that produced the result
timestampstringREQUIREDISO 8601 timestamp of result generation
errorstringOPTIONALError message if the action failed

Structured JSON Logging

Kernel processors MUST output structured JSON to stdout. This is the sole log format. Unstructured log output MUST NOT be written to stdout.

json
{"ts":"2026-04-05T16:37:23Z","level":"info","kernel":"Delvinator.Core","event":"nats.connected","endpoint":"nats://nats.nats.svc:4222"}
{"ts":"2026-04-05T16:37:23Z","level":"info","kernel":"Delvinator.Core","event":"nats.subscribed","topic":"input.Delvinator.Core"}
{"ts":"2026-04-05T16:37:23Z","level":"info","kernel":"Delvinator.Core","event":"ready"}
{"ts":"2026-04-05T16:37:25Z","level":"info","kernel":"Delvinator.Core","event":"rx","trace":"tx-a8f3c1","action":"status","user":"anonymous"}
FieldTypeRequiredDescription
tsstringREQUIREDISO 8601 timestamp
levelstringREQUIREDOne of: debug, info, warn, error
kernelstringREQUIREDKernel class name
eventstringREQUIREDDotted event identifier (e.g., nats.connected, rx, tx, error.dispatch)

Additional fields MAY be present. The trace, action, user, endpoint, and topic fields are common but not required on every line.

TIP

Structured JSON logging enables log aggregation via standard Kubernetes tooling (Loki, Fluentd, Elasticsearch) without configuration. Every log line is machine-parseable. The kernel and event fields allow filtering by kernel class and lifecycle phase without regular expressions.

Persistent Storage. Logs MAY be written to storage/logs/ (DATA loop) with daily rotation. This enables post-pod log analysis for kernels that accumulate operational history.

Stream Topics for Real-Time Events

Kernels with LLM or streaming capability SHOULD publish per-token events to stream.{kernel}. This enables browser clients to render Claude responses in real time without polling.

FieldTypeRequiredDescription
typestringREQUIREDEvent type: content_block_delta, tool_use, AssistantMessage, ResultMessage
trace_idstringREQUIREDCorrelation ID matching the originating request
kernel_urnstringREQUIREDURN of the source kernel
dataobjectREQUIREDEvent payload (e.g., {"text": "..."} for content deltas)

Handler implementations receive a stream callback:

python
@on("analyze")
async def handle_analyze(self, data, stream=None):
    async for chunk in claude_response:
        if stream:
            await stream("content_block_delta", {"text": chunk.text})
    return {"summary": final_result}

The stream callback publishes to stream.{kernel} with the Trace-Id from the originating request. Clients subscribe to stream.{kernel} and filter by trace_id to isolate their own streaming session.

Python-JavaScript Interoperability

CK.Lib.Py and CK.Lib.Js MUST produce and consume identical message formats.

AspectStatusDetail
Subject namingAlignedBoth use input.{K}, result.{K}, event.{K}, stream.{K}
NATS headersConsistentTrace-Id, X-Kernel-ID, X-User-ID, Authorization, Nats-Msg-Id
JSON codecCompatibleJS JSONCodec().encode() <-> Python json.dumps().encode()
Body structure{ action, data } envelopeImplementations MUST validate this structure
Stream eventsCompatibleBoth publish/consume { type, trace_id, kernel_urn, data }

NatsKernelLoop Processing Cycle

NatsKernelLoop is the runtime loop that bridges the NATS transport and the kernel processor. It is implemented in CK.Lib.Py (nats_loop.py) and is the sole entry point for message-driven kernel execution.

Why This Matters

Centralising NATS connection management, JWT verification, grants checking, instance writing, and result publication in a single loop enforces the three-loop separation axiom. Handler functions (TOOL loop) receive parsed data and return dicts. They never touch storage/ directly, never manage NATS connections, and never verify credentials. This separation is not advisory -- it is structural.

Startup Sequence

When --listen is invoked, NatsKernelLoop executes the following startup sequence:

StepActionLog Event
1Connect to NATS (TCP or WSS)nats.connected
2Subscribe to input.{KernelName}nats.subscribed
3Read conceptkernel.yaml edges block(internal)
4Materialise edge subscriptions (see below)nats.edge.subscribed per edge
5Subscribe to session.* if session-capablenats.session.subscribed
6Emit ready log eventready

Edge Subscription Materialisation

Edge predicates in conceptkernel.yaml are not just metadata -- they materialise as NATS subscriptions at kernel start time. No edge subscription code is written in the processor. The loop derives subscriptions from the ontology declaration.

Edge PredicateNATS SubscriptionActivation Trigger
PRODUCESSubscribe to event.{source}Target auto-invokes default action
TRIGGERSSubscribe to event.{source} with trigger_action filterTarget invokes the named action
COMPOSESSubscribe to result.{spoke} ; can publish to input.{spoke}Hub dispatches, receives results
EXTENDSSubscribe to result.{child}Parent forwards unknown actions to child
LOOPS_WITHSubscribe to event.{peer} (both directions)Bidirectional invocation with circular guard
python
# Pseudocode: edge subscription materialisation at startup
for edge in kernel.edges.outbound:
    if edge.predicate == "COMPOSES":
        await nats.subscribe(f"result.{edge.target_kernel}")
    elif edge.predicate == "TRIGGERS":
        await nats.subscribe(f"event.{edge.source_kernel}")
    elif edge.predicate == "PRODUCES":
        await nats.subscribe(f"event.{edge.source_kernel}")
    elif edge.predicate == "EXTENDS":
        await nats.subscribe(f"result.{edge.target_kernel}")
    elif edge.predicate == "LOOPS_WITH":
        await nats.subscribe(f"event.{edge.target_kernel}")

TIP

Declarative edge subscriptions eliminate a class of bugs where a developer forgets to subscribe to a topic that the ontology declares. If the edge exists in conceptkernel.yaml, the subscription exists at runtime. If the edge is removed, the subscription disappears. No code change is required.

8-Step Message Processing Cycle

For each message received on any subscribed topic, NatsKernelLoop executes:

Detailed step breakdown:

  1. Parse NATS headers -- Extract Trace-Id, X-Kernel-ID, X-User-ID, and Authorization header.
  2. Verify JWT (if Authorization header present) -- Decode and validate JWT signature against JWKS. Extract preferred_username as verified user_id. On failure: reject with 401, log to audit.
  3. Check grants -- Resolve user access level (anon / auth / owner / spiffe). Look up requested action in the grants block. On failure: reject with 403, log to audit.
  4. Parse body as JSON -- Validate { action, data } envelope. On failure: reject with 400, log error.
  5. Dispatch to handler -- Look up @on(action) handler in processor. If not found, check effective actions from edges. If EXTENDS action, load persona and invoke target. Execute handler with parsed data.
  6. Write instance (if stateful action type) -- Create storage/instances/i-{trace}-{ts}/, write manifest.json, data.json (handler return value), generate proof.json, append ledger.json, commit to git on DATA volume.
  7. Publish result -- Publish to result.{KernelName}, publish to event.{KernelName}, and if session active, publish to session.{project}.{id}.
  8. Log completion -- Structured JSON: {"ts":..., "level":"info", "kernel":..., "event":"tx.complete"}.

The Three-Loop Contract in Action

Steps 1--3 enforce access control (CK loop authority). Step 5 invokes the handler (TOOL loop execution). Steps 6--7 write the result (DATA loop storage). The handler in step 5 never performs steps 6 or 7 directly. It returns data; the loop writes. This is the separation axiom in runtime form.

JWT Verification

NatsKernelLoop MUST verify JWT tokens in the Authorization header before dispatching to handlers. The pattern of trusting X-User-ID headers without verification is a known vulnerability.

python
# Required verification before dispatch
auth_header = headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
    token = auth_header[7:]
    claims = verify_jwt(token, jwks_url=kernel.auth.issuer_url)
    user_id = claims.get("preferred_username", "anonymous")
else:
    user_id = "anonymous"
# Then check grants for this user_id + requested action

The verify_jwt function MUST:

  1. Fetch the JWKS from the issuer's .well-known/openid-configuration.
  2. Validate the token signature against the JWKS keys.
  3. Check token expiration (exp claim).
  4. Check token audience (aud claim) if configured.
  5. Return decoded claims on success; raise on failure.

NATS Availability and Durability

Task lifecycle NATS messages MUST use JetStream. If NATS becomes unavailable during operation:

ConditionKernel Behaviour
NATS unreachable at startupRetry with exponential backoff; do not enter ready state
NATS connection lost during operationQueue events locally in storage/ledger/pending_events.jsonl
Local queue exceeds 1000 eventsEnter degraded state
NATS reconnectsReplay pending events in order; publish ck.{guid}.data.nats-degraded if degraded
task.complete without NATS confirmationBlock -- data.json MUST NOT be written

Kernel Type and NATS Pattern

Different kernel types use NATS differently:

Kernel TypeNATS PatternSubscribePublishAuth
node:hotPersistent subscriberinput.{kernel} (always listening)result.{kernel}, event.{kernel}SPIFFE JWT-SVID
node:coldStarted on messageinput.{kernel} (trigger, then listen)result.{kernel}, event.{kernel}SPIFFE JWT-SVID
agentPersistent subscriberinput.{kernel} (always listening)result.{kernel}, event.{kernel}, stream.{kernel}SPIFFE JWT-SVID
inlineBrowser clientresult.{kernel}, event.{kernel}input.{kernel}Keycloak JWT
staticNo NATS connectionNoneNoneNone

Conformance Requirements

CriterionLevel
Every NATS message MUST include Trace-Id, X-Kernel-ID, and X-User-ID headersREQUIRED
Message body MUST use the { action, data } envelopeREQUIRED
Result messages MUST include trace_id, kernel, and timestampREQUIRED
Kernel processors MUST output structured JSON to stdoutREQUIRED
Each log line MUST include ts, level, kernel, eventREQUIRED
Streaming kernels SHOULD publish to stream.{kernel}RECOMMENDED
Stream events MUST include type, trace_id, kernel_urn, dataREQUIRED
NatsKernelLoop MUST be the sole message dispatch entry pointREQUIRED
JWT verification MUST occur before handler dispatchREQUIRED
Grants MUST be checked before handler dispatchREQUIRED
Handlers MUST NOT write to storage/ directlyREQUIRED
Instance creation MUST occur inside the loop, not the handlerREQUIRED
Edge subscriptions MUST be materialised from conceptkernel.yaml at startupREQUIRED
Processor code MUST NOT contain hardcoded edge subscriptionsREQUIRED
Task lifecycle messages MUST use JetStreamREQUIRED
NATS unavailability MUST trigger local event queuingREQUIRED
Results MUST be published to result.{kernel} and event.{kernel}REQUIRED

See also: NATS Messaging for topic convention and per-loop topics, Authentication for JWT verification details, Loop Isolation for the three-loop separation axiom.

Released under the MIT License.