callrec

package
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 14, 2026 License: Apache-2.0 Imports: 14 Imported by: 0

Documentation

Overview

Package callrec is the single home of the CLI's journaling POLICY plus the journal-backed korbit.Recorder that enacts it. The L1 client (internal/korbit) owns no journaling: it calls an injected Recorder's Ready before the first send and Record once after, and a post-call failure is delivered through this package's onPostFailure sink — never back through the client. This package is the concrete Recorder, and — crucially — the one place the policy lives.

Why the policy lives here, alone

Whether a given call is journaled, and whether a post-call journal failure is FATAL or merely a warning, is decided in ONE place: DefaultPolicy (or a caller swapping in its own PolicyFunc). Every surface — cli, doctor, monitor — routes through the same decision.

A Decision has two axes:

  • Record — journal this call at all?
  • PostFailure — if the post-call Record write fails, is that FATAL (Fail: the onPostFailure sink captures it, and the cli surfaces it AFTER emitting the API result) or a WARNING (Warn: the sink logs/toasts it and the command still succeeds)?

DefaultPolicy(debug) is the policy. See its table for the per-surface rules; the doctor row (never record) is kept explicit rather than left to the default.

Lazy open + the pre-send hard guarantee

The journal DB file must not even be created unless a call actually records — this preserves the property that pure public use never touches a read-only home. So the Recorder opens the journal LAZILY: it opens only when (and only when) the policy says a call records, at Ready time. Ready is the L1 client's pre-send gate; opening there means a broken journal (e.g. a read-only home) fails the command with NOTHING sent on the wire — the open-journal-before-send hard guarantee. On open failure Ready returns the output.Configf error text so the error and its exit-4 classification are preserved.

For order placement the order INTENT row is written before the placement send too (StartOrder, an explicit pass-through the CLI calls before korbit.Do); its failure aborts with nothing sent, and FinishOrder folds in the result after. Routing orders through this package — rather than a caller holding a raw journal.Logger — keeps every journaled write behind one lazily-opened handle.

KORBIT_CLI_NO_JOURNAL is honored here: the Decision short-circuits to "don't record" before any open, so opting out never creates the file.

Per-call isolation (ForCall) — concurrency

The korbit.Recorder interface (Ready then Record) carries no per-call token, so any state a call needs between its Ready and its Record — the start timestamp captured pre-send, and the spec/insertion-ordered params_json that overrides the client's map-sorted CallInfo.ParamsJSON — must NOT live on the shared Recorder, or two concurrent calls would clobber each other. Instead Recorder.ForCall(orderedParams) returns a fresh per-call CallRecorder (sharing the parent's journal, policy, clock, and onPostFailure sink) that the client's NewRecorder hands back for exactly one Do. The CLI makes one per runEndpoint; the monitor surface, which fires concurrent calls — including several of the same command — through one shared parent, gets correct isolation for free by taking a fresh ForCall per Do. The parent Recorder owns only shared, safely-locked state (the lazily-opened journal handle and the order-row seam).

Close concurrent with in-flight calls

A recorder can be Close()d while a call is still in flight — the TUI closes its recorders as soon as the Bubble Tea program returns, but Bubble Tea does not await the command goroutines that run order placements, cancels, and candle fetches, so one can call into the recorder after Close(). Every journal handle read therefore goes through the lock: ensureOpen returns the live *journal.Logger under mu (open-then-write paths), and lockedJL reads it under mu (the post-send order/operation finish paths and the per-call recorders, whose journal was opened earlier), never a panic. Close is also terminal: it sets a closed flag so ensureOpen never reopens a fresh handle that nobody would close. What a late call does after Close depends on whether its write is pre-send (the hard guarantee) or post-send (diagnostic):

  • Pre-send, would-record writes REFUSE: Begin (the operations ledger row) and StartOrder (the order intent row) return a fatal ConfigError, so the operation aborts before anything is sent. This is the same failure an open/insert error produces; it must NOT be downgraded to a non-recording handle, or a money op could place an order with no mint-time row.
  • Post-send, diagnostic writes NO-OP: Record (api_calls) and the order/operation finish paths cleanly skip — the outcome is already decided, so a dropped row is diagnostic, not a safety signal.

This kills the pointer data race and the nil-deref. A write can still hit a handle Close shuts mid-call (the narrow TUI-shutdown window); that degrades to a journal error handled as a post-send Warn — an accepted limitation, since the lost row is post-send diagnostic, not a money-safety signal (the pre-send order intent is written and gated before any send). The recorder deliberately does NOT drain in-flight writes before Close: the pre-send hard guarantee is unaffected, and serializing Close against every write to recover a diagnostic row in a shutdown-only window is not worth the added lifecycle coupling.

The injectable clock: korbit.Do brackets a call with the real, un-injectable wall clock (Outcome.StartedAtMs/FinishedAtMs), but the journal stamps ALL its own time columns — every operations, orders, and api_calls row — from the caller's single injectable clock (the system clock; ops supplies no timestamp). So the CallRecorder captures the api_call start on that clock at Ready and the finish at Record, and the operation/order rows are stamped on the same clock at Begin/StartOrder/Finish — one clock for the whole journal, deterministic under a test clock.

No secrets

Only pre-signing facts are ever written: the public api-key id and the pre-signing params (korbit assembles the timestamp/recvWindow/signature AFTER the CallInfo is captured, so none of them reach a Recorder). The keystore is never read for journaling. This is the same no-secrets rule the journal package itself documents.

FailMode semantics

A post-call write failure never returns through the wire client; it is handed to the onPostFailure(FailMode, error) sink the caller injects, and the sink reacts per mode:

  • Fail — the sink captures the error; the CLI emits the API result first, then surfaces the journal failure with a non-zero exit (runEndpoint sequencing). The api_calls write uses the call's FailMode; the operations-ledger Finish additionally returns its error so the cli can join it with the api_calls one (ops.Result.JournalErr).
  • Warn — the sink logs/toasts the error and the command still succeeds. This is the monitor/tui/mcp surfaces' behavior; any surface whose policy selects Warn gets it. The order-outcome (FinishOrder) and operations-ledger warnings are ALWAYS delivered as Warn (their result is already decided), so a finish-write failure never fails a placement that already happened.

A Ready (pre-send) open failure is ALWAYS fatal regardless of PostFailure: it is the hard guarantee, and nothing has been sent yet. PostFailure governs only the post-call write.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type CallRecorder

type CallRecorder struct {
	// contains filtered or unexported fields
}

CallRecorder is the korbit.Recorder for ONE logical call. It holds only that call's state (its ordered params, its operation link/sequence, and its Ready-captured start), delegating the shared journal/policy/clock to its parent — so it is the per-call isolation seam that makes concurrent recording correct. An ad-hoc CallRecorder (from Recorder.ForCall) carries operationID 0 and seq 0; one minted by an OpHandle.ForCall carries the operation's id and a 1-based sequence within it.

func (*CallRecorder) Ready

func (c *CallRecorder) Ready(info korbit.CallInfo) error

Ready is the L1 client's pre-send gate. When the policy says this call records, it opens the journal (creating the DB if needed) — so a broken journal fails the command here, BEFORE anything is sent (the hard guarantee). When the call is not recorded it opens nothing and permits the send. It also captures the start timestamp on the injectable clock for the api_calls started_at_ms column.

func (*CallRecorder) Record

func (c *CallRecorder) Record(info korbit.CallInfo, out korbit.Outcome) int64

Record is the post-call write. When the call is not recorded it is a no-op. Otherwise it writes the api_calls row with the same fields runEndpoint wrote. A write failure is delivered to the onPostFailure sink with the call's FailMode (the caller's closure warns or captures-for-fatal) — never returned, so a journal fault can never be conflated with the API error.

type Decision

type Decision struct {
	// Record is whether this call is journaled at all. When false the journal is
	// never opened (no DB file is created) and PostFailure is irrelevant.
	Record bool
	// PostFailure governs a post-call Record write failure only.
	PostFailure FailMode
	// Reason is the short fixed label explaining this verdict, logged verbatim in
	// the journaling-decision Debug line.
	Reason string
}

Decision is the journaling policy's verdict for one call: whether to record it at all, and — if the post-call write fails — whether that is fatal or a warning.

type FailMode

type FailMode int

FailMode is how a POST-call Record failure is handled — the second axis of a Decision. (A pre-send open failure at Ready time is always fatal regardless, since nothing has been sent: see the package doc.)

const (
	// Fail hands the journal error to the onPostFailure sink, which the cli's
	// closure captures and surfaces after emitting the API result (runEndpoint
	// behavior: a post-success journal failure is hard-surfaced with a non-zero
	// exit).
	Fail FailMode = iota
	// Warn hands the journal error to the onPostFailure sink, which logs/toasts it
	// and lets the command still succeed (monitor/tui/mcp behavior).
	Warn
)

type OpHandle

type OpHandle struct {
	// contains filtered or unexported fields
}

OpHandle is the per-operation journal handle. A recording handle groups the operation's api_calls under one operations row (each ForCall taking the next 1-based sequence) and folds in the order intent and final outcome through the shared open Logger. A non-recording handle no-ops everything and opens nothing.

func (*OpHandle) Finish

func (h *OpHandle) Finish(outcome, errorCode string) error

Finish folds the operation result into its ledger row. The operation's result is already determined, so a finish-write failure is diagnostic, not a money-safety signal: it always warns. Under FailMode Fail it is ALSO returned so the cli can surface it alongside its other journal errors; under Warn it is swallowed (returns nil) since the warn already happened.

func (*OpHandle) ForCall

func (h *OpHandle) ForCall(orderedParams string) korbit.Recorder

ForCall returns a per-api_call recorder bound to this operation, assigning the next 1-based sequence within it. A non-recording handle returns nil — the wire client treats a nil Recorder as "do not record" (the same sentinel the unjournaled path uses), so the send proceeds un-journaled. The journal is already open from Begin, so a recording recorder's Ready only captures the start timestamp — the hard open gate already fired in Begin.

func (*OpHandle) OperationID

func (h *OpHandle) OperationID() int64

OperationID returns the operations row id (0 when not recording).

func (*OpHandle) StartOrder

func (h *OpHandle) StartOrder(in ops.OrderIntent) (ops.OrderFinishFunc, error)

StartOrder records the order's mint-time intent under this operation BEFORE the placement send (the pre-send hard guarantee; its failure is the fatal output.Configf order text). A non-recording handle returns a no-op finish.

type PolicyFunc

type PolicyFunc func(info korbit.CallInfo) Decision

PolicyFunc decides, per call, whether and how to journal it. It is a constructor parameter of the Recorder so a future caller can swap the whole policy wholesale without touching the recorder. The CallInfo it receives is the pre-signing call description (origin/surface, read/write class, params) — the only inputs a policy gets to reason over.

func DefaultPolicy

func DefaultPolicy(debug bool) PolicyFunc

DefaultPolicy returns the journaling policy, parameterized by whether debug mode is on. It is THE policy table — the single place to change journaling behavior:

	surface           class   record?               post-failure
	-------           -----   -------               ------------
	cli               write   always                Fail
	cli               read    only in debug         Fail
	doctor            any     NEVER                  (n/a)
	stream-backfill   any     NEVER                  (n/a)
	<other>           write   always                Warn
	<other>           read    only in debug         Warn

  - Writes (order place/cancel, transfers) are always journaled; reads only in
    --debug. A write operation's read sub-calls (e.g. place's reconcile fetch)
    are grouped under the write operation, so they are journaled with it — the
    class comes from the operation (CallInfo.Safety), not each sub-call.
  - "cli" surfaces a post-success journal failure fatally (Fail) after the
    result; "doctor" and "stream-backfill" never record (the stream layer's
    REST recovery reads are recovery machinery, not user actions). The decision
    lives in this table, not at the call sites.
  - any other surface (notably "monitor") records the same calls cli does but
    Warns on a post-failure, so a long-running session isn't killed by it.

type Recorder

type Recorder struct {

	// Log receives operational diagnostics about journaling decisions and the
	// lazy open (the Debug "journaled / skipped" lines, the lazy-open path passed
	// to journal.Open). nil = silent (logging.Or). The user-facing post-failure
	// report goes through onPostFailure — this is the telemetry around it.
	Log *slog.Logger
	// contains filtered or unexported fields
}

Recorder is the journal-backed korbit.Recorder. It enacts a PolicyFunc over a lazily-opened journal.Logger: the DB file is created only when a call actually records (at Ready time), so pure public use never touches a read-only home. One Recorder is opened at most once and its handle is reused for the api_calls write and the order-row seam (StartOrder/FinishOrder); Close releases it.

Safe for concurrent use: open is guarded so the lazy open happens once even if several Do calls race (the monitor surface drives this concurrently).

func New

func New(path string, disabled, noFsync bool, policy PolicyFunc, clock func() int64, onPostFailure func(FailMode, error)) *Recorder

New builds a Recorder writing to the journal at path. disabled mirrors journal.Disabled (KORBIT_CLI_NO_JOURNAL): when true every Decision is short-circuited to "don't record" and the DB is never opened. noFsync mirrors the --no-fsync/KORBIT_CLI_NO_FSYNC opt-in: the journal is opened with synchronous=OFF (faster writes, weaker crash-durability). policy is the (swappable) journaling policy; clock is the caller's injectable (system) wall clock for every journal time column (nil = time.Now().UnixMilli()); onPostFailure is the post-call write-failure sink (a no-op sink is used if nil).

func (*Recorder) Begin

func (r *Recorder) Begin(o ops.OpStart) (ops.OpHandle, error)

Begin opens the operations ledger for one logical operation. The persist decision is made here, once, by running a synthetic CallInfo through the SAME PolicyFunc the per-call path uses — so an operation and its calls always agree on whether to record. When the decision is "don't record" (or journaling is disabled) Begin returns a no-op handle that opens nothing. When recording, it is the operation's pre-send hard gate: it opens the journal (a failure is the fatal output.Configf open text — nothing has been sent) and writes the operations row (its insert failure is likewise fatal pre-send). A pre-send open failure is fatal regardless of surface (see the package doc); the per-call FailMode governs only post-send write failures.

func (*Recorder) Close

func (r *Recorder) Close() error

Close releases the journal handle if it was opened and marks the recorder closed for good: a late call arriving after Close (a leaked TUI command goroutine) finds ensureOpen a no-op and never reopens the journal.

func (*Recorder) FinishOrder

func (r *Recorder) FinishOrder(id int64, f journal.OrderFinish) error

FinishOrder folds the placement result into the order row. It is a pass-through to journal.FinishOrder; the journal is already open (StartOrder opened it).

func (*Recorder) ForCall

func (r *Recorder) ForCall(orderedParams string) *CallRecorder

ForCall returns a per-call korbit.Recorder bound to this Recorder's journal, policy, clock, and warn sink, carrying ONE call's own state — the spec/insertion-ordered params JSON for the api_calls row (orderedParams; "" to keep the client's CallInfo.ParamsJSON) and the start timestamp it captures at Ready. A fresh CallRecorder per korbit.Do keeps every call's state isolated, so concurrent calls — including several of the SAME command through one shared parent Recorder (the monitor surface) — never collide. Wire it as the Client's Rec for exactly one Do.

func (*Recorder) Opened

func (r *Recorder) Opened() bool

Opened reports whether the journal has been opened (a call recorded). Used by the CLI to emit the "recorded to action journal" debug note only when there was actually a write.

func (*Recorder) Path

func (r *Recorder) Path() string

Path returns the journal database path (for messages and the debug note). It is the configured path whether or not the DB has been opened yet.

func (*Recorder) StartOrder

func (r *Recorder) StartOrder(o journal.OrderStart) (int64, error)

StartOrder records the order's mint-time intent row BEFORE the placement send (the pre-send hard guarantee), opening the journal if needed. Its failure is fatal — nothing has been sent yet — and is reported with output.Configf so the error and its exit-4 classification are preserved. Returns the order row id for a later FinishOrder. KORBIT_CLI_NO_JOURNAL (disabled) short-circuits to (0, nil) without opening the DB; the matching FinishOrder then no-ops, so opting out skips the order rows exactly as it skips api_calls.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL