ledger

package
v0.1.6 Latest Latest
Warning

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

Go to latest
Published: Oct 11, 2026 License: Apache-2.0 Imports: 10 Imported by: 0

Documentation

Overview

Package ledger reads and writes the three committed state files (docs/SPEC.md §15, §19): the ledger of every defined block, the reverse index of every reference, and the append-only ack log. All three are TSV with a one-line header that carries a format version, the repo name, and the commit they were produced at, so `publish` can ship them and `sync` can merge them.

Why TSV and not JSON: one row per id or per reference, sorted, means a move is a one-line diff in a pull request and a human can read the file. Why a format version on every file: a newer tool upgrades older files in place, an older tool refuses a newer file by name instead of misparsing it (§32).

Field escaping is the minimum that keeps a row on one line: tab, newline, carriage return, and backslash are escaped with a backslash. Nothing else is touched, so grep still works on ids, paths, and hashes.

Index

Constants

View Source
const (
	KindLedger  = "ledger"
	KindRefs    = "refs"
	KindAcks    = "acks"
	KindForeign = "foreign"
)

File kinds, written into the header so a refs file can never be read as a ledger by mistake.

View Source
const (
	LedgerFile  = "ledger.tsv"
	RefsFile    = "refs.tsv"
	AcksFile    = "acks.tsv"
	ForeignFile = "foreign.tsv"
)

Default file names under `.ds/`.

View Source
const AcksFormat = 3

AcksFormat is the ack log's own layout version. The ack log alone gained columns — 3 added rule and sentence — and it is never published, so it versions apart: bumping Format for it would stamp the published ledger and refs too, and a repository still on an older ds would refuse the whole workspace index for two columns it never reads.

View Source
const Format = 2

Format is the version of all three file layouts this package writes. Bump when a column is added or changes meaning; Decode refuses anything newer and reads every version down to MinFormat, so a ledger written by an older tool keeps working and only loses what its columns never carried.

2 added refs.seen_hash: the hash a block had when a citation was first recorded, which is the only baseline a citation that was never acked has. Without it a rescan silently adopted whatever the block said at that moment, and a change nobody had reviewed became invisible.

View Source
const MinFormat = 1

MinFormat is the oldest layout this tool still reads.

View Source
const RootShard = "_root"

RootShard names the shard for files at the repository root.

Variables

View Source
var (
	ErrBadHeader = errors.New("ledger: missing or malformed header line")
	ErrKind      = errors.New("ledger: file kind does not match")
	ErrFormat    = errors.New("ledger: format version not supported by this tool")
	ErrColumns   = errors.New("ledger: column count mismatch")
	ErrField     = errors.New("ledger: bad field value")
	// ErrConflict is a line git left behind when a merge conflicted. It is
	// reported as itself, not as the column-count mismatch it also is, so
	// the reader is told to finish the merge rather than to repair a row.
	ErrConflict = errors.New("ledger: unresolved merge conflict")
)

Sentinel errors.

View Source
var ActorKindValues = []ActorKind{ActorHuman, ActorAgent}

ActorKindValues is the canonical order.

View Source
var ErrTimeRange = errors.New("ledger: timestamp outside the years RFC 3339 can write")

ErrTimeRange is a timestamp that parses but cannot be written back.

Functions

func Carry

func Carry(prev, cur []RefRow, effective func(RefRow) string, current func(RefRow) string) []int

Carry decides, for each current citation, which previous refs row it continues, so a citation keeps its baselines when it moves. It returns one index into prev per current row, or -1 for a citation that is new.

Why it exists: baselines used to be looked up by the exact doc and line. A citation that moved missed that lookup and was treated as new, and a new citation is baselined at the block's current hash — so adding one line above a citation, or renaming its doc, silently accepted every change to the cited block that nobody had reviewed. That is the one thing the tool exists to prevent, and it was reached by the most ordinary edit there is.

The rule it follows is that carrying an older baseline can only add a report, never remove one, while pairing two citations of the same block wrongly can remove one: if the acked citation's baseline lands on the unreviewed one, its drift disappears. So it pairs only where the pairing is certain, and everywhere else it is conservative.

  1. Same id and env with the same, non-empty sentence hash: the sentence itself moved. Same doc first, in line order, then across docs, which is a rename.
  2. What remains, per doc: one previous and one current citation of the id is a certain pairing. Anything else is ambiguous.
  3. What remains in docs with no previous citation of the id, against previous rows whose doc has none left: one and one is a rename whose sentence also changed. Anything else is ambiguous.
  4. An ambiguous citation gets the candidate whose baseline most needs reporting: the first, in doc and line order, whose effective baseline differs from the block's current hash. Over-reporting costs an ack; under-reporting is a wrong "up to date".

effective returns the baseline a previous row stood for (the latest ack at its position, else its acked hash, else its seen hash); current returns the hash now of the block a current citation resolves to — per citation, since its env and branch pick among defs sharing an id — or "" when it is not known. Both come from the caller because they are state this package does not hold.

Invariant: rows are grouped by id and env and never paired across groups, and for citations at distinct positions the result is independent of the order prev and cur arrive in (two citations of one id on one line are indistinguishable, and every consumer keys them by position anyway).

func EscapeField

func EscapeField(s string) string

EscapeField escapes a value for one column of a docsync TSV file, so a tab, newline, carriage return or backslash in it cannot split a row. It is exported so every TSV docsync writes — tests.tsv as well as the ledger — uses one grammar; UnescapeField reverses it.

func ParseTime

func ParseTime(s string) (time.Time, error)

ParseTime reads an RFC 3339 timestamp the way every docsync file stores one, and rejects a value whose UTC form falls outside the years RFC 3339 can express.

Why it exists: every writer formats times in UTC. A value such as 0000-01-01T00:00:00+00:10 parses, but is year -1 in UTC, so re-saving the file writes a timestamp no reader accepts — the file loads once and never again. Rejecting it on read keeps every file that decodes re-savable.

Invariant: a time it returns formats, in UTC, to a string it accepts.

func UnescapeField

func UnescapeField(s string) string

UnescapeField reverses EscapeField.

Types

type Ack

type Ack struct {
	At           time.Time
	Actor        string
	ActorKind    ActorKind
	DelegatedBy  string
	ID           string
	Repo         string
	Doc          string
	Line         int
	Env          string
	BlockHash    string
	SentenceHash string
	Note         string
	// Rule is the extraction rule (extract.Rule) the ack was recorded
	// under, and Sentence the text of the sentence it approved. A sentence
	// hash is only comparable with one bound by the same rule, so an ack
	// from an older rule cannot say whether the wording changed; and a
	// rewritten sentence is only reviewable when the old text is there to
	// show beside the new. Both are empty in an ack log written before
	// AcksFormat 3.
	Rule     int
	Sentence string
}

Ack is one append-only approval event.

type AckKey

type AckKey struct {
	Repo string
	Doc  string
	Line int
	ID   string
	Env  string
}

AckKey identifies the sentence an ack applies to.

type Acks

type Acks struct {
	Header Header
	Rows   []Ack
}

Acks is a header plus ack events, in the order they were recorded.

func DecodeAcks

func DecodeAcks(r io.Reader) (Acks, error)

DecodeAcks reads an ack log.

func (Acks) Bytes

func (a Acks) Bytes() []byte

Bytes encodes to memory; see Ledger.Bytes.

func (Acks) Encode

func (a Acks) Encode(w io.Writer) error

Encode writes acks in recorded order; the log is append-only and never sorted, because order is part of the audit trail.

func (Acks) Latest

func (a Acks) Latest() map[AckKey]Ack

Latest returns the newest ack per sentence: the one with the latest At, with file order breaking a tie.

Why not file order alone: one writer appends in time order, but a merge does not. Git's union driver, which keeps two branches' acks instead of conflicting, puts one branch's rows before the other's, so after a merge the last row for a sentence is whichever branch was merged second — not whichever ack was made last.

type ActorKind

type ActorKind string

ActorKind says who made an ack (§19, §26.7).

const (
	ActorHuman ActorKind = "human"
	ActorAgent ActorKind = "agent"
)

The actor kinds. An agent ack must name the human who delegated it (Ack.DelegatedBy); System.Ack refuses one that does not.

type Baseline

type Baseline struct {
	Acked string
	Seen  string
	// Ack is the ack row Acked came from, when it came from one: the one
	// at the citation's position or, for a moved citation, the one carried
	// from its old position, or the one behind a hash refs.tsv carried
	// (carriedAck). It carries the sentence the ack approved, which check
	// compares with the sentence there now (SPEC §18). Nil when no ack row
	// names the baseline.
	Ack *Ack
}

Baseline is what one current citation is measured against: the hash it was last acked at, and the hash it was first seen at. Either may be empty; an empty Seen means the citation is new and the caller baselines it at the block's current hash.

func Baselines

func Baselines(prev []RefRow, cur []RefRow, latest map[AckKey]Ack, scannedAt time.Time, current func(RefRow) string) []Baseline

Baselines resolves, for each current citation, the acked and seen hashes it is measured against, following it across moves (see Carry). It is the one place that decision is made, so `scan` writing refs.tsv and `check` reading it cannot disagree about which ack covers which sentence.

An ack recorded at a citation's current position is the newest statement about the sentence there, and wins — with one exception, which is the point of this function. When the citation moved, the ack at its new position may belong to a different citation that used to sit on that line; taking it would let an acked sentence's approval cover an unreviewed one. So for a moved citation that ack is trusted only when it names the same sentence (both sentence hashes non-empty and equal), or, for a citation with no sentence to compare, when it was recorded after scannedAt — the last scan, whose layout the move is measured from.

type Foreign

type Foreign struct {
	Header Header
	Rows   []ForeignRow
}

Foreign is the committed snapshot of the foreign blocks a repo cites (docs/SPEC.md §21). It is what makes `check` reproducible across repos: without it the answer for one commit depends on what upstream last published and on when the index was last synced, so CI can go red with no change in the repo under test and an old commit cannot be re-run.

It holds cited ids only, never the whole merged ledger, so an unrelated upstream edit does not churn every downstream repo's `.ds/`.

func DecodeForeign

func DecodeForeign(r io.Reader) (Foreign, error)

DecodeForeign reads a snapshot.

func (Foreign) Blocks

func (f Foreign) Blocks() []block.Block

Blocks rebuilds the foreign rows as blocks, for a check that resolves citations from the snapshot instead of the live index.

func (Foreign) Bytes

func (f Foreign) Bytes() []byte

Bytes renders the snapshot.

func (Foreign) Encode

func (f Foreign) Encode(w io.Writer) error

Encode writes rows sorted by id, so a resync is a readable diff.

type ForeignRow

type ForeignRow struct {
	Row
	// Commit is the publishing repo's commit for this row.
	Commit string
}

ForeignRow is one block another repository published that this one cites: a published ledger row plus the commit that repo published it at, which a per-file header cannot carry because the rows come from several repos.

type Header struct {
	Kind   string
	Format int
	// Extract is the version of the extraction rules the hashes in this
	// file were computed under (extract.Rule). A hash is only comparable
	// with another computed under the same rule, and nothing in a hash says
	// which rule produced it — so the rule is recorded beside them. Absent,
	// which is every file written before it existed, reads as 1.
	Extract   int
	Repo      string
	Commit    string
	ScannedAt time.Time
}

Header is the metadata line at the top of every file.

type Ledger

type Ledger struct {
	Header Header
	Rows   []Row
}

Ledger is a header plus rows.

func DecodeLedger

func DecodeLedger(r io.Reader) (Ledger, error)

DecodeLedger reads a ledger file. Rows come back in file order.

func Unshard

func Unshard(shards map[string]Ledger) Ledger

Unshard merges shards back, sorted the way Encode sorts rows; the header is taken from the first shard by name.

func (Ledger) ByHash

func (l Ledger) ByHash() map[string][]Row

ByHash maps a body hash to every row carrying it, for `moved-unmarked` detection: a vanished id whose hash still exists somewhere.

func (Ledger) Bytes

func (l Ledger) Bytes() []byte

Bytes encodes to memory. Encode only fails on a writer error and a bytes.Buffer has none, so callers that need the bytes (stores, tests) get them without an error branch they could never exercise.

func (Ledger) Encode

func (l Ledger) Encode(w io.Writer) error

Encode writes the ledger sorted by id. Sorting here, not in the caller, is what makes the file diff-stable no matter how it was produced.

func (Ledger) Index

func (l Ledger) Index() map[string]Row

Index maps id to row. The last row wins on duplicates, which cannot occur in a file this package wrote; merged workspace input is deduplicated by the caller with its own collision policy.

func (Ledger) Shard

func (l Ledger) Shard() map[string]Ledger

Shard splits a ledger by the top-level directory of each row's file (§ Scale "ledger sharded per top-level directory"), so a large repo commits many small files that merge cleanly instead of one that conflicts. Every shard carries the same header.

type RefRow

type RefRow struct {
	ID      string
	Repo    string
	Doc     string
	Line    int
	Verb    string
	Carrier block.Carrier
	// AckedHash is the block hash a human or agent last accepted for this
	// sentence. Empty until something acks it.
	AckedHash string
	// SeenHash is the block hash the first scan that recorded this citation
	// saw, and it is written once and never revised: it is the fallback
	// baseline for a citation nobody has acked, so that an unreviewed change
	// is still reported. Revising it on every scan would recreate exactly
	// the hole it exists to close. Empty when read from a format 1 file, and
	// filled by the next scan.
	SeenHash     string
	SentenceHash string
	Env          string
	Args         map[string]string
}

RefRow is one reference as stored. AckedHash, SeenHash and SentenceHash are what `check` compares to decide ok versus unacked.

func FromReference

func FromReference(repo string, r block.Reference) RefRow

FromReference flattens a scanned reference. AckedHash and SeenHash start empty; acks fill the first and Snapshot fills the second.

func (RefRow) Key

func (r RefRow) Key() AckKey

Key is the position an ack for this citation is recorded against.

func (RefRow) ToReference

func (r RefRow) ToReference() block.Reference

ToReference rebuilds a reference from a row for merged-workspace checks; Sentence is not stored, only its hash.

type Refs

type Refs struct {
	Header Header
	Rows   []RefRow
}

Refs is a header plus reference rows.

func DecodeRefs

func DecodeRefs(r io.Reader) (Refs, error)

DecodeRefs reads a refs file.

func (Refs) ByID

func (r Refs) ByID() map[string][]RefRow

ByID groups references by the id they point at: the reverse index.

func (Refs) Bytes

func (r Refs) Bytes() []byte

Bytes encodes to memory; see Ledger.Bytes.

func (Refs) Encode

func (r Refs) Encode(w io.Writer) error

Encode writes references sorted by repo, doc, line, verb, id.

type Row

type Row struct {
	ID        string
	Repo      string
	Kind      block.Kind
	File      string
	Symbol    string
	Start     int
	End       int
	Hash      string
	Owner     string
	Stability block.Stability
	Env       string
	Args      map[string]string
}

Row is one defined block as stored. It is a flattened block.Block plus the repo that owns it. Args carries every directive key so nothing is lost; the named columns duplicate the ones reports sort and filter on.

func FromBlock

func FromBlock(repo string, b block.Block) Row

FromBlock flattens a scanned block into a row.

func (Row) ToBlock

func (r Row) ToBlock() block.Block

ToBlock rebuilds a block (without Content) from a row, for matching and rendering permalinks without re-reading source.

Jump to

Keyboard shortcuts

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