Content-addressable storage (CAS)
Every comline build freezes the package into .comline/, its
content-addressable store. The store holds each version of the package as an
immutable commit, and the commits form one chain. This page covers what is on
disk, what the objects contain, how they are hashed, how builds and releases
make the chain, and what reads it.
It is modelled on Git's object store, with differences that suit a schema package. They are collected in Compared with Git.
On disk
.comline/
├── FORMAT_VERSION the IR format stamp, e.g. 3
├── objects/
│ └── ab/cdef… one file per object, named by its hash
├── refs/
│ └── heads/main the hash of the newest commit
└── deps-cache/ fetched git dependencies (not part of the store)
flowchart LR
P["package"] --> C[".comline/"]
C --> F["FORMAT_VERSION"]
C --> O["objects/"]
C --> R["refs/heads/main"]
C --> D["deps-cache/"]
O --> H["ab/cdef…<br/>one file per object"]
R -. "hash of" .-> H
D -. "not committed" .-> X["ignored by git"]
| Path | Contents |
|---|---|
objects/<xx>/<rest> |
One object. The 64 hex characters of its hash are split: the first two name the directory, the other 62 name the file. The file holds the object's bytes, LZ4-compressed. |
refs/heads/main |
The hash of the newest commit, as 64 hex characters. Updated at the end of every build and release. It is the only ref. |
FORMAT_VERSION |
The IR format stamp as a decimal number. |
deps-cache/ |
Git dependencies fetched by comline add and by builds. A cache, not history. |
What to commit
Commit .comline/, except deps-cache/. The store is the package's version
history, and nothing else holds it: there is no publish step yet. A clone
without .comline/ starts over with no recorded versions, at 0.1.0-dev.1.
deps-cache/ is different. It is a checkout that can be fetched again from the
uri and commit pinned in config.idp. The .gitignore that comline new
writes ignores only that directory:
comline clean never touches .comline/. comline reset deletes the whole
directory, history included, behind a typed confirmation or --force. See
Packages & dependencies for the package layout.
Objects
There are three kinds of object file: blobs, trees and commits. A release is not a fourth kind. It is a record inside a commit.
All objects are CBOR (see Hashing), so the field names below are the keys in the stored bytes.
Blob
A blob holds raw bytes.
| Field | Type | Meaning |
|---|---|---|
content |
bytes | The payload. For a schema unit, the CBOR of one frozen unit. |
Blobs hold three things: one frozen unit each (a struct, an enum, a protocol,
a constant, and so on), a version's config, docs and facts, and a
dependency's manifest.
Tree
A tree lists named entries, like a directory.
| Field | Type | Meaning |
|---|---|---|
entries |
list of entry | In the order they were added. |
Each entry:
| Field | Type | Meaning |
|---|---|---|
mode |
Blob or Tree |
What hash points at. |
name |
string | The entry's name in this tree. |
hash |
32 bytes | The object's blake3 hash. |
A schema's tree has one blob entry per frozen unit, named by kind and
declaration: namespace_<ns>, struct_<Name>, enum_<Name>,
protocol_<Name>, const_<Name>. Other units are named by position:
import_<n>, unit_<n>.
Commit
A commit is one version of the package.
| Field | Type | Meaning |
|---|---|---|
tree |
hash | The version's root tree. |
parents |
list of hash | The previous commit. Empty for the first commit. Today it holds at most one. |
author |
string | The user name of whoever built, from the operating system. |
timestamp |
integer | Unix time in seconds. |
message |
string | Written by Comline: Initial commit, Build (Minor change), Release 0.2.0. |
version |
string | A release (0.3.0) or a build between releases (0.3.0-dev.2). |
release |
release record, optional | Present on commits made by comline release. Left out of the stored bytes otherwise. |
A commit is a release if it has a release record or its version has no
pre-release part. The second rule covers commits from before release records
existed.
Release record
The release field of a release commit says how the version was reached. See
Builds and releases for how the bumps are chosen.
| Field | Type | Meaning |
|---|---|---|
computed |
bump | The bump the changes since the previous release call for: None, Patch, Minor or Major. |
applied |
bump | The bump used. The computed one, or higher if --raise was given. |
reason |
string, optional | Why the bump was raised or the package made stable. |
covered |
bool | A raise was asked for, but the computed bump already reached it. The reason is kept as a note. |
stable |
bool | This release moved the package to 1.0.0. |
since |
hash, optional | The previous release's commit. The changes behind this release are those between the two. Absent on the first release. |
A version's root tree
A commit's tree is the root tree. Its entries are the whole package at that
version.
flowchart TD
K["commit<br/>version, parents, author, release"] --> T["root tree"]
T --> S0["schema_0<br/>tree"]
T --> S1["schema_1<br/>tree"]
T --> DP["dep_name<br/>tree"]
T --> CF["config<br/>blob"]
T --> DC["docs<br/>blob"]
T --> FC["facts<br/>blob"]
S0 --> U1["struct_Greeting<br/>blob"]
S0 --> U2["enum_Language<br/>blob"]
S0 --> U3["namespace_…<br/>blob"]
| Entry | Mode | Contents |
|---|---|---|
schema_<n> |
tree | One per schema file, numbered in build order, holding that schema's frozen units. |
dep_<name> |
tree | A dependency vendored into the commit: its own schema_<n> trees and a manifest blob with its verified content hash. Written by builds that include dependency support. |
config |
blob | The frozen config.idp, as a list of units. |
docs |
blob | The //! headers: the package's, and each module's by path. Only written when there is at least one. |
facts |
blob | The version's compatibility history, for comline@2. |
dep_<name> trees mean a version can be rebuilt from its commit alone, without
resolving the dependencies again.
The facts record
The facts blob holds what a version's compatibility facts need from
history (see Compatibility facts).
It's CBOR like every other object, with fields by name:
| Field | Type | Meaning |
|---|---|---|
protocols |
list | One entry per protocol of the package's own, in schema and declaration order. |
protocols[].path |
string | namespace::Protocol, the key every fact is kept by. |
protocols[].line |
string | The release at which the protocol last broke. |
protocols[].number |
u16 |
The protocol's wire number, unique in the package. |
protocols[].functions |
list | name, since (the release it first appeared in) and number (its wire number, unique in the protocol), in declaration order. |
protocols[].retired |
list, optional | Function numbers the protocol has had and no function holds now: number and the name that last held it. |
retired |
list, optional | Protocol numbers the package has had and no protocol holds now: number and the path that last held it. |
numbered |
bool | Whether the numbers were assigned. |
Retired numbers are never assigned again. number, retired and numbered
were added without a format bump: a blob written before them reads with
numbered false, and its numbers are computed from the history when it's
read, the same way a missing facts blob is.
Why config, docs and facts sit beside the schemas
They are separate entries in the root tree, not part of any schema_<n> tree.
A schema's schema_ir_hash is computed over that schema's units alone.
Generated code embeds the hash, and so does the connection handshake. If the
//! headers, the config or the facts lived inside the schema, editing a
header or recording a new fact would move the hash and change the generated
code for no reason a consumer cares about.
Kept apart, they leave the schema trees and the hash as they were. The facts
go further: the build leaves them out when it decides whether anything changed,
because they follow from the rest. A rebuild that changes only docs or
config still makes a new commit. A rebuild that changes nothing makes none.
The docs and facts entries were added without a format bump. A reader of an
older version finds no entry and treats it as absent; a missing facts is
computed from the history when read.
Hashing and compression
Hash. An object's address is the blake3 hash of its serialized bytes: 32 bytes, written as 64 hex characters. Identical content has an identical hash, so an object that already exists is not written again. Unchanged schemas share their trees and blobs across every version that contains them.
Serialization. Every object is encoded as CBOR. Structs are written with their field names and enums with their variant names, not by position. Adding a field to the frozen IR therefore does not break stores already on disk. The same value always encodes to the same bytes.
A unit's blob is nested: the unit is CBOR-encoded into the blob's content,
and the blob is then CBOR-encoded and hashed. The hash is of the blob object,
not of the bare unit.
Compression. Objects are compressed with LZ4 (the lz4_flex block format
with the uncompressed size prepended). Hashing happens first, on the
uncompressed bytes. Reading an object decompresses it and checks that the hash
still matches; a mismatch is an error.
Refs. refs/heads/main is not an object. It is a plain text file with a
hash in it.
The IR format stamp
FORMAT_VERSION records which shape of the IR wrote the store. This binary
expects 3. It is not the package's specification_version; that is the
language edition the schemas are written in, and it is unrelated to how core
stores its own IR.
| Stamp | What changed |
|---|---|
| 1 | CBOR, replacing a position-based encoding. |
| 2 | A property's expression became a typed value. |
| 3 | Types became structured (TypeRef, Literal) instead of text. |
Because CBOR names its fields, only renaming or removing an existing field or variant needs a new stamp. Additions do not.
Warning
A store with a missing or different stamp cannot be read safely. The next
comline build warns and deletes the whole .comline/ directory, then
starts again from 0.1.0-dev.1. Upgrading to a Comline that bumped the
stamp therefore costs the stored history. The same applies to a .comline/
from before the stamp existed.
Builds and releases
A build records history. A release is the version consumers see. The full bump rules are in Versioning rules; this section covers how they land in the store.
flowchart RL
M["refs/heads/main"] --> C6
C6["0.2.0<br/>release"] --> C5["0.2.0-dev.2<br/>build"]
C5 --> C4["0.1.1-dev.1<br/>build"]
C4 --> C3["0.1.0<br/>release"]
C3 --> C2["0.1.0-dev.2<br/>build"]
C2 --> C1["0.1.0-dev.1<br/>build"]
Arrows are parent links. Reading right to left walks back through history.
comline build freezes the working tree. The first build makes a commit
with no parent at 0.1.0-dev.1. Each later build:
- Builds a new root tree from the schemas, dependencies, config and docs.
- Compares it, without
facts, to the previous commit's root tree. If it is the same, it stops: no commit, no change to the ref. - Otherwise compares it with the previous commit for this build's own changes, and with the last release for the bump everything since calls for.
- Names the build after the release it leads to: the last release bumped by that amount, plus a count of builds since it. A build that calls for no bump still leads to a patch release, so it never sorts before the release it follows.
- Records the compatibility facts, writes the root tree and the commit, and
moves
refs/heads/main.
flowchart TD
A["compile config.idp and schemas"] --> B["check declared targets"]
B --> V{"FORMAT_VERSION<br/>matches?"}
V -- "no" --> W["delete .comline/"]
V -- "yes" --> E{"refs/heads/main<br/>exists?"}
W --> I
E -- "no" --> I["freeze: first commit<br/>0.1.0-dev.1"]
E -- "yes" --> F["freeze: new root tree"]
F --> U{"same tree as<br/>the previous commit?"}
U -- "yes" --> N["no commit"]
U -- "no" --> D["compare with the previous commit<br/>and with the last release"]
D --> L["version = next release<br/>+ dev.N"]
L --> H["record facts, write tree<br/>and commit"]
I --> R["move refs/heads/main"]
H --> R
A package with no .comline/ yet passes the stamp check, since there is
nothing to compare.
comline release builds first, so what is released is what is in src/
now. Then it adds one commit on top of the latest build:
- The commit has the build's tree, and the build as its parent. The tree is
the same, unless the release is a different version from the one the build
led to (a raised bump, or
1.0). Then the compatibility facts name the new version, so the release gets a root tree of its own. - Its
versionhas no pre-release part, and it carries a release record. - The bump follows the versioning rules:
the highest the changes since the last release call for, raised with
--raiseif asked. It refuses when there is nothing to release.
See comline release in the CLI reference.
What reads the store
| Reader | What it reads |
|---|---|
comline build, comline release |
The ref and the previous commits, to compare and to number the next version. |
comline diff <old> <new> |
Two commits' root trees, compared with the same rules as a build: schema changes, changes within a dependency's reach, config changes and the bump they call for. |
comline inspect |
The newest build's facts, shown beside each protocol's wire summary. It writes nothing. See comline inspect. |
comline doc --version <v> |
A built version's schema blobs, its config for the package name, and its docs headers. --changelog-from <v> adds the changes since an earlier version. See comline doc and Documenting your schemas. |
comline generate |
With a pinned version or "all", the frozen schemas of those versions. The default, "latest", reads the working tree and not the store. |
diff, doc --version and generate find a commit by HEAD, a version
string such as 0.3.0-dev.2, or a commit hash (the full 64 characters, or a
prefix of 4 or more that matches one commit). They walk the chain from
refs/heads/main through each commit's first parent.
The comline@2 facts
The facts blob stores a version's history: for each protocol (keyed by
namespace::Protocol), the release at which it last had a breaking change
(line), and for each function, the release it first appeared in (since).
These need history, so a build records them. Both name releases; a build
between releases carries the lines of the release it leads to.
The rest of what comline@2 exchanges is computed from the version itself when
read, not stored: the package id from config.idp, each protocol's shape
hash, and each encoding's name and parameter hash (both blake3, as hex).
See Compatibility facts for
what they mean and how they are compared. The config.idp keys are in the
config.idp reference, and where
generated code lands is set in
comline.toml.
Branches
The chain is linear: one ref, and every history walk follows first parents
only. If two Git branches each build, they each move refs/heads/main. The
object files merge without conflict, since they are named by hash, but the ref
is one file, and Git leaves conflict markers in it with one head from each
side.
The next build after such a merge joins the two heads back into one line:
- If one head already contains the other, the ref just moves to the newer one.
- Otherwise the line continues from one head: the side that released since the two split, or, if neither did, the head built first (the earlier timestamp, then the lower hash). The merged working tree is built on top of it as one build. That build counts as the other side's changes, not a replay of its builds one by one, and it records the other head as a second parent.
- That build's version,
-dev.Ncount and facts (each protocol's line and each function's "since") are computed the normal way, against the last release on the line. So anything unreleased is renumbered to fit, and released versions keep theirs, since peers may already depend on them. - The ref is written back as one hash. Nothing is deleted. The other side's
commits stay in
.comline/objectsand stay reachable through the second parent.
Wire numbers for protocols and functions arrive with comline@2. They will be
assigned against the last release the same way, so the build that reconciles
renumbers the ones added since that release too.
If both branches released since they split, the build refuses and writes
nothing. The error names the shared version, both releases and both heads.
Two releases cannot both keep their version, so you pick the one to keep:
write that branch's head hash into .comline/refs/heads/main (for example
with git checkout --ours or --theirs on that file), then build again. The
other branch's changes then build on top of the kept release, unreleased until
the next release. Its own release stays in the store but leaves the line.
A ref that was already resolved by picking one side does not reconcile on its
own: the build sees one head, and the other side's commits are simply not on
the line. Its changes are still built from the merged working tree. To record
the dropped head as well, core's cas::reconcile::mark_diverged puts it back
beside the current one, and the next build reconciles the two. From the CLI,
comline build --reconcile <hash> does both, given the dropped head's full
hash. Either way, the build says it reconciled: which head the line continued
from, and which one it recorded as the second parent.
Compared with Git
| Git | Comline | |
|---|---|---|
| Hash | SHA-1 or SHA-256, over a type size header plus the content |
blake3, over the object's serialized bytes |
| Loose object compression | zlib | LZ4 |
| Object encoding | Its own text and binary layouts | CBOR, with field names |
| Blob | A file's bytes | One frozen schema unit, or a config, docs or facts payload |
| Tree | Directories and files, sorted | schema_<n> per schema file, dep_<name> per dependency, plus config, docs and facts |
| Commit | Author, committer, message | Author name and timestamp, a generated message, a version, and for releases a release record |
| Refs | Many branches, tags, HEAD |
One: refs/heads/main |
| History | Branches, merges, rewrites | One chain, never rewritten. Objects are never deleted. The build after a Git merge joins two heads back into one line. |
| Packing | Packfiles, garbage collection | None. Every object is its own file. |
| Version names | Hashes and tags you choose | version set by the diff, not typed in |
Because objects are never deleted or rewritten, any past version can be rebuilt, diffed, documented or generated from at any time.