ledger

package
v1.2.0 Latest Latest
Warning

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

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

Documentation

Overview

Package ledger implements the write-ahead intent ledger described in plan-tinvest-cli.md §9 (reliability model) and §10 (intent ledger spec).

Every mutation an agent asks the CLI to perform is journaled in stages, fsynced before each network step, so a crash at any point leaves a durable record that reconciliation can resolve — including the worst case where the broker accepted an order but the response was never recorded. The package is CLI-independent by design: it takes typed intents and results, never touches the network, and knows nothing about cobra, rendering, or gRPC.

Storage is append-only JSONL at ${XDG_STATE_HOME:-~/.local/state}/tinvest/ journal/YYYY-MM.jsonl (files 0600, directory 0700). Each line carries a crc32c (Castagnoli) checksum of its own content for corruption detection; corrupt lines are skipped, counted, and reported, never fatal to reading the lines that follow. Every append is fsynced (file, plus the parent directory on first create) and guarded by an advisory file lock (flock) so concurrent processes cannot interleave or tear a line. Monthly rotation only ever opens a new file; existing files are never rewritten.

Index

Constants

View Source
const (
	StageIntentCreated   = "intent-created"
	StageSendStarted     = "send-started"
	StageBrokerConfirmed = "broker-confirmed"
	StageBrokerRejected  = "broker-rejected"
	StageReconciled      = "reconciled"
)

Journal stages, in lifecycle order (plan §10). intent-created and send-started are the "unresolved" stages: an intent whose last recorded stage is one of these may have reached the broker and needs reconciliation.

Variables

This section is empty.

Functions

func DefaultDir

func DefaultDir() (string, error)

DefaultDir resolves ${XDG_STATE_HOME:-~/.local/state}/tinvest/journal.

Types

type AdvisoryLock

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

AdvisoryLock is a process-safe exclusive file lock. It reuses the same platform flock implementation as journal appends and releases the lock when closed.

func AcquireAccountLock

func AcquireAccountLock(journalDir, accountID string) (*AdvisoryLock, error)

AcquireAccountLock serializes an account-scoped critical section across CLI processes. Lock files live beside the journal directory under locks/; the account id is hashed so it cannot escape that directory or create invalid filenames.

func (*AdvisoryLock) Close

func (l *AdvisoryLock) Close() error

Close releases the advisory lock and closes its file handle. It is safe to call more than once.

type Corruption

type Corruption struct {
	File   string `json:"file"`
	Line   int    `json:"line"`
	Reason string `json:"reason"`
}

Corruption records one unreadable line found during a scan.

type Entry

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

Entry is a handle to a single journaled intent. It carries the latest known result fields forward across stages so each line is self-contained.

func (*Entry) AccountID

func (e *Entry) AccountID() string

func (*Entry) Attempt

func (e *Entry) Attempt() int

func (*Entry) Confirmed

func (e *Entry) Confirmed(res Result) error

Confirmed records a successful broker response and its result fields.

func (*Entry) ExchangeOrderID

func (e *Entry) ExchangeOrderID() string

func (*Entry) IntentID

func (e *Entry) IntentID() string

func (*Entry) Kind

func (e *Entry) Kind() string

func (*Entry) OrderID

func (e *Entry) OrderID() string

func (*Entry) Payload

func (e *Entry) Payload() json.RawMessage

func (*Entry) PayloadHash

func (e *Entry) PayloadHash() string

func (*Entry) Profile

func (e *Entry) Profile() string

func (*Entry) Reconciled

func (e *Entry) Reconciled(res Result) error

Reconciled records the outcome discovered by reconciliation, closing out an intent whose fate was previously unknown.

func (*Entry) Rejected

func (e *Entry) Rejected(cause error) error

Rejected records a definitive broker rejection.

func (*Entry) SendStarted

func (e *Entry) SendStarted() error

SendStarted records that the network send is about to happen. Fsynced before it returns so the "we may have sent it" fact is durable.

func (*Entry) Stage

func (e *Entry) Stage() string

func (*Entry) StopOrderID

func (e *Entry) StopOrderID() string

func (*Entry) TrackingID

func (e *Entry) TrackingID() string

type Intent

type Intent struct {
	IntentID    string // durable client intent key (agent-supplied, recommended)
	Kind        string // e.g. "order.place", "order.cancel", "stop.place"
	AccountID   string
	Profile     string
	Attempt     int
	OrderID     string // client order_id idempotency key, if applicable
	StopOrderID string
	Payload     any // full request minus token; JSON-marshalable
}

Intent is the durable description of a mutation, written at Begin before any network I/O. Per the idempotency contract (§9), the client-generated order_id lives inside Payload and is also surfaced in OrderID, so a crash between Begin and Confirmed leaves an Unresolved entry carrying the exact key that may have reached the broker.

Payload must be the full request minus any token; the caller is responsible for stripping credentials before handing it to the ledger.

type Ledger

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

Ledger is an open handle to a journal directory. It is safe for concurrent use by multiple goroutines; appends are serialized and each is fsynced and flock-guarded before it returns.

func Open

func Open(dir string) (*Ledger, error)

Open prepares the journal directory and opens the current month's file, creating both if needed. seq is seeded from the highest seq already present in that file so a process restart keeps producing increasing sequence numbers.

func (*Ledger) AssignedStopOrderIDs

func (l *Ledger) AssignedStopOrderIDs(kind string) (map[string][]string, error)

AssignedStopOrderIDs returns stop-order ids already tied to successful mutations of kind, grouped by account. Reconciliation uses this durable history to ensure a broker stop order cannot be assigned again on a later run. Corrupt journal lines fail the scan closed because they could hide an earlier assignment.

func (*Ledger) Begin

func (l *Ledger) Begin(intent Intent) (*Entry, error)

Begin records the intent-created stage and returns a handle. It MUST be called before any network send (§9): the payload it persists carries the client order_id, so a crash before Confirmed leaves an Unresolved entry with the exact key that may have reached the broker.

func (*Ledger) Close

func (l *Ledger) Close() error

Close releases the open file handle.

func (*Ledger) TornTails

func (l *Ledger) TornTails() []TornTail

TornTails returns the torn final writes Open detected and repaired during this handle's life, so a caller can surface them (finding F7). Empty in the common case of a clean journal.

func (*Ledger) Unresolved

func (l *Ledger) Unresolved() ([]*Entry, error)

Unresolved scans every journal file and returns a handle for every intent whose last recorded stage is intent-created or send-started — the intents that may have reached the broker and need reconciliation. The returned handles carry the order_id and payload from the journal, and can be closed out with Reconciled.

All files are scanned, in chronological (sorted) order, so an unresolved intent from any month is visible, not just the current and previous ones (finding F12): a mutation whose fate was never recorded must never fall off the recovery horizon. Filenames are YYYY-MM, so lexical order is chronological and a later file's stage supersedes an earlier one for the same intent.

If any journal line is corrupt, the readable unresolved entries are still returned but a *RecoveryError is returned alongside them, so corruption that could hide an unresolved intent is surfaced loudly rather than silently skipped (finding F7).

func (*Ledger) Verify

func (l *Ledger) Verify() (Report, error)

Verify scans every journal file in the directory, validating each line's checksum. Corrupt lines are counted and reported but never abort the scan.

type RecoveryError

type RecoveryError struct {
	Corruptions []Corruption
}

RecoveryError reports that the recovery scan (Unresolved) encountered corrupt journal lines. The unresolved entries that could still be read are returned alongside it, so a caller may proceed with partial recovery after inspecting Corruptions — but the failure is surfaced loudly rather than silently dropping the affected intents, which could otherwise hide an unresolved mutation (finding F7/F12).

func (*RecoveryError) Error

func (e *RecoveryError) Error() string

type Report

type Report struct {
	Files       int          `json:"files"`
	Lines       int          `json:"lines"` // non-empty lines scanned (OK + Corrupt)
	OK          int          `json:"ok"`
	Corrupt     int          `json:"corrupt"`
	Corruptions []Corruption `json:"corruptions,omitempty"`
}

Report is the result of a Verify checksum scan.

type Result

type Result struct {
	OrderID         string
	StopOrderID     string
	ExchangeOrderID string
	TrackingID      string
	ExitCode        *int
	Error           string
}

Result carries broker/reconciliation outcome fields recorded on the confirmed/rejected/reconciled stages. Zero-valued fields are left unchanged.

type TornTail

type TornTail struct {
	File    string `json:"file"`
	Bytes   int    `json:"bytes"`
	Sidecar string `json:"sidecar"`
}

TornTail records a torn final write that Open detected and repaired: a partial line at EOF (no terminating newline, e.g. power loss mid-append) that would otherwise have been concatenated onto by the next append and corrupted a fresh record (finding F7). The torn bytes were moved to Sidecar before the journal file was truncated back to its last complete record.

Jump to

Keyboard shortcuts

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