Skip to content

Kernel Runtime

The KernelRuntime is the central component of LumenFlow. Every executable tool call passes through a host-authenticated kernel boundary before anything executes. Transport input is never execution authority.

The kernel is domain-agnostic. It knows portable workspace, lane, task, run, scope, policy, and capability primitives, but it does not know Software Delivery Work Units or gates. Those concepts come from packs. The kernel provides four capabilities that any pack can build on:

  1. Scope intersection — permission enforcement
  2. Policy evaluation — deny-wins rule cascade
  3. Evidence recording — immutable audit trail
  4. Tool dispatch — routing calls to pack-provided handlers

Before this pipeline starts, the host authenticates an opaque caller credential. The kernel returns a sealed AuthenticatedExecutionRuntime whose caller binding cannot be serialized, copied into a request, or reconstructed from its public fields. Every call then supplies an ExecutionSelector containing exactly task_id, run_id, and session_id. Those three values select persisted state; they do not grant authority.

When the authenticated facade calls a tool (for example, fs:write), the kernel executes a strict pipeline:

  1. Persisted authority projection. The kernel resolves the exact selector against the immutable task spec and current event projection. The task must be active, the run must be executing, and the run session, principal, workspace, and lane must match the authenticated caller and loaded workspace. A selector cannot override any of these values.

  2. Tool lookup. The kernel checks the tool registry. If the tool name is not registered by any loaded pack, the call fails with TOOL_NOT_FOUND.

  3. Scope and profile resolution. The kernel projects the pinned workspace, lane, immutable task, loaded capability, pack integrity, and persisted execution-policy profile. It computes the scope intersection. Empty intersections and missing or unknown required profiles fail closed.

  4. Reserved path check. Attempts to write kernel authority or credential paths under .lumenflow/** or the workspace .env are denied independently of task input.

  5. Input persistence. The tool’s input is serialized, hashed with SHA-256, and written to the evidence store as a content-addressed blob. This happens before execution — even if the tool fails, the input is recorded.

  6. Policy evaluation. The kernel’s policy engine evaluates all matching rules with the on_tool_request trigger. A single deny from any layer makes the decision final. approval_required fails closed before the tool handler runs and returns APPROVAL_REQUIRED with a request identifier and the matching policy decisions.

  7. Input validation and dispatch. The kernel validates the declared input schema, then routes the call to an authority-aware in-process intrinsic or a physically confined subprocess. For the exact-file mount and brokered-write boundary, see Tool Execution.

  8. Evidence recording. The immutable authority fingerprint, input, result, scope intersection, policy decisions, pack integrity, and timing are appended to the evidence store. Success, failure, denial, cancellation, and crash all remain attributable to the exact persisted execution.

Steps 1–6 are the authorization gate. If any check fails, the tool never executes. Transport payloads cannot add workspace, lane, task, policy-profile, capability, or caller authority.

executeTool/executeApprovedTool return only the tool’s plain output. Callers that need the exact receipt_id this evidence was recorded under — for example a durable bridge that must correlate a receipt with a later terminal transition — should call the sibling executeToolWithReceipt/executeApprovedToolWithReceipt (WU-3409) instead: same authorization pipeline and dispatch, but the typed result is { output, receipt_id }, with receipt_id set to the exact receipt this call’s evidence was appended under (null only when no evidence was ever created, e.g. a TOOL_NOT_FOUND early-out before capability resolution). Callers never need to scan the evidence store before and after a call to discover which trace is theirs. These two methods are additive and optional on AuthenticatedExecutionRuntime — every runtime bound through bindAuthenticatedExecutionRuntime provides them, but the interface does not require them, so the pre-existing plain-ToolOutput methods and their many callers (MCP server, HTTP tool-api, CLI inspect/task-claim/heartbeat, packs/agent-runtime) are unaffected.

The kernel is created with a workspace root and an explicit event-log factory. A runtime without a host caller authenticator can manage lifecycle state, but executable calls fail with the typed CALLER_UNBOUND error.

Local Node.js surfaces should use the host-owned binder. It validates every selector against the persisted active task and run, derives the principal and workspace from that projection, and keeps the one-use credential private inside the host closure:

import { initializeLocalExecutionRuntimeHost } from '@lumenflow/host';

const host = await initializeLocalExecutionRuntimeHost({
  workspaceRoot,
});

const selector = {
  task_id: 'task-agent-session-1',
  run_id: 'run-task-agent-session-1-1',
  session_id: 'session-agent-session-1',
};

const execution = await host.bindSelectors([selector]);
const governedTools = await execution.listGovernedTools(selector);

The caller owns the returned host and must await host.close() during shutdown. Closing stops new work, drains operations that already started, runs ToolHost shutdown reconciliation, and then closes the event log. Repeated close calls are safe.

The ExecutionSelector schema is strict: extra caller, workspace, lane, scope, pack, profile, or capability fields are rejected. A local host binds one batch only, and every selector in that batch must resolve to the same persisted principal and workspace before the first handler can run.

Workspace, task, run, session, receipt, and event identifiers are selectors and persisted names, not arbitrary strings. Their canonical grammar is:

  • the first character is a Unicode letter or number
  • later characters are Unicode letters or numbers, ., _, :, or -
  • the entire value is NFC-normalized
  • /, \, percent-encoded separators, absolute paths, . and .., null bytes, control characters, leading/trailing whitespace, and other aliases are rejected

Validation never slugifies, decodes, trims, or normalizes an invalid value in place. A NON_CANONICAL_IDENTIFIER failure includes the NFC migration target, while INVALID_CANONICAL_IDENTIFIER explains which invariant failed. Operators must choose the new identifier and migrate every persisted reference explicitly.

Existing identifiers that already satisfy the grammar remain valid without modification. For a legacy alias, stop every writer, inventory task specs, event payloads, evidence receipts, session records, and external references, rewrite them as one migration, then restart and replay validation. Do not create a compatibility map that accepts both aliases indefinitely.

The kernel expresses task and evidence locations as ProtectedStatePath values and depends on the backend-neutral ProtectedStatePort. The port covers directory creation, read and range-read, exclusive write, immutable publication, append, atomic replacement, rename, listing, metadata, and removal. It contains no host root and exposes no realpath, file descriptor, or directory-creation primitive to kernel policy code.

createLocalEventLogFactory({ workspaceRoot }) attaches the Linux host binding automatically. Task specs and evidence then use descriptor-held, no-follow operations for their complete lifetime. Missing directories are created relative to the nearest held existing ancestor; replacing the visible parent name with a symlink cannot redirect the pending leaf operation.

Custom event-log factories must attach an equivalent binding. Initialization fails closed when it is absent, and caller-supplied taskSpecRoot or evidenceRoot overrides are rejected instead of silently bypassing or replacing the binding. Explicitly ephemeral hosts and tests can attach InMemoryProtectedStateAdapter.

HTTP and MCP Streamable HTTP use the same canonical signed remote credential model. The host accepts a credential only when all of these checks pass:

  • the signature and expiry are valid
  • the issuer equals the configured issuer
  • the audience equals the exact configured resource URL
  • the workspace equals the loaded workspace
  • the subject is present and canonical
  • every transport permission is from the supported permission vocabulary

The permissions are task:read, task:write, event:read, evidence:read, discovery:read, tool:invoke, and ag-ui:run. They are transport permissions, not kernel tool scopes. Legacy scopes such as tool:* cannot authorize an HTTP route.

Authentication happens before URL parsing, permission routing, request-body reads, MCP session lookup, or runtime state access. Invalid credentials produce 401 invalid_token without revealing which credential check failed. A valid credential without the required permission produces 403 insufficient_scope. The default HTTP authenticator denies every request; opaque bearer fallback is not enabled unless a host deliberately supplies a different adapter.

After verification, the bearer is discarded. The HTTP host retains a sanitized principal, workspace, resource, and permission list, and creates a one-request opaque kernel credential backed by a private WeakMap. Only that opaque object can bind the authenticated lifecycle or execution facade. It is not serializable, and the bearer itself never reaches the kernel, handlers, evidence, logs, or persisted session state.

Remote request bodies are data, not authority. Lifecycle routes reject caller-authored task IDs, workspace IDs, principals, agent IDs, claim identities, scopes, session IDs, and request IDs. The server generates remote task IDs, persists the authenticated principal as owner, and generates claim session and run IDs. Inspection, claim, completion, event streaming, and evidence access must continue to resolve to that same owner.

The web runtime reads the workspace ID from workspace.yaml and requires LUMENFLOW_WEB_HTTP_ISSUER, LUMENFLOW_WEB_HTTP_RESOURCE, and LUMENFLOW_CONTROL_PLANE_SIGNING_KEY. The issuer and resource must be absolute URLs, and the resource is also the credential’s exact audience.

MCP Streamable HTTP exposes only its RFC 9728 protected-resource metadata without authentication. Every protocol request is authenticated first, and each Mcp-Session-Id is bound to the exact sanitized principal, workspace, resource, and permission set established at initialization. Changing any part of that context is rejected. Successful transport admission still passes through kernel ownership, scope, policy, approval, and evidence checks. MCP stdio remains a local process-environment credential boundary and does not run the HTTP bearer flow.

The web production launcher enables Next’s manual signal handling in a shell-independent Node wrapper. On POSIX systems, the production smoke sends a real SIGTERM and requires the runtime closed marker before the server exits. Node’s Windows subprocess API terminates a child for these signals without delivering its JavaScript listener, so the Windows build smoke still validates the native runtime, replay, and SQLite database while the platform-neutral shutdown-controller test drives the listener directly and proves close-once behavior.

The explicit binding keeps state-path authority in @lumenflow/host: the kernel owns identifier validation, path intent, task/evidence behavior, and ports, while the host owns protected filesystem access, SQLite, WAL, filesystem locking, and legacy migration. Workspace and pack discovery I/O remains an initialization adapter concern being separated further by INIT-087. During initialization:

  1. The workspace.yaml spec is read and hashed (SHA-256). This hash is checked on every subsequent executeTool call — if the spec changes while the kernel is running, all tool calls return SPEC_TAMPERED until the kernel is restarted.
  2. All packs referenced in the workspace spec are loaded, validated, and their tools registered.
  3. Pack-specific workspace config is validated against each pack’s declared config_schema and made available to runtime capability and policy factories.
  4. The policy engine is constructed with a four-layer stack built from pack-declared policies plus any runtime-authored rules returned by pack policy_factory hooks.
  5. The injected event-log adapter and evidence store are initialized; any orphaned evidence traces from a previous crash are reconciled.

The optional local runtime daemon uses strict newline-delimited protocol v1 frames. A request has a per-attempt correlation_id and a caller-stable idempotency_key. The daemon owns method mutation classification—scheduler.claim-next is mutating even if caller input claims otherwise—and the strict request schema rejects caller-supplied classification fields.

The client permits the in-process command path only when the socket connection fails before its first write attempt. Immediately before calling socket.write, it records that delivery is possible. A later timeout, disconnect, socket error, malformed or oversized response, invalid response schema, or correlation mismatch returns a typed status: 'ambiguous_delivery' result and does not call the local handler.

Request and response frames are limited to 64 KiB including the newline delimiter. Both sides validate protocol version, strict schema, JSON/UTF-8 framing, and response identity. Matching well-formed daemon success and failure responses remain terminal responses and do not invoke fallback.

Reconciling an ambiguous delivery, or retrying any mutating command, resolves against the daemon-outcome ledger: the same workspace-scoped RunAggregate compare-and-swap authority the scheduler and session use, not a separate idempotency database or an in-process cache. Before running a handler, the daemon hashes the trusted workspace scope, the caller’s idempotency_key, and a canonical fingerprint of protocol version, method, and validated parameters, then wins a lease reservation for that key. The fencing key is a per-attempt token generated fresh for every call, not the daemon instance’s owner_idowner_id identifies the process for cross-instance rejection and diagnostics only, and is not attempt-unique. Only the attempt that actually wins the reservation runs the handler; its bounded, JSON-safe, versioned result commits back onto that exact reservation, proven by the same attempt token, as the terminal, replayable outcome. A second, genuinely concurrent in-flight request from the same daemon process never executes a second time — it waits for the winning attempt’s committed outcome instead of racing it.

This distinguishes four things operators otherwise conflate:

  • Transport ambiguity — the client does not know whether a request reached the daemon.
  • Retry identity — the idempotency_key says which attempt this is; alone it proves nothing about execution, and it is not attempt-unique across concurrent requests from one process.
  • Committed aggregate outcome — the ledger entry is the one place proving whether a mutation ran and what it produced. The same key and fingerprint always replay that committed outcome; the same key with a different fingerprint fails closed instead of executing.
  • Operator recovery — a crash before the reservation commits is safe to retry immediately; a crash after commit but before the reply reaches the caller replays the stored outcome; a same-process concurrent duplicate waits for the winner; a reservation held by a still-live different instance rejects a competing attempt until its lease expires, so recovery is: wait out the lease (or restart the stuck instance), then resend the exact original request.

The scheduler uses one committed scheduling contention stream as authority. Enqueue resolves priority and lane from the authoritative task specification, rejects caller promotion or unknown lanes, and persists the positive WIP and lease policy. Atomic scheduler.claim-next commits owner, attempt, lease expiry, and a monotonic fencing token before returning work, removing both the cross-task WIP race and the dequeue-then-start crash window. Competing runtimes replay after expected-version conflicts before selecting again.

Heartbeat, completion, release, cancellation, and takeover require the exact current token. A stale generation fails closed. Expiry uses the trusted runtime clock, and takeover records a durable expired or released reason. Restarted daemons rebuild queue and ownership projections from event history; process memory is only a cache.

The kernel manages tasks through an event-sourced state machine:

ready → active → done

       blocked

| Method | Transition | Policy trigger | | -------------- | ----------------------- | --------------- | | createTask | — (writes spec to disk) | None | | claimTask | ready → active | on_claim | | blockTask | active → blocked | None | | unblockTask | blocked → active | None | | completeTask | active → done | on_completion | | failTask | active → done | None | | cancelTask | active → done | None |

failTask and cancelTask (WU-3409) are terminal — unlike blockTask, which only pauses a resumable run, they reach the aggregate’s failed/cancelled status and are absorbing (no further lifecycle command may mutate the task). They exist because before WU-3409, KernelRuntime exposed no way to reach RunAggregate’s pre-existing fail/cancel commands at all — only succeed (via completeTask) was wired. Both accept an optional connected_compute payload that batches a canonical receipt and cloud terminal-outbox intent into the same atomic append as the terminal event; see connected-compute-local-runner.md “Atomic Receipt And Terminal-Outbox Batch (WU-3409)”.

Task specs are immutable YAML files stored at .lumenflow/kernel/tasks/<id>.yaml. State is projected from an append-only event log — there is no mutable state file.

Task creation stages the complete spec under a temporary protected-state name, flushes it, and atomically links it to the final name without replacement before flushing the parent directory and appending task_created. Readers therefore see either no spec or the complete immutable spec, never a partial file. An identical retry may verify the existing immutable spec against the one creation event, but never appends a second creation event. Reads reject symlinks, non-regular entries, hard links, identity mismatches, replaced ancestors, and non-canonical identifiers. The Linux host keeps the selected ancestor descriptor open across the leaf operation; a pre-open realpath check is diagnostic only and is never the confinement boundary.

When task-spec ancestors are created, the Linux host flushes each held parent descriptor in root-to-leaf order before immutable publication can return. A retry after an interrupted directory sync re-establishes the complete ancestor durability chain, including entries that the earlier attempt created but did not durably flush.

New task_created events mark spec_hash_algorithm: canonical-json-v2. Version 2 sorts object keys with locale-independent UTF-16 ordering and accepts only plain JSON values. It rejects non-finite numbers and non-JSON object types such as maps, sets, dates, and class instances before publication, so moving a workspace between host locales cannot change an untampered task’s identity and YAML .nan/.inf values cannot collide with JSON null. Unmarked events remain a legacy compatibility boundary: the runtime verifies their stored hash against the runtime’s supported two- and three-letter ICU language collations plus the historical script variants, while marked version-2 events accept only the version-2 hash.

claimTask accepts a caller-supplied session_id, but that value is not what gets persisted on the run. Each task’s RunAggregate identity (run_id, session_id, workflow_id) is derived once, at task-creation time, from TaskSpec.extensions (extensions.run_id, extensions.session_id, extensions.workflow_id), defaulting to session-${task.id} / workflow-${task.id} when the extension is absent. Every subsequent claimTask, blockTask, unblockTask, and completeTask call for that task reuses this pinned identity — the session_id argument in ClaimTaskInput only participates in policy/approval evaluation, never in the persisted run identity.

Consumers that need claimTask’s returned run.session_id to equal a specific caller session — for example a durable execution bridge that later compares its own session id against the persisted run for ownership checks — must declare that session id as extensions.session_id on the TaskSpec passed to createTask, before the first claim. WU-3409 (@lumenflow/cli Connected Compute execution binding) is the reference consumer: see connected-compute-local-runner.md “Local Execution Activation (WU-3409)” for the concrete defect this pinning behavior caused when left undeclared.

The identity tuple (run_id/session_id/workflow_id) stays pinned to the first claimant forever by design — no lifecycle command mutates it, and evolveRunAggregate throws RunAggregateIntegrityError if history ever tries. A different-session restart on an already-active (non-terminal) task is not, therefore, resolved by changing the identity. Instead claimTask/reclaimConnectedComputeRun (WU-3409) also track a separate, mutable connected_compute_fencing_generation per task, set at claim time from an optional connected_compute_fencing_generation input and updated by a new fenced_reclaim RunAggregate command: a caller presenting a strictly greater generation than the one currently recorded may re-fence the task’s owner_id/generation/attempt (the mutable claim fence, not the immutable identity) to itself. A same-or-lower generation fails closed with RunAggregateCommandError. This is how a genuine cloud-fenced takeover (WU-3408’s fencing_generation) recovers a crashed session’s run without ever weakening the identity-immutability invariant above; see connected-compute-local-runner.md “Atomic Receipt And Terminal-Outbox Batch (WU-3409)” for the full reconciliation flow, including why the execution binding’s own TaskSpec-equality check (a separate, binding-local guard unrelated to RunAggregate identity) also had to stop comparing extensions.session_id for this to work end-to-end.

EventLogPort is the backend-neutral authority. Adapters assign a monotonic sequence, an opaque cursor, and a per-stream version to every accepted event. Timestamps remain event data and never decide persistence order. replay() pages in sequence order, while readStream() returns the full consistent stream snapshot used for lifecycle projection, including streams longer than 100 events.

Durable event payloads accept only lossless JSON values. In particular, domain_data rejects dates, maps, sets, undefined values, non-finite numbers, big integers, and class instances before an adapter append, so the append result and a later SQLite replay cannot disagree.

Task lifecycle methods read a stream version and append with expectedStreamVersion. If another writer wins first, the append fails atomically with EventLogConflictError and code EVENT_LOG_CONFLICT; no part of a multi-event transition is persisted. Callers should re-read the stream before deciding whether to retry.

The local host adapter uses SQLite transactions with WAL journaling. On first open it takes the existing JSONL writer lock, snapshots and validates the legacy file, commits events plus a source fingerprint marker, and archives the source as events.jsonl.migrated before releasing the lock. Interrupted migration is restartable: pre-commit work rolls back, while a committed marker resumes the archive step without importing records twice. Re-creating the old JSONL source after migration is treated as drift and fails closed.

An existing legacy lock also fails closed after the configured acquisition retries. The adapter does not remove locks automatically because an older JSONL writer cannot participate in SQLite fencing. New locks use a v2 shape with owner_id and owner_pid but omit the numeric pid required by the frozen JSONL parser. Old writers therefore treat a live v2 lock as opaque and cannot auto-delete it, including across PID namespaces. To recover, stop and establish operational quiescence for every process or container using the workspace, including legacy JSONL writers and current SQLite adapters. A valid v2 owner_pid is supporting evidence only; it cannot prove quiescence across containers or PID namespaces. Only then remove the exact stranded lock, restart the adapter, and retry. Never delete a lock while any legacy or current writer may be active.

createLocalEventLogFactory({ workspaceRoot }) uses the canonical legacy source and lock under .lumenflow/kernel/events/. Explicit host-factory paths take precedence over deprecated runtime path hints; those runtime hints remain compatibility inputs only and should not be used in new integrations.

approval_required is a non-executable policy result for tools, claims, and completions:

  • claimTask and completeTask append approval_requested, then throw a typed ApprovalRequiredError. They append no protected lifecycle transition.
  • executeTool returns APPROVAL_REQUIRED, persists the denied receipt and exact request, and does not call the handler.
  • the request binds task, run, session, principal, lane, final enforced scopes, deny overlays, tool and pack identity, exact input digest, stable capability descriptor, policy, configuration, runtime version, original denied receipt, and finite expiry.
  • a host first authenticates a reviewer with authenticateApprovalCaller(). A separate host-owned authorizer must permit that reviewer for the request. Resolution input contains only request_id, task_id, decision, and reason; the kernel derives the resolver identity from the authenticated closure.
  • resolution never emits task_resumed or task_blocked and never executes an effect. Approval state is projected separately from task lifecycle state.
  • retry revalidates the current trusted authority, policy, configuration, input, scopes, runtime, workspace integrity, and expiry. Any drift fails closed.
  • one compare-and-swap append consumes the grant before a tool attempt. Claim and completion append consumption, lifecycle transition, and terminal effect atomically.

Agent orchestration can stay above the kernel while still using kernel-enforced governance. A host uses one sealed AuthenticatedExecutionRuntime and one exact selector to:

  1. call agent-runtime:execute-turn
  2. inspect the governed turn output for status: tool_request
  3. verify the tool is present in the exact host-provided catalog
  4. call the requested tool through executeTool() with the same selector
  5. feed the tool result back into the next agent-runtime:execute-turn

This keeps the model-turn loop in host code, but the actual tool request still passes through scope enforcement, the task’s persisted policy profile, approvals, and evidence recording. The orchestration helper counts turns and tool calls against the configured ceilings without putting authority metadata into the selector.

WU-3361 composed and tested the same kernel boundary for operator-managed Connected Compute nodes; WU-3409 subsequently wired the durable execution bridge into the production compute-run entrypoint. The runner refuses execution-bearing work before runner registration, claim, provider invocation, tool discovery/execution, sandbox dispatch, evidence delivery, or terminal side effects.

The runner is pull-only. It opens no inbound listener, exposes no remote shell, and does not fall back to a hosted model. WU-3408 bridges the cloud lease to a monotonic ownership generation (fencing_generation, distinct from the opaque lease_token), end-to-end cancellation, trusted configuration/network authority, and the host catalog needed to bind a real assignment to its persisted WU, workspace, lane, checkout, and run.

Every claim response carries a positive, strictly increasing fencing_generation. The runner captures it once at claim time and carries the exact same value on every subsequent lifecycle call — ack, extend, complete, fail, release, heartbeat, and tool-dispatch evidence ingestion. The control plane is the compare-and-swap authority: a call carrying a superseded generation is rejected with fencing_generation_stale even when lease_token still matches, so a takeover after crash, an expired lease, or a failed renewal cannot admit split-brain execution — the superseded owner’s calls are rejected atomically rather than silently accepted. The client never derives a generation from assignment_id or lease_token; a claim response that omits or corrupts it fails closed (surfaced by lumenflow compute doctor’s fencingCapability check), mirroring the local scheduler fencing token described above but scoped to the cloud lease/runner boundary rather than the in-process scheduler.

WU-3409 adds durable canonical-receipt and terminal outboxes, restart reconciliation, and a separate sealed host lifecycle gate (see “Terminal Lifecycle Commands” below and connected-compute-local-runner.md “Atomic Receipt And Terminal-Outbox Batch” and “Sealed Host Lifecycle Adapter”). A completed code assignment’s gate check runs through that sealed adapter — never through the model’s own governed tool authority — and every terminal delivery (complete, fail, release/cancel) is sealed into the same atomic RunAggregate append as the canonical receipt, then drained to the cloud idempotently under the exact fencing generation the runner holds. Configuration and assignment payloads still cannot activate or broaden the composed WU-3361 boundary.

Installable runner packages are advertised by release channel. Private test artifacts and internal release candidates may use one-time bootstrap paths, but public npm, WinGet, Homebrew, MSI, .pkg, .deb, .rpm, or tarball instructions require artifact manifest evidence plus installation, doctor, and service smoke evidence for that channel.

Packs can declare a config_key and config_schema in their manifest. During runtime startup, the kernel validates the workspace config payload for that pack and threads the resolved value into:

  • capability factories — for config-aware required_scopes and required_env
  • policy factories — for runtime-authored conditional rules

This is how the agent-runtime pack derives provider host allowlists, validates credential env references, and turns persisted execution-policy profiles into kernel-enforced decisions.

Hosts do not need to reconstruct tool gating in application code. They ask the authenticated execution facade for the already-filtered tool set:

const governedTools = await execution.listGovernedTools(selector);

listGovernedTools() projects the same persisted authority and evaluates the same scope and policy rules used during real execution. The result includes allow and approval_required entries but excludes denied and out-of-scope tools. The composed Connected Compute bridge also applies an explicit host allowlist, but production does not expose that catalog until WU-3408 installs the fenced bridge.

Framework orchestration can remain above the kernel, but packs can still own their domain runtime state. The agent-runtime pack uses this boundary to persist:

  • linear agent-session resume state
  • workflow branch and join readiness
  • scheduled wakeups
  • continuation records explaining why a session resumed

Those orchestration records live in pack state under .agent-runtime/workflow/, while the kernel continues to own approval state, tool execution, and immutable evidence receipts.

The kernel stores all state under .lumenflow/kernel/ in the workspace root:

.lumenflow/kernel/
  tasks/              ← immutable task specs (one YAML per task)
  events/
    events.jsonl.sqlite       ← authoritative SQLite event log
    events.jsonl.sqlite-wal   ← SQLite WAL while a writer is active
    events.jsonl.sqlite-shm   ← transient SQLite shared-memory sidecar
    events.jsonl.migrated     ← immutable one-way legacy archive, when present
    events.lock               ← legacy migration/writer coordination lock
  evidence/
    traces/
      tool-traces.jsonl           ← active trace file
      tool-traces.00000001.jsonl  ← compacted segment
    inputs/
      <sha256-hex>    ← content-addressed input blobs

Event records are immutable at the logical contract even though SQLite manages database pages and WAL checkpoints internally. Task specs, migrated JSONL archives, evidence traces, and input blobs remain write-once or append-only. Long-lived hosts that directly own an event-log port must await close() to stop and drain subscription polling; repeated close calls are safe.

An EventSubscription reports running, degraded, or terminal health, its last successful cursor, and its last typed error. Callback or transient read failure does not advance the cursor. Transient reads retry; corruption and legacy-source drift terminate visibly. After repair, resume() continues from the last successful cursor.

.lumenflow/kernel/ is local runtime authority, not source. LumenFlow init and upgrade add a canonical ignore rule for the workspace root and nested workspace paths in the same Git repository. An independent nested Git repository must run init or upgrade itself because parent ignore rules do not cross repository boundaries. The tree can contain sensitive task inputs and execution evidence, so secure backups and restrict access accordingly.

When a tool call fails at any stage in the pipeline, the kernel returns a structured error:

| Code | Stage | Meaning | | ----------------------- | -------------- | ----------------------------------------------------------------- | | TOOL_NOT_FOUND | Lookup | Tool name not registered by any pack | | SCOPE_DENIED | Authorization | Scope intersection is empty or reserved path violation | | POLICY_DENIED | Authorization | Policy engine returned a deny decision | | APPROVAL_REQUIRED | Authorization | Policy blocked the action; no handler or lifecycle transition ran | | SPEC_TAMPERED | Pre-check | Workspace spec changed since kernel startup | | INVALID_INPUT | Validation | Input failed the tool’s schema | | INVALID_OUTPUT | Post-execution | Output failed schema normalization | | TOOL_EXECUTION_FAILED | Execution | Handler threw an exception |

Caller and selector failures throw typed ExecutionAuthorityError values before a handler can run. Important codes include:

| Code | Meaning | | ------------------------------ | ------------------------------------------------------------------------ | | CALLER_UNBOUND | The host or transport has no authenticated execution caller | | CALLER_AUTHENTICATION_FAILED | The opaque host credential was absent or rejected | | SELECTOR_INVALID | The selector is malformed or contains fields beyond the strict three IDs | | TASK_NOT_ACTIVE | The selected task is not active | | RUN_NOT_EXECUTING | The selected run is absent or not executing | | SESSION_MISMATCH | The selected session does not own the current persisted run | | PRINCIPAL_MISMATCH | The authenticated caller does not match the persisted run principal | | WORKSPACE_MISMATCH | The caller/task workspace does not match the loaded runtime | | PROFILE_MISSING | Configured policy profiles require a persisted task selection | | PROFILE_UNKNOWN | The persisted selection is absent from the pinned pack config | | AUTHORITY_FORGED | A handler received an object not minted and sealed by this runtime |