Reference · v0.44.0
Storage
From the pinned CLI source. Read upstream.
Current format policy: Portable .tine files default to v5 (since 0.42). --for-legacy writes v4. The .tine/ repository format is v3. tine evidence export writes v4.
V3 repository and remote protocol
Implementing OpenTine in another language? This document is format policy and history. The normative, byte-level specification — envelope framing, oid derivation, both canonicalizations, the repository layout, the TINEPACK3 pack format, and all three signature schemes — is SPEC.md.
OpenTine 0.3.0 stores agent history under .tine/ using Git-shaped concepts,
while retaining portable *.tine v2 compatibility files.
Advanced verbs appear here in their 0.16 group spelling (tine repo …, tine share …,
tine trust …, tine agent …); the older top-level spellings such as tine fsck or
tine promote still work unchanged. The full mapping is in CLI.md.
Object model
The immutable object types are:
| Type | Purpose |
|---|---|
blob | raw prompts, outputs, patches, tool results, artifacts, and manifests |
event | normalized model, tool, human, policy, approval, error, or subagent activity |
run | event roots/tips plus code, environment, policy, budget, and pricing manifests |
attestation | signatures, evaluations, approvals, and provenance claims; optionally signed at tine-attest/1 |
annotation | separately versioned mutable human/system metadata |
A workspace snapshot (0.38+) is a tree blob: a blob at schema 2 whose
canonical JSON body lists {mode, name, oid} entries, one tree per directory
(SPEC §1.5.7). An event links the snapshot taken at its step in its tree
field. fsck, gc, prune, pack, bundle and fetch/push walk a tree's
entries like any other link; a 0.34 reader accepts trees as opaque blobs.
opentine._snapshot.snapshot(repo, root, ignore=..., max_file_bytes=..., cache=...) stores one (deduplicated against earlier turns: unchanged files
and directories keep their oids, and the returned cache skips rereading
unchanged files); opentine._tree.link_tree(event_payload, tree_oid) links it
from an event; tine rewind TREE --apply (or RUN@STEP --tree) restores it.
An object ID is TYPE:sha256:HEX, computed over the type, schema version, and
canonical stored bytes. JSON uses RFC 8785/JCS encoding; blobs retain raw bytes.
Credential redaction precedes canonicalization and hashing. Identical objects
deduplicate naturally.
This is the key difference from portable v2 identity. A v3 repository id is a
content hash, so two identical forks collapse to one run object. A v2 .tine
fork id names the fork act — derived from lineage, the retained slice, the
branch, the caller's declared intent, and a recorded nonce, stored in
metadata.fork, and provable with verify_fork_id — so forking the same point
twice produces two distinct runs rather than one shared id. Both are digests;
neither lets an untrusted run id steer an output path. Importing a v2 run
re-represents its steps as content-addressed event objects, but load_run
reconstructs it with its original run id and metadata.fork, so a fork's
verify_fork_id verdict survives the round-trip. A fork created inside a
repository is a new content-addressed run object that carries no metadata.fork
record, so verify_fork_id returns None (no verdict) for it.
Integer inputs outside JSON's interoperable ±(2**53−1) range are rejected; callers must encode 64-bit identifiers, nanosecond timestamps, or arbitrary- precision amounts as strings instead of accepting silent IEEE-754 rounding.
The dependency-free trusted kernel owns only canonical encoding, typed IDs, immutable envelopes, typed parent/causal-link checks, object verification, and a tiny repository protocol. CI caps the complete kernel and every production Python module at 250 physical lines.
Local layout and operations
.tine/ contains loose objects, refs, reflogs, deterministic compressed packs,
configuration, shallow boundaries, and rebuildable indexes. The public API
includes Repo.init/open, put/get, CAS update_ref, log, semantic diff,
fork, verify/fsck, pack, fetch, and push.
from opentine import Repo
repo = Repo.init("workspace")
oid = repo.put("blob", b"artifact")
repo.update_ref("tags/example", oid, expected_old=None)
assert repo.fsck(deep=True).ok
update_ref is strict compare-and-swap: a contended ref lock is waited for
(bounded, jittered backoff), but an expected_old that no longer matches raises
RefCasMismatch (a ValueError) and is never retried, because the caller's new
value was computed from a stale state. When the new value is a pure function of
the current one (an append), opt in to re-read-and-retry instead:
repo.update_ref_rebase("tags/log", lambda current: make_entry_after(current))
Six ref namespaces are accepted — heads/, experiments/, promotions/,
annotations/, tags/, and remotes/ — and any other name is refused.
heads/*, experiments/*, and promotions/* move between run objects, and
annotations/* between annotation objects; tags/* and remotes/* carry no
type constraint and may label any immutable object. tine init creates
refs/annotations, refs/heads, and refs/tags.
An annotations/* ref carries one further rule. Its target must be an
annotation whose target_id is a run, and the ref name after annotations/
must equal that run's hex digest, so annotations/<digest> is the only
annotations/* name that can point at an annotation of run:sha256:<digest>.
A ref naming a
different run, an annotation without a string target_id, or an annotation of
a non-run object is refused rather than stored.
Attestations created by the v3 workflow target runs. These type rules are
enforced on write, read, pack verification, remote ref updates, and by fsck.
fsck recomputes every ID, validates envelope canonicality, typed links,
shallow boundaries, refs, and the event DAG. Ref updates are compare-and-swap;
a stale expected value is rejected instead of silently overwriting another
writer.
Semantic diff reports common/divergent events, cost, latency, tool path,
content/blob and artifact changes, usage, billing, and evaluation scores.
Exact common/only sets use object identity; the changed pairs are a
sequence-position comparison of divergent events, not a causal merge alignment.
Transcript line merging is not an operation. Agents select, compose, fork,
resume, evaluate, attest, and promote run graphs instead.
Verb names: the repo- prefix rule
tine carries two command families. The legacy v2 verbs read and write portable
.tine artifacts through the .tine_runs file index; the v3 verbs read and
write objects and refs inside a .tine repository. They are different stores,
so where both families need the same word, the v3 verb takes a repo- prefix.
The prefix is a collision marker, not a namespace. It appears only where a
legacy verb already owns the plain name, which is why context, attest,
evaluate, and promote are bare — v2 has no such verbs. Do not add a
repo- prefix for symmetry.
Legacy verb (.tine file + .tine_runs index) | v3 verb (.tine repository) | MCP tool |
|---|---|---|
tine show <run> | tine repo-show <ref-or-oid> | — (inspect_object is tine object) |
tine search <query> | tine repo-search [query] | search_runs |
tine diff <a> <b> | tine repo-diff <a> <b> | semantic_diff |
tine fork <run> --from-step N | tine repo-fork <ref-or-oid> --from-event OID | fork_run_v3 |
tine resume <run> | tine repo-resume <ref-or-oid> | resume_run_v3 |
repo-log is the one prefix without a collision: log was never a legacy verb,
but repo-log shipped in 0.3.0 and renaming a released verb is a breaking
change, so it is grandfathered. tests/test_repo_cli_parity.py asserts the rule
and pins that single exception, so a second one cannot appear by accident.
The two families never share state. tine fork branches a run file and writes
.tine_runs/<run-id>.tine; tine repo-fork branches a run object and moves a
repository ref. A v2 artifact reaches the v3 side only through tine repo migrate --to-v3.
Read verbs and their --json contract
Five read verbs expose repository engines on the command line. Each renders for
a human by default and emits exactly one JSON object with --json:
tine repo-show heads/main --repo . [--json] # a whole run, as a step tree
tine context <event-oid> --repo . [--depth N] # only that event's ancestors
tine repo-log [ref] --repo . [--limit N] # the event ancestry, one per line
tine repo-diff <left> <right> --repo . # the semantic diff of two runs
tine repo-search [query] --repo . [--limit N] # completed runs, by content
repo-show accepts a ref name or a run:sha256:… oid; context requires an
event:sha256:… oid and defaults to --depth 8, the same default the MCP
context_slice tool uses, so an operator reproducing what an agent saw gets the
same slice. Every one of the five is read-only and never writes a ref.
repo-diff takes a ref name or a run oid on either side, resolves each once, and
compares the resolved objects, so the left_id/right_id it reports are exactly
what was diffed. It is the semantic_diff MCP tool, so the human table and the
JSON carry the same divergence sets and the same five summary dimensions.
With --exit-code it follows git diff: 0 when the runs are identical,
1 when they differ. Identical means no event-level divergence — no
only_left, no only_right, no changed — which the JSON also reports as
identical. Without --exit-code a successful comparison always exits 0.
A usage error is argparse's 2, which the verb itself never emits, so a script
can distinguish "the runs differ" from "you typed the command wrong".
repo-search mirrors the MCP search_runs tool exactly: it defaults to
successful_only (completed runs only, flipped by --include-unsuccessful) and
to --limit 20, with --min-score and --model as the same optional filters.
An empty query lists candidate runs; a query is matched case-insensitively
against event input and output blob text, and the matching prefix comes back as
matched_text.
A run's score prefers signed evaluations: when any exist, the best signed one
decides it, and runs scored by signed evaluations rank above runs scored only by
unsigned ones — any MCP client can write an unsigned evaluation, and through
0.9.0 one injected score decided the rank. Each result carries score_signed,
and --signed-only (signed_only= in the library and in search_runs) ignores
unsigned evaluations entirely. Signed means a signature block is present, not
that it verified; check it with tine repo-verify and a trusted key.
Search is a bounded scan, not an index. It walks up to
MAX_SEARCH_OBJECTS = 100,000 repository objects per invocation and reads blob
text under further aggregate byte budgets, so its latency grows with repository
size and it is deliberately not on any write path. --limit bounds the result
set, not the scan — it is what keeps output readable, not what makes the command
fast. Exceeding any of the scan budgets is a refusal, printed as a single
tine repo-search: <message> line with exit 1, never a partial result presented
as a complete one.
The derived index (0.44). tine repo-search --rebuild-index writes an
SQLite FTS5 index to .tine/index/search.sqlite (format opentine-search/1)
holding each run's stored prompts and completions, tool names and arguments,
file paths named in those arguments, and run metadata (status, models, tags,
annotation metadata). It is derived and never authoritative: it copies only what
the store holds, after redaction; deleting it loses nothing. Once it exists,
put_run and every pack import (push, fetch, bundle import) update it; if an
update fails the file is removed and search scans again. While it is present
repo-search (and tine search --repo, and MCP search_runs and recall)
answer from it: every query word must occur (case- and diacritic-insensitive
tokens, not a substring match), results rank by BM25, an empty query lists runs
newest first, and the 10,000-candidate cap does not apply. Evaluation scores are
still read from the attestations, for the returned rows only. --no-index
forces the scan. A Python whose sqlite3 lacks FTS5 never builds the index and
keeps scanning; --rebuild-index then exits with that reason.
scripts/bench/search_index.py measures a cold 3-term query over 100,000 runs.
The JSON objects come from the same writer as tine show --json — literally the
one function opentine/_cli_json.py:serialize, which every --json surface in
the CLI calls: keys are
sorted, every value passes json_safe, and each object carries command, which
names the schema below. Within a major version fields are added, never renamed
or removed. A failure that stops a verb producing a result — an unresolvable
ref, a non-event id, a negative depth, a run beyond a shallow boundary — is a
single tine <verb>: <message> line on stderr and exit 1, not JSON.
The five plumbing verbs have no --json flag
fsck, object, migrate-v3, fetch, and push print bare JSON
unconditionally and deliberately take no --json flag. They are plumbing: a
consistency report, an object dump, a migration receipt, and two transport
receipts. None of them has a rich human rendering that --json would replace,
so a flag to ask for JSON would only be a flag you could forget. Their output
bytes are pinned by tests (tests/test_cli_surface_json.py,
tests/test_repo_cli_routing.py, tests/test_cli_v3.py) and are part of the
compatibility surface: scripts parse them as they stand. Everywhere else —
including all four tine pricing subcommands — JSON is opt-in behind --json,
and the human rendering is the default.
| Verb | Key | Type | Meaning |
|---|---|---|---|
repo-show | command | str | "repo-show" |
repo | str | repository the run was read from | |
ref | str | the ref or run oid as the caller spelled it | |
run_object_id | str | the run:sha256:… oid ref resolved to | |
run | object | id, status, model, created_at, total_cost, step_count, tags, user_prompt, system_prompt | |
steps | array | the tine show --json step shape — id, kind, parent_ids, provider, model, cost, duration, timestamp, inputs, outputs, usage, billing, error, tool — where id is the event's v3 oid | |
context | command | str | "context" |
repo | str | repository the slice was read from | |
event | str | the event oid the slice was requested for | |
depth | int | causal depth requested | |
count | int | number of entries | |
entries | array | oldest first: oid, object_type, kind | |
repo-log | command | str | "repo-log" |
repo | str | repository the ancestry was read from | |
ref | str | the ref or oid the walk started at | |
count | int | number of entries | |
entries | array | oid, object_type, kind | |
repo-diff | command | str | "repo-diff" |
repo | str | repository the runs were read from | |
left, right | str | each side as the caller spelled it | |
left_id, right_id | str | the run:sha256:… oid each side resolved to | |
identical | bool | the --exit-code predicate: no divergent events | |
common_events | array | event oids both runs contain | |
only_left, only_right | array | event oids unique to one side | |
changed | array | position-aligned divergent pairs: index, before, after, fields | |
summary | object | cost, latency, artifacts, evaluations, tool_path, each {left, right} | |
repo-search | command | str | "repo-search" — distinct from the legacy index-backed "search", which emits runs |
repo | str | repository searched | |
query | str | the query, empty when none was given | |
successful_only | bool | false only with --include-unsuccessful | |
limit | int | result cap as requested (default 20) | |
min_score, model | float/str or null | the optional filters, null when unset | |
count | int | rows in results, never more than limit | |
results | array | run_id, status, score (null when unevaluated), cost, latency, models, matched_text |
repo-diff emits every SemanticDiff field verbatim and adds only the envelope
keys above, so a new engine field appears in the JSON without a schema change.
Write verbs
Three verbs mutate a repository. They are the operator half of the surface: MCP
withholds promote_run unless the host opts in, because a model reading
untrusted run content must not be able to move a release gate, but the person at
the terminal already holds the repository.
tine trust attest <run-ref-or-oid> --signer NAME (--claim JSON | --claim-file PATH) \
[--evidence OID]... [--key-env VAR | --key-file PATH | --ed25519-key-file PATH] \
[--key-id ID] [--repo .] [--json]
tine trust evaluate <run-ref-or-oid> --evaluator NAME --score NAME=VALUE... \
[--key-env VAR | --key-file PATH | --ed25519-key-file PATH] [--key-id ID] [--json]
tine trust promote <run-ref-or-oid> --name NAME [--expected-old OID] [--json]
tine repo-verify <attestation-oid | run-ref-or-oid> \
[--key-env VAR | --key-file PATH | --pubkey PATH | --trust-embedded-key] \
[--pin FINGERPRINT]... [--require-signature] [--signer NAME]... [--claim JSON] \
[--repo .] [--json]
Each accepts a ref name or a run:sha256:… oid and resolves it first. That
resolution is not cosmetic: attest hands target_id to the object store,
whose link check requires an object that already exists, and promote hands its
run into update_ref, which rejects a ref string outright. After resolving,
each verb makes exactly one engine call — Repo.attest or Repo.promote — so a
CLI-written object is byte-identical to the object the matching MCP tool writes.
A target that resolves to no object, or to something that is not a run, is
refused before anything is written.
attest stores the claim verbatim inside a content-addressed attestation. The
claim must parse to a JSON object; a list, number, or string is refused,
because every reader — the association scan behind repo-diff's
summary.evaluations, and repo-search's score scan — reads a claim as a
mapping. --evidence may be repeated and each value must be an existing object.
evaluate is attest with the claim fixed to
{"kind": "evaluation", "scores": {…}} and signer taken from --evaluator —
exactly what the MCP evaluate_run tool builds. There is deliberately only one
evaluation claim shape in the format; a second would be scores no reader could
see. Each --score must be a finite number, and a repeated name is refused
rather than silently resolved.
Signing an attestation. Given a key — --key-env/--key-file for HMAC-SHA256,
--ed25519-key-file for Ed25519, plus an optional --key-id recorded inside the
block — attest and evaluate bind the signer label to that key at
tine-attest/1. The algorithm is inferred from which flag names the key, so
there is no --algorithm to disagree with it, and two key flags are refused
rather than resolved by precedence. Given no key the verb writes the
byte-identical unsigned object every release since 0.3.0 wrote, and the receipt
still says unsigned: the label is then self-asserted, and saying otherwise
would be the lie signing exists to remove. The signed view, the verdicts, and
what a signature does and does not prove are in
docs/SECURITY_MODEL.md.
tine repo-verify reads it back. It takes an attestation:sha256:… oid, or a run
ref/oid — in which case it checks every attestation targeting that run, which
is the release question ("is this approved, and by whom?") a promotion gate
scripts. It reports tine verify's own verdicts (verified, verified-tofu,
unsigned, no-key, mismatch, error; only the first two pass) and is
fail-closed the same way: any key, --trust-embedded-key, or
--require-signature arms the check and exits 1 unless every attestation
verified, and --require-signature also refuses a run with no attestation at
all. Unarmed, it is a report that exits 0. Scope a release gate with
--signer NAME (repeatable) and --claim JSON — the attestation's claim must
contain those keys with those values: the gate then passes when at least one
selected attestation verifies, and unselected ones (an unsigned note, a signed
evaluation that is not the approval) neither pass nor block it. Unscoped, a
verified rejection passed like an approval and any writer's unsigned note
blocked the release. Either flag arms the check. Each Ed25519 row reports its
key_fingerprint, and --pin FINGERPRINT (repeatable, the full sha256:
digest; alone it implies the embedded key) passes only attestations signed by a
pinned key, as verified. MCP has no equivalent tool: checking a
signature needs the operator's key material, and attest_run has no signing
options for the same reason — run content must not be able to sign as an
operator.
promote compare-and-swaps promotions/<name>. --expected-old omitted means
expect no existing ref, so creating a promotion is the default and moving one
always requires naming the value being replaced; the refusal on a conflict prints
the current oid and the --expected-old invocation that would move it. There is
no --force, and there will not be one: a release gate that can be overwritten
without stating what it overwrote is not a gate.
Their --json object is a receipt, emitted only after the write has
succeeded. A failure that stops the result — a CAS conflict, an unresolvable or
non-run target, an invalid claim or score — is a single tine <verb>: <message>
line on stderr with exit 1 and no JSON on stdout, so a consumer never has to
parse an object to learn whether the repository changed. A usage error is
argparse's 2, which these verbs never emit themselves.
| Verb | Key | Type | Meaning |
|---|---|---|---|
attest | command | str | "attest" |
repo | str | repository written to | |
target | str | the ref or oid as the caller spelled it | |
run_id | str | the run:sha256:… oid target resolved to | |
attestation_id | str | the new attestation:sha256:… oid | |
signer | str | the self-asserted signer label | |
claim | object | the claim as stored | |
evidence_ids | array | supporting object ids, empty by default | |
signed | bool | whether the stored attestation carries a signature | |
signature | object or null | the stored tine-attest/1 block verbatim (alg, key_id, scheme, signer, value, and public_key for Ed25519) | |
evaluate | command | str | "evaluate" |
repo, target, run_id, attestation_id | str | as above | |
evaluator | str | stored as the attestation's signer | |
scores | object | the finite scores as stored | |
signed | bool | whether the stored attestation carries a signature | |
signature | object or null | as above | |
promote | command | str | "promote" |
repo, target, run_id | str | as above | |
name | str | the promotion name | |
ref | str | always "promotions/<name>" | |
expected_old | str or null | the compare-and-swap value as given | |
created | bool | true exactly when expected_old was null | |
repo-verify | command | str | "repo-verify" |
repo, target | str | as above | |
target_id | str | the attestation or run oid target resolved to | |
require_signature | bool | whether --require-signature was passed | |
count | int | attestations checked (1 for an attestation target) | |
verified | int | how many reported a passing state | |
signers | array | the --signer values (0.9.1) | |
claim | object or null | the --claim object (0.9.1) | |
selected | int | rows the scope selects; every row when unscoped (0.9.1) | |
ok | bool | the exit predicate: unscoped, every row passed and not --require-signature with nothing to check; scoped, at least one selected row passed | |
attestations | array | per attestation: attestation_id, target_id, signer (the claimed label; bound only when state is verified), state, ok, algorithm, key_id, signed_at, scheme, reason, claim and selected (0.9.1) |
repo-verify is a read verb, so unlike the three above its --json object is
emitted whether or not the check passed — ok and the exit code carry that. Only
a refusal (an unresolvable target, an unreadable key, two key flags) stays a
single stderr line with no JSON.
Appending to a repository written by an older release is a release gate, tested
the same way reading one is: every verb runs against a copy of every committed
golden repository from 0.3.0 onward, and the copy must fsck clean afterwards.
Lineage verbs
Two more verbs branch a run graph. They are the CLI twins of the MCP
fork_run_v3 and resume_run_v3 tools, and both make exactly one engine call,
Repo.fork, with the same arguments the tool passes — so a CLI-written fork is
the same content-addressed object the tool writes.
tine repo-fork <run-ref-or-oid> --from-event OID --ref REF \
[--model M] [--prompt P] [--policy JSON] [--repo .] [--json]
tine repo-resume <run-ref-or-oid> --ref REF [--repo .] [--json]
repo-fork rebuilds a run from the causal closure of --from-event, so the
forked run contains that event and its ancestors and nothing later. --model,
--prompt, and --policy are the three overrides fork_run_v3 accepts;
--policy must parse to a JSON object, and an omitted flag is identical to an
absent override key, not to a null one.
repo-resume is the same fork taken at the run's last verified tip with
overrides={"resume": True}, which is what makes the new run status: "running" instead of a plain branch. A run with no event tip cannot be resumed
and is refused by name rather than crashing on an empty tip list.
Both accept a ref name or a run:sha256:… oid and resolve it first, then apply
the run-type check resume_run_v3 performs; a target that is not a run is
refused before anything is written.
--ref is required, has no default, and is not confined
--ref must be given on both verbs and there is deliberately no default.
A fork's ref update is an unconditional overwrite — it compare-and-swaps against
the value it just read — so a defaulted destination would mean a bare
tine repo-fork RUN silently advancing whatever ref the default named. Every
fork destination is an explicit operator choice.
The CLI is also deliberately not confined to experiments/*. That
confinement is real, and it stays where it is: at the MCP boundary, in
mcp_repository._writable_ref. Its threat model is model-controlled input — the
ref an MCP client picks is chosen from run content it just read, so an
unconfined fork tool would make "fork onto heads/main" a one-step
prompt-injection payload. An operator at a terminal already holds the
repository and can move any ref with tine trust promote or a direct write, so
confining the CLI would buy no security and would only push people around it.
The CLI still validates the ref name (canonicalizing it before the write, so a
malformed ref is refused rather than reported after the object exists).
Their --json object is a receipt, emitted only after the write succeeded;
a refusal is one tine repo-fork: <message> / tine repo-resume: <message>
line on stderr with exit 1 and no JSON on stdout.
| Verb | Key | Type | Meaning |
|---|---|---|---|
repo-fork | command | str | "repo-fork" |
repo | str | repository written to | |
target | str | the ref or oid as the caller spelled it | |
source_run_id | str | the run:sha256:… oid target resolved to | |
from_event | str | the event:sha256:… fork point | |
ref | str | the canonicalized ref now pointing at the new run | |
run_id | str | the new run:sha256:… oid | |
overrides | object | only the overrides actually given: model, prompt, policy | |
resumed | bool | always false | |
repo-resume | command | str | "repo-resume" |
repo, target, source_run_id, ref, run_id | str | as above | |
from_event | str | the tip the resume forked from | |
overrides | object | always {"resume": true} | |
resumed | bool | always true |
Forking a repository an older release wrote is a release gate exactly as
appending to one is: both verbs run against a copy of every committed golden
repository from 0.3.0 onward, and the copy must fsck clean afterwards.
short_id is deliberately absent from every v3 object. It is a twelve-character
prefix, and a v3 oid's first twelve characters are the constant run:sha256: or
event:sha25, so the field would be identical for every object in the
repository. Human output shortens an oid as type:<12 hex digits> instead,
dropping the hash-name segment rather than the digest.
entries carries no payload in either verb, exactly like the human line. Event
payloads are unbounded recorded content; tine object <oid> --repo . is the
verb that resolves one, with --resolve-blobs to follow its content blobs.
Everything these verbs print is recorded content — prompts, tool names, model
strings, and ids an agent may have been fed by an attacker. Human rendering
routes all of it through the CLI's terminal sanitizer, which strips control
bytes and bidi overrides and escapes markup. --json does not sanitize: it is
not a terminal, and a consumer must see the bytes as recorded.
V2 migration
tine init .
tine repo migrate --to-v3 legacy.tine --repo . --ref heads/imported
With a key flag (--key-env/--key-file/--pubkey/--trust-embedded-key/--pin)
the source's signature must verify, or the import is refused unless
--allow-unverified.
Migration:
- stores the exact original v2 bytes as a legacy blob;
- records the original checksum and signature-verification result;
- redacts structured v3 content before canonicalization and hashing;
- rebuilds object IDs and causal links;
- stores a deterministic old-step-ID → event-ID map;
- marks any v2 signature as
legacy_blob_only.
Migration is fail-closed by default for a bad integrity digest and for any
signature the caller requested to verify. --allow-unverified is an explicit
escape hatch and the failed result remains recorded. The exact legacy blob is
intentionally not redacted, so it can contain credentials present in the source
artifact and should be treated as sensitive.
It never claims a v2 signature authenticates newly generated v3 objects. This also closes a v2 identity limitation: v2 compatibility saves can redact fields after a step ID was formed, whereas v3 always hashes the redacted stored bytes.
Agent-facing traces
Recorder starts a run with code/dirty patch, environment, policy, budget, and
pricing manifests, then appends normalized events live. The code manifest marks
capture failures explicitly when Git state is unavailable or a patch exceeds the
16 MiB capture ceiling. Untracked paths mark the worktree dirty and are listed,
but their contents are not captured, so the manifest is explicitly incomplete
until they are added or staged. Importers accept native
OpenTine records, JSONL, and OpenTelemetry GenAI spans or complete OTLP/JSON
exports. Malformed JSONL lines are skipped independently, large integers are
string-preserved, and imported dependencies are ordered before recording.
Span lookup is qualified by trace identity; duplicates and dependency cycles
are rejected, while unresolved boundaries in partial traces are retained on the
stored event as unresolved_span_refs.
Framework importers best-effort normalize common serialized shapes
from LangChain, LlamaIndex, AutoGen, CrewAI, and OpenAI Agents.
Import parsers can normalize larger input sources, but one stored Recorder run
is limited to 3,000 events. The limit is checked before any event data is written;
bulk imports preserve dependency order and commit one final run snapshot. This
keeps even a worst-case graph with unique input and output blobs below the 10,000
object pack-negotiation ceiling.
Local search retrieves successful runs and evaluation scores. context_slice
walks only parent and causal links needed for an event. Forking can replace
model, prompt, or policy from the last good event. Evaluations and approvals
are content-addressed, tamper-evident attestations; the signer is self-asserted
unless the attestation is signed at tine-attest/1, which an operator does with
tine trust attest --key-file … and checks with tine repo-verify. Promotion is a CAS
ref update.
When an MCP server starts inside a repository, it adds search_runs,
inspect_object, context_slice, semantic_diff, fork_run_v3,
resume_run_v3, evaluate_run, and attest_run, plus verified object
resources. promote_run is not among them by default. It is registered only
when the host opts in with allow_promotion=True, because a promotion ref is a
release gate and the run content an MCP client reads is untrusted, so text
recorded inside a run can ask the model to promote a run of an attacker's
choosing. fork_run_v3 and resume_run_v3 may write only experiments/*
refs; the namespace is checked on the canonical ref name, so mainline,
promotion, tag, and remote-tracking refs stay operator-only.
Promotion being an operator CLI verb (tine trust promote) does not change that
default. The two surfaces are trusted differently on purpose: an operator at a
terminal already holds the repository, while a model over MCP is acting on
content it read out of that repository. allow_promotion remains False.
CLI ↔ MCP parity, and the four places they differ
Every MCP tool has a CLI verb and every v3 verb is accounted for. tests/test_repo_cli_parity.py holds the map and fails CI in both directions: a new tool without a verb is red, and so is a verb that is neither mapped to a tool nor listed as deliberately CLI-only.
| MCP tool | CLI verb |
|---|---|
search_runs | tine repo-search |
inspect_object | tine object |
context_slice | tine context |
semantic_diff | tine repo-diff |
fork_run_v3 | tine repo-fork |
resume_run_v3 | tine repo-resume |
evaluate_run | tine trust evaluate |
attest_run | tine trust attest |
promote_run | tine trust promote |
The CLI-only verbs are init, fsck, pack, migrate-v3, fetch, push,
and clone — repository administration and transport, each handing out a
filesystem path, a network endpoint, or a credential that has no business being
driven by run content — plus repo-show and repo-log, which render a run tree
and an event ancestry for a terminal; a model reads the same objects through
inspect_object and context_slice.
Four differences are deliberate. Each is asserted as a fact in the parity test, so making the surfaces agree turns CI red and forces the argument to be re-made.
promoteis unconditional on the CLI, opt-in over MCP. No flag, no environment variable. The reasoning is the paragraph above.repo-fork/repo-resumeare not confined toexperiments/*. The namespace confinement is an MCP-boundary control against model-chosen refs; see the lineage-verb section.- CLI
--jsonreceipts are a superset of the MCP return.fork_run_v3returns{ref, run_id}— a model's working set. The CLI receipt adds what the operator typed and what it resolved to, because it is an audit artifact. Shared keys always carry the same values. - The CLI refuses non-finite evaluation scores; MCP passes them through.
tine trust evaluate --score q=nanis refused at the argument door, naming the flag, before any engine call, becauserepo-searchaverages scores.evaluate_runtakes an already-typeddict[str, float]and adds no check of its own, so the value travels intocanonical_jsonand is refused there as a kernel error about JSON encoding. Nothing non-finite is stored either way — the kernel is the backstop — but the CLI is the stricter and more legible surface, and that asymmetry is recorded rather than levelled.
Search and inspection fail closed at explicit implementation ceilings: search indexes at most 100,000 objects, 10,000 candidate runs, and 100,000 aggregate event references while bounding structured and blob source bytes. Direct blob inspection returns a verified 512 KiB prefix; resolved inspection returns at most 1 MiB across 64 referenced blobs and marks truncated results explicitly.
Compression and packs
Since 0.11 Repo.init creates a version-2 repository (config.json carries
"version": 2 and "storage": {"compression": …}) whose loose objects are
stored compressed (zstd on Python 3.14 or with opentine[zstd], zlib
otherwise). Object ids stay over the canonical bytes, so a compressed and a
plain repository hold the same objects. OPENTINE_COMPRESS=0 or
tine --no-compress creates the plain version-1 layout older releases read,
and an existing version-1 repository is never written compressed until
tine repo storage gc upgrades it.
The maintenance verbs below are also spelled under the repo group (0.16+):
tine repo storage gc, tine repo storage repack, tine repo storage du,
tine repo storage prune, tine repo storage compress, tine repo storage fsck
and tine repo storage reindex are exactly tine gc, tine repack, tine du,
tine prune, tine compress, tine fsck and tine reindex; both spellings
take the same flags.
-
tine repo storage gcmoves loose objects into compressed local packs (.tine/packs/*.tpk+.tidx): a pack is written and read back object by object before anything loose is deleted (about 18x less disk on real sessions). Redundant copies of received transfer packs are removed unless--keep-transfer-packs. -
tine repo storage gcandtine repo storage repackhold.tine/gc.lock, so two packers never race. The lock (like every ref lock) records pid, host and start time; a lock left by a crashed process on the same host is taken over once that process is provably gone, on every platform and never by age alone (OPENTINE_GC_BREAK_LOCK=1forces it). Any other lock is reported with its path. An interrupted gc loses nothing and can be re-run. -
tine repo storage repackmerges every local pack into one. -
Behaviour change in 0.39:
tine repo storage gc(andrepack) prunes unreachable objects again by default (--prune=retention), but only those outside the retention window and never held ones. The window comes from.tine/retention.toml(default 14 days when the file is absent):keep_days = 30 # default window legal_hold = ["blob:sha256:…"] # never deleted [refs] # ref namespace (fnmatch) -> days "heads/release/*" = 365 [status] # run status -> days failed = 7 [tier] # assurance tier -> days high = 3650An object keeps the longest window that applies (rules only lengthen it); facts are read from a
runobject'sref,statusandassurance_tier, other objects getkeep_days. Prune's safety floor (MIN_GRACE, just over an hour) and quarantine still apply.--prune=neverrestores the 0.35-0.38 behaviour and--prune=nowignores the window (holds still kept).tine retention showprints the effective policy;tine retention checkis a dry run listing what default gc would delete now. The sameRetentionPolicyimplements the remoteRetentionHook(retain_until/before_delete). -
From 0.35 to 0.38
tine repo storage gcdeleted no unreachable objects unless--prune=now.tine hold add|list|release <oid|ref>records holds in.tine/holds;prune,gc --prune=now,rewind --applyand remote deletes and purges keep held objects and everything they reach. -
tine repo storage gc --upgrade(0.13+, opt-in, never automatic) moves a repository to storage version 3: packs of large single-kind blocks, run snapshots stored as their parent plus the newly appended events, events without their empty default fields, and a binary index. Every read rebuilds the original bytes and checks them against the object id. About 2x smaller than version-2 packs on recorded sessions. opentine 0.12 and older refuse a version-3 repository (repository config is incompatible);tine repo storage gc --downgraderewrites it for version 2 so they can open it again. Latergc,repack, auto-pack andprunekeep writing v3 packs in a version-3 repository. -
tine repo storage gc --pack-refs(0.37+, opt-in) folds every loose ref into one.tine/packed-refsfile (<oid> <ref>lines, sorted), solist_refsis one read instead of one open per ref; later gcs keep folding new loose refs. Reads look at the loose file first, then the packed value; writes still land loose (and shadow it); a delete removes both underpacked-refs.lock. A loose ref is removed only when it still equals its packed value under its own lock, so a concurrent writer is never undone. Because opentine 0.36 and older never readpacked-refs(they would see the refs as missing, and prune would treat their history as unreachable), the opt-in writes"refs": "packed"and"min_reader": "0.37.0"toconfig.json(object format 4 is required), and those releases refuse the repository; without the opt-in gc never touches a ref.tine repo storage gc --unpack-refswrites every ref loose again and lowersmin_readerback to 0.26.0. Reflogs stay one file per ref. -
tine repo storage gc --blob-deltas(0.40+, opt-in) stores blobs in local v3 packs as blob deltas: the bytes that differ from a similar blob of the same pack (its base, chosen by the longest shared prefix plus suffix among the 16 blobs before it in recording order). A run's transcript, whichput_transcriptsaves whole after every turn, then costs one turn per save. Every read rebuilds the canonical bytes and re-hashes each object of the chain against its id; a chain deeper than 64 or one that loops is refused with an error naming the object. The opt-in moves the repository to storage version 3 and object format 4 and writes"packs": "delta"plus"min_reader": "0.40.0", so opentine 0.39 and older refuse it instead of failing on a pack entry. Push, fetch, bundles and exports send canonical objects, so peers on older releases are unaffected.gcand auto-pack both write the form once opted in (auto-pack only because it met the 0.40 latency gate,docs/bench/0.40-cdc-bakeoff.md).tine repo storage gc --no-blob-deltasrewrites every pack without deltas, then lowersmin_readeragain (0.37.0 with packed refs, else 0.26.0). -
Pack indexes are read lazily (0.37+): the binary v3
.tidxis mapped into memory (read into memory on Windows, where a mapping would stop gc from replacing it) and searched through a fanout table, instead of every entry being parsed into one dict at a process's first object read. Opening an index checks its header, length, trailing SHA-256 and block table; each entry a lookup returns is checked on its own; the whole-file rules (sorted, unique, tiling) run for bulk walks (gc, du,verify_packs).has()asks the pack index before probing the loose path. Version-1 JSON indexes are still read. -
Auto-pack (0.37+) triggers on cold loose objects by count (256, or
"auto_pack": {"objects": N}/OPENTINE_AUTO_PACK=N) or by the disk space they occupy (16 MiB, or"auto_pack": {"bytes": N}/OPENTINE_AUTO_PACK_BYTES=N), each file counted as at least one 4 KiB block. -
tine repo archive create FILEwrites the whole repository (objects, refs, shallow boundaries) as one compressed file;tine repo archive restore FILE DESTverifies the pack digest and every object id and sets refs through the normal validated path, into a new repository only.
Push, fetch and clone are unchanged: transfer packs carry canonical bytes, so compressed and plain repositories exchange objects freely.
Packs and synchronization
TINEPACK3 packs are canonical manifests of verified envelopes, compressed
with a SHA-256 checksum. Negotiation sends only objects reachable from wanted
IDs that the receiver does not already have. Since 0.37 a server advertises
"negotiation": ["haves", "tips"], and a fetching client then sends its ref
tips (at most 4096 ref targets it holds) instead of every local oid as a have;
the server's walk stops at a tip it holds, though runs met there still bring
their associations (a redundant re-send at worst, never a missing object). A
client with shallow boundaries, or facing a server that does not advertise
tips (0.36 and older), sends haves exactly as before; a 0.36 client's haves
are still answered by a 0.37 server. Packs support shallow boundaries
and filtered fetch without pretending omitted history is present. Compressed
transfers and decompressed manifests are capped at 256 MiB and trailing zlib
streams/data are rejected. Repository-wide shallow state is validated and
capped at 10,000 object IDs / 1 MiB, then cached for link verification; local
object and negotiation listings stop at protocol limits before sorting. Client
control responses are streamed under a 1 MiB
raw-byte cap and non-identity HTTP content encodings are rejected; resumable
offsets must advance, loops are bounded, transient short reads retain partial
upload state, and abandoned upload state is reaped.
Peers pick the transfer format from each other's pack_formats:
- 0.37 and newer (with zstd) send
TINEPACK5(SPEC §2.8). Objects come in recording order, as one zstd long-distance frame (window 2^27, level 9) flushed into 4 MiB blocks that are read in order. Readers refuse a larger window. - 0.34–0.36 peers get
TINEPACK4. - 0.11 and older peers get
TINEPACK3.
Bodies compressed past 1000:1 (codec v2 frames) go only to a peer that
advertises 0.35 or newer, and never into a .tine file.
scripts/bench/tinepack5.py measures the wire size and compress throughput of
TINEPACK4 and TINEPACK5 on a synthetic corpus of similar runs with growing
histories.
The pack layer is explicit about omitted history; the local read surface treats
it in two different ways, and a reader of a shallow clone must know which. log,
diff, and context_slice stop at the boundary silently: a cut object is
skipped, and SemanticDiff carries no truncation marker, so its cost, latency,
tool-path, and artifact aggregates are computed only over the events present
locally and omit history beyond the boundary without saying so. load_run,
fork, and resume instead refuse, with a typed error naming the object that
lies beyond the repository's shallow fetch boundary and directing the caller to
deepen the fetch. Treat aggregates read from a shallow clone as lower bounds
until the clone is deepened.
The HTTP protocol exposes:
- unauthenticated capability discovery;
- authenticated ref discovery and missing-object negotiation;
- deterministic pack download and resumable, checksummed upload;
- depth/object-type filtered fetch;
- compare-and-swap ref updates.
Clients require HTTPS except when the base URL host is a literal loopback IP
address (127.0.0.1, ::1), which is accepted over plain http:// with no
opt-in, or when --allow-insecure development is requested explicitly. The
hostname localhost is not a literal address and is refused without that flag.
Authenticated clients ignore ambient proxy variables so local development
credentials are not forwarded unexpectedly. Push uploads verified missing
objects before attempting the CAS ref update.
Serverless sharing: bundles and plain git remotes
Since 0.15, runs can be shared with no opentine server.
tine share bundle create FILE [REFS...] | --allwrites one self-verifying file:TINEBUNDLE1magic, a SHA-256 over the rest, a canonical JSON manifest (format, the pack's SHA-256 and length,ref -> object id) and an ordinary transfer pack. The pack carries each run's associations (annotations and attestations) exactly as a fetch would.--allbundles every local ref exceptremotes/*; a bare name meansheads/NAME. The pack isTINEPACK3, which every release reads. With--pack-format TINEPACK5(0.37+) it is TINEPACK5 with run deltas and slim events. That pack is much smaller, but only 0.37 and newer can read it.tine share bundle verify FILEchecks it without a repository: size caps, both digests, canonical manifest, every ref name throughnormalize_ref(no.., absolute, upper-case, control characters orremotes/*), every ref target present in the pack, and every packed object against its id.tine share bundle unbundle FILE(aliasimport) verifies, installs the objects and moves refs only underremotes/<name>/…(heads/dropped, like fetch);<name>is--as NAMEor derived from the file name. A tracking ref that already exists moves only to a descendant (itsforked_fromchain) unless--force; refs move atomically (all or none). Re-importing is a no-op.tine share bundle push REMOTE [REFS...]andtine share bundle fetch REMOTE [--as NAME]carry a bundle over any git remote (GitHub, Gitea, a bare repository, Radicle's git, a local path). The remote gets one ref,refs/tine/bundle: a commit whose tree is a singlebundle.tineblob, parented on the commit it replaces, so a push that raced another is refused by git's fast-forward check. git runs in a throwaway bare repository with an argument list, a scrubbed environment, the hardened-cset, only thefile/https/sshprotocols (noext::, no option-shaped remotes) and--timeout(default 120 s). The fetched blob is size-checked before it is read and fully verified before any local ref moves. Credentials come from your normal git/ssh setup.
Bundles are capped at the transfer-pack limit (256 MiB) plus a 4 MiB manifest.
Unlike tine repo archive (whole repository, new destination only), a bundle carries
chosen refs into an existing repository. Each bundle push uploads a full
snapshot of the chosen refs, not a delta.
Reference self-hosted remote
Extension interfaces cover ObjectStore, IndexBackend, IdentityProvider,
AuthorizationPolicy, KeyProvider, AuditSink, RetentionHook, and
AdmissionPolicy. The reference deployment implements them with encrypted
filesystem objects and SQLite. A StaticTokenIdentityProvider supports
development identities. Retention hooks gate object deletion, and admission
policies can reject pack bytes, object counts, or ref updates; resumable uploads
invoke admission at declaration and again after pack inspection, so a policy can
bound both bytes and object counts.
The security properties of that deployment — encryption at rest and key provision, identity and tenant-scoped authorization, the HMAC-chained audit log and its authenticated head outside SQLite, the listing and annotation ceilings, and the operator responsibilities that remain — are specified in SECURITY_MODEL.md and are not restated here.
tine repo serve --root DIR [--tenant T] ACTION runs operator maintenance on that
storage instead of serving: refs, delete-ref NAME [--expect OID],
associations OID, delete-objects OID… [--from FILE|-] [--dry-run] and
purge [--grace-seconds N] [--dry-run], each printing JSON. It needs
TINE_KMS_KEY (the server's key) and no token; see SECURITY_MODEL.md for what
each keeps and refuses.
A fetch response may carry Opentine-Associations-Omitted: N — N runs whose
annotations or attestations did not all fit in the pack. Clients that predate
it ignore it; tine share fetch reports it as associations_omitted. Associations are
budgeted by object count, encoded pack bytes, source bytes and the
shallow-boundary list a depth-limited pack carries, so an association
set that does not fit is left out rather than failing the fetch. A pack that
still cannot be built with associations — the receiver's haves or a type filter
can add shallow boundaries after the budget is checked — is rebuilt from the
object graph alone and its runs reported as omitted; this applies to fetch,
typed fetch and push. A graph that does not fit by itself still fails. The
association phase also has a total read budget, twice the pack body budget,
charged before every read and never returned when a set is rolled back; once it
is spent, the remaining runs' associations are reported omitted rather than
read, so one oversized evidence set shared by many runs is read a bounded number
of times.
fetch adopts the advertised annotations/* refs of the runs it delivers —
never one whose chain holds an annotation recorded as foreign at install, so a
remote cannot plant a note through its ref for a run it only names as a shallow
boundary. For a
run the local repository already held, the remote's annotation head is stored
as remotes/<name>/annotations/<digest> and left unadopted; tine fetch --adopt-annotations (fetch(..., adopt_annotations=True)) adopts those
deliberately. The .tine/unadopted record is bounded: a foreign annotation
that no longer fits is not installed (with a warning), and a damaged or
over-limit record makes the unheaded fallback adopt nothing instead of failing.
So do two or more independent unheaded chain tips for one run (with a
warning); the next tag of that run writes a fresh head under its
annotations/<digest> ref.
Unheaded lookups use the non-authoritative target caches in .tine/indexes/. tine share clone refuses a target that already holds refs or objects
(use fetch). A remote URL may not carry user:pass@, a query or a fragment —
checked on the URL as given, before an embedded tenant
(…/v1/tenants/acme) is split off; pass the token with --token-file or
TINE_REMOTE_TOKEN — a remote tenant must match [a-z0-9][a-z0-9._-]*, and
remote errors name only the HTTP status and path. tine repo serve purge --grace-seconds N with
N below 60 needs --force (or --dry-run).
POST /search returns {"objects": [...], "truncated": bool} — at most 1,000
objects, newest first. GET /audit/verify returns {"ok", "status", "warnings"} (no chain head). A busy server answers 503; retry.
The 0.3.0 scope is an enterprise repository foundation. The bundled bounded WSGI server targets development and small self-hosted deployments, not turnkey high availability. S3-compatible object storage, PostgreSQL/search backends, managed identity configuration, and a hosted control plane remain production adapters or later 0.3.x work—not claims of this reference server.
Linking runs to git
A commit names the runs that produced it with Tine-Run: <run> trailers (added by the hook tine repo integrate git install-hook installs) or a note under refs/notes/tine (tine repo integrate git note). In a repository the run is named by its object id; in a runs directory by its run id. tine repo integrate git log --commit SHA and tine agent blame FILE:LINE --git follow those links back to runs and steps. Nothing about the link is stored in opentine's own objects: trailers and notes are plain git and travel with git push (push refs/notes/tine explicitly, as with any notes ref).