Skip to content

Storage

kslate keeps everything in Postgres. The gateway holds no state of its own, so two gateways serving the same record see the same rows. This page lists the tables, says what each column holds, and shows the two UPDATE statements that make a write safe.

You do not need this page to use kslate. Read it if you operate the database, write a migration, or want to know what an archived record contains.

Nothing but the gateway and the worker writes these tables. Read them directly and you bypass scope, so an auditor sees fields their role may not read. Use the HTTP API for that instead.

Tables

TableHolds
classesOne row per class. A cache of the class the CRD or the YAML file defines
recordsOne row per record. Carries record_version and the expiry
fieldsThe current value of every versioned, soft and task field
entriesEvery entry of every append field. Never updated
historyEvery write, applied or rejected
checkpointsEvery saved checkpoint. The newest per role wins
archiveA snapshot per record, for classes that set onExpire: archive
scope_readsWhat a caller read, so the gateway can work out an omitted based_on

classes

ColumnTypeNotes
nametextPrimary key. Class names are unique across the cluster
specjsonbThe class as loaded
sourcetextWhich policy source loaded it
updated_attimestamptz

This row is a cache. The class itself lives in a SharedContext resource or a YAML file.

Nothing deletes the row when the class goes away. So “in this table but not in the live policy source” is what CLASS_GONE means, and the cached spec is what the gateway reads to answer who may still read the records. See Honest limits.

records

ColumnTypeNotes
class, idtextPrimary key together
record_versionbigintRises on every applied write to any field
created_attimestamptz
last_write_attimestamptz
expires_attimestamptzNull when the class sets no TTL. Fixed at creation

fields

The current state of a field that is not append.

ColumnTypeNotes
class, id, fieldtextPrimary key together
valuejsonbNull for a task field, which has no value
versionbigint
authortextThe resolved identity. Never from the request body
evidencetext
updated_attimestamptz
ownertexttask fields. Null when nobody holds it
lease_expires_attimestamptztask fields
based_onjsonbThe premise of this value
record_versionbigintThe record version at which this value was applied
identity_strengthtextNull means unknown, not weak
invocationjsonbWriter-asserted. The gateway never reads a key out of it

The row has a foreign key to records, so a field cannot outlive its record.

entries

One row per entry of an append field. Rows are inserted, never updated.

ColumnTypeNotes
seqbigserialPrimary key. Orders entries
class, id, fieldtext
entryjsonb
authortext
evidencetextNot null. Evidence is required on every entry
based_onjsonb
created_attimestamptz
record_versionbigint
identity_strengthtext
invocationjsonb

The number of rows for a field is that field’s version. An append field at version 4 has four entries.

history

One row per write the gateway considered, whether or not it applied.

ColumnTypeNotes
seqbigserialPrimary key
class, id, fieldtext
optextSee below
versionbigintThe version the write produced. Null when it was rejected
record_versionbigint
valuejsonbWhat the caller tried to write
authortext
evidencetext
expected_versionbigintWhat the caller said it read
based_onjsonb
inferredbooleanTrue when the gateway worked the premise out rather than the caller declaring it
outcometextSee below
created_attimestamptz
identity_strengthtext
invocationjsonb

op is one of:

putappendclaimreleasetakeover

outcome is one of:

appliedthe write landed
stalethe target field had moved
stale_premisea field named in based_on had moved
heldanother agent held the task
scope_deniedthe role may not write that field
evidence_requiredthe field requires evidence and none was given
identity_mismatchthe body named an author
weak_identitythe credential was below the field’s floor

Both lists are enforced by a check constraint, so a new outcome needs a migration. Which rejections reach this table at all is covered in blame.

checkpoints

ColumnTypeNotes
seqbigserialPrimary key
class, id, roletext
authortext
documentjsonbAt most 64 KiB
record_versionbigintThe record version when it was saved
created_attimestamptz

A load takes the highest seq for the role. Earlier checkpoints stay.

archive

ColumnTypeNotes
snapshotjsonbThe whole record
class, idtext
archived_attimestamptz

Written only for a class that sets ttl: { onExpire: archive }. Without that line the record and its history are deleted at TTL.

The snapshot carries the record’s record_version, created_at and last_write_at, every field with its value, version, author, evidence, based_on, identity_strength and invocation, every entry, every checkpoint document, and the full history including the rejected writes and their inferred flag. It is a complete record of what happened, so you can learn from it after the record is gone.

scope_reads

What one caller read, so the gateway can fill in a based_on the caller did not send. See based_on.

ColumnTypeNotes
scope, identity, class, id, fieldtextPrimary key together
versionbigintThe version that caller saw
read_attimestamptz

The scope comes from a header the host sends. The gateway does not invent one. This lives in Postgres rather than in gateway memory because a read served by one replica has to be visible to a write served by another.

How a write stays safe

Every write is one transaction. It locks the record’s row first:

SELECT record_version FROM records WHERE class=$1 AND id=$2 FOR UPDATE

That lock is what makes record_version rise one at a time under concurrent writers. The premise checks read fields and entries inside the same transaction, so a premise cannot pass against a value that changed a moment later.

A version check is one conditional UPDATE:

UPDATE fields SET … WHERE class=$1 AND id=$2 AND field=$3 AND version=$expected

It updates one row or none. None means the field moved, and the caller gets STALE.

A claim is the same shape with a different condition:

UPDATE fields SET owner=$me, lease_expires_at=… WHERE … AND (owner IS NULL OR owner=$me OR lease_expires_at < now())

The three conditions are the three cases where a claim succeeds. The lease is free, it is already yours, or it has run out. Anything else updates no row, and the caller gets HELD.

No caller ever waits on a lock, holds one across requests, or sees one. A request gets an answer or a rejection. The only advisory lock kslate takes is during migrations, so that replicas starting together apply them one at a time.

Why the current value keeps its own based_on

fields and entries each carry based_on, and history carries it for every write. The copy on the current value is not redundant. It is what get and query return, so one query tells an agent which records are behind without joining history for each one.

How long rows live

RowsKept until
A record, its fields, entries, checkpoints and historyThe record’s TTL. Then deleted, or archived if the class says so
A record in a class with no TTLFor as long as the class has no TTL
History rows belonging to no recordA week. The worker reaps them
scope_readsAn hour. The worker reaps them
An archive snapshotNothing deletes it

History goes with its record. That is not the same as kept forever, so a class whose history you want to keep sets onExpire: archive.

A rejected write that never created a record leaves a history row with no record to belong to. A STALE on a record that does not exist is one example. Those are the rows the week-old reaper removes.

The gateway works out an omitted based_on from reads in the last 15 minutes. The worker keeps scope_reads for an hour, so the window is always shorter than the retention.

Migrations

Numbered files compiled into the binary and applied when the gateway or the worker starts. Upgrade describes the ordering, the advisory lock and the checksum.

There is one, 0001_schema.sql, and it creates everything on this page. kslate has not been released, so no database predates it and there is nothing to upgrade from. The number is kept because migrations are applied in filename order and a second one has to sort after this one.

Never change the SQL of an applied migration — a change in what it does is a new migration. The checksum covers the whole file, comments included, so even a reworded comment makes a database that already applied it refuse to start. Before 1.0 the answer is to recreate the database.