loregd
Local Registry Daemon — the SQLite-backed RSI source that provides the Machine and Users hives: storage, concurrency, and request handling.
Single-page view · as markdown
1.1 Overview
Peios / Advanced Peios / loregd / Introduction
loregd — the Local Registry Daemon — is the primary registry source for Peios. It holds one or more registry hives in SQLite databases and serves them to the kernel's registry subsystem over the Registry Source Interface (RSI).
loregd is not architecturally special. Any process that implements the
RSI contract can serve as a registry source, and the kernel is
source-agnostic: it does not know or care that the process answering for
a hive keeps its data in SQLite. What makes loregd special is
operational. It is the source that provides the Machine\ and Users\
hives at boot, which puts it on the critical path to a running system —
if loregd does not come up, very little else does.
1.1.1 Where loregd sits #
The registry is split across a trust boundary. The kernel side owns the namespace, the layer model, access checks, watches, and transactions; it holds no storage of its own. The source side owns bytes on disk and answers questions about them. loregd is a source, and everything in this manual describes the lower half of that split.
Three consequences run through the whole design:
- loregd stores; it does not decide. It never resolves layers, never filters results by visibility, and never applies a security descriptor. It returns every layer entry it holds and lets the kernel work out which one wins. The security descriptors in its tables are opaque payload to it.
- GUIDs come from the kernel. loregd does not mint key identities. It records the GUID it is given, and uses it as the primary key.
- Sequence numbers come from the kernel. loregd stores them and reports the maximum it holds at registration, but never allocates one.
1.1.2 What this manual covers #
The command line and startup sequence; the SQLite schema backing a hive and the in-memory store backing volatile keys; the connection model, write serialisation, and how requests are dispatched; and the handling of each RSI operation, including transactions and enumeration ordering.
The kernel half of the registry — the namespace, layers, watches, access control — is chapter 5 of the Peios Kernel TRM, and the RSI wire protocol itself is specified in PSPK. Key schemas belong to the subsystems that own them, and loregd's supervision as a service belongs to the init system's manual.
1.2 Terminology
Peios / Advanced Peios / loregd / Introduction
The registry's own vocabulary — hive, key, value, layer, source, watch, security descriptor, sequence number — is defined by the kernel-side registry documentation and is used here unchanged.
The terms below are specific to loregd.
Hive database. The SQLite database file backing one hive. Each hive
registered by loregd has its own file, whose path is given on the command
line (§2.1). Referred to in SQL as the main schema.
Volatile database. The in-memory SQLite database backing one hive's
volatile keys, attached to that hive's connections under the schema name
volatile (§3.3). It mirrors the hive database's tables, holds no data at
startup, and is destroyed with the process.
Folded name. The case-folded form of a key name, value name, or child
name, stored in a _folded column beside the canonical case-preserving
name and used for all case-insensitive comparison (§3.4).
Write connection. The single SQLite connection per hive through which every mutation passes. Its uniqueness is what serialises writes (§4.1).
Read pool. The fixed set of connections serving reads that are not part of a transaction, selected round-robin (§4.1).
Snapshot connection. A dedicated connection opened for one read-only transaction, pinning a point-in-time view of the hive database for that transaction's lifetime (§4.3).
Orphan. A key record that no path entry in any layer points at. Orphans
are cleaned up at startup (§2.2) and are reported by RSI_DELETE_LAYER as
the keys its deletion left unreachable (§5.6).
2.1 Command Line
Peios / Advanced Peios / loregd / Startup
loregd is configured entirely by its argument vector. It takes one or more hive declarations, each naming a hive and the SQLite database file that backs it:
loregd <HiveName>=<DatabasePath> [<HiveName>=<DatabasePath> ...]
For example:
loregd Machine=/var/state/registry/machine.regdb Users=/var/state/registry/users.regdb
loregd Machine=/var/state/registry/machine.regdb Users=/var/state/registry/users.regdb Roles=/var/state/registry/roles.regdb
Each argument is split at its first =, so a database path may
itself contain =. Every declared hive is registered with the kernel at
startup (§2.2).
2.1.1 Argument validation #
loregd rejects the invocation and exits with a non-zero status if any of the following hold:
| Condition | Reason |
|---|---|
| No hive arguments at all | At least one hive is required. |
An argument with no = | Not a hive declaration. |
| An empty hive name or empty path | Neither is meaningful. |
| A relative database path | Paths are required to be absolute. |
A hive name containing \, /, or NUL | These are path separators and terminators in the registry namespace. |
The hive name CurrentUser, in any case | Reserved by the kernel as a per-token alias; no source may claim it. |
| Two declarations of the same hive name | Duplicates are detected on the folded name, so Machine and MACHINE collide. |
Hive-name comparison is case-insensitive throughout — for duplicate detection here, and for routing requests later — but the case as written on the command line is preserved and is what loregd presents to the kernel when it registers.
2.1.2 Configuration #
loregd has no configuration file and reads no configuration from the registry. This is deliberate: loregd is the configuration store, and a store that had to read its own configuration in order to start could not start.
Everything about how it behaves comes from three places: the command-line arguments above, the contents of the SQLite databases they name, and compiled-in constants (§4.1).
NOTIFY_SOCKET is the one environment variable loregd consults, and it
carries no behavioural setting. When it is set, loregd treats it as a
service-manager readiness socket: once the hives are registered and the
request loop is about to begin, it connects and sends READY=1. When it
is unset, the step is skipped.
loregd also opens /dev/console for writing at startup and, if that
succeeds, redirects its own diagnostic log there. This is what makes
loregd's failures visible during early boot, before any log daemon
exists.
2.2 Startup Sequence
Peios / Advanced Peios / loregd / Startup
Startup runs to completion before loregd accepts a single request. Any failure in it is fatal: loregd logs the error and exits non-zero rather than serving a hive it could not fully prepare.
2.2.1 1. Parse and validate arguments #
Extract the hive name to database path mapping and apply the validation in §2.1.
2.2.2 2. Open each hive database #
For each declared hive, create the database file's parent directory if it
is absent (mode 0755), then open — or create — the SQLite database at
that path. loregd owns its storage location, so a first boot onto an
empty /var/state is expected to work without anything having prepared
the directory.
Four pieces of connection state are established immediately:
PRAGMA journal_mode=wal. The result is read back and checked; if the database does not reportwal, the open fails. WAL mode is what allows concurrent readers alongside a writer (§4.1), so silently running without it is not acceptable.PRAGMA foreign_keys=ON. Neither schema declares a foreign key, so this constrains nothing.PRAGMA busy_timeout=25000— 25 seconds (§4.3).- The volatile database is attached (§3.3).
Each database/sql handle is limited to a single underlying connection.
2.2.3 3. Attach the volatile store #
Each hive gets an in-memory SQLite database, attached to its connections
under the schema name volatile (§3.3). The attach happens as part of
opening the connection in step 2; the volatile tables are created in
step 4.
2.2.4 4. Establish the schema #
If the database has no schema_version table, it is new: loregd creates
the persistent tables, creates the volatile tables, and stamps the schema
version, in one transaction.
If the version is present, it is compared against the version loregd supports. A newer version aborts startup, and so does an older one (§3.1).
2.2.5 5. First-boot root key #
For each hive, look for a key with no parent. If none exists, this is the hive's first boot: loregd generates a random 16-byte GUID for the root key, builds the default hive-root security descriptor, and inserts the root key record.
The default root descriptor grants SYSTEM and Administrators full access to the key, grants Authenticated Users read access, marks all three as container-inheritable, and sets both owner and group to SYSTEM.
2.2.6 6. Crash recovery #
SQLite's own WAL recovery handles any transaction that was uncommitted when the process died; it happens when the database is opened and needs nothing from loregd.
What loregd does on top of that is clean up orphaned keys — key records that no path entry in any layer points at. These can survive a crash between a key's creation and the creation of the path entry naming it. In one transaction, loregd deletes the values belonging to orphaned keys, then their blanket tombstones, then the key records themselves.
The hive root is exempt: it legitimately has no parent and no path entry
pointing at it, so orphan detection skips keys whose parent_guid is
null.
2.2.7 7. Compute the maximum sequence number #
For each hive, take the maximum sequence across the path entries,
values, and blanket tombstones — in both the persistent and the volatile
tables — then take the maximum across all hives. That single global
figure is what loregd reports at registration, so the kernel can resume
allocating sequence numbers above everything already stored.
In practice the volatile tables are empty at this point in startup and contribute nothing.
2.2.8 8. Open the registry device #
Open /dev/pkm_registry. The kernel requires SeTcbPrivilege in the
calling thread's token to permit this; loregd performs no check of its
own and relies on the kernel to refuse.
2.2.9 9. Register the hives #
Issue REG_SRC_REGISTER with every hive name, its root key GUID, and the
global maximum sequence number from step 7. The registration flags are
zero: loregd registers global hives only and never private ones.
2.2.10 10. Signal readiness and serve #
Send readiness to NOTIFY_SOCKET if it is set (§2.1), install the
termination signal handler (§2.3), and enter the request loop (§4.2).
2.3 Exit and Shutdown
Peios / Advanced Peios / loregd / Startup
2.3.1 What ends the process #
loregd exits when any of the following happens:
- The kernel closes the registry device. Reading from
/dev/pkm_registryreturns end-of-file, the request loop returns, and loregd shuts down cleanly with status 0. This is the normal path when the registry subsystem goes away. - A termination signal arrives.
SIGTERMandSIGINTare trapped. The handler closes the device, which unblocks the read loop and produces the same clean shutdown as above. This is how the service manager stops loregd. - A request cannot be framed. If a message read from the device cannot be parsed as an RSI request, loregd treats it as unrecoverable and exits non-zero.
- Startup fails. Any error in §2.2 is fatal.
On shutdown, in-flight requests are drained before the process exits (§4.2), and every hive's read connections and write connection are closed.
2.3.2 What does not end the process #
A storage failure during request handling does not terminate loregd.
Errors from SQLite while serving a request — including I/O errors — are
converted into an RSI_STORAGE_ERROR response and the daemon carries on
serving. There is no corruption detector and no disk-full detector that
takes the process down; a database that has become unreadable will
produce a stream of storage errors rather than an exit.
2.3.3 What happens to the data #
Persistent data is durable at the point each transaction commits; SQLite finalises any outstanding WAL state as the connections close.
Volatile data does not survive. The in-memory databases holding volatile keys are destroyed with the process (§3.3), which is the entire point of volatility. When the kernel observes the source disconnect, it marks every hive loregd served as unavailable.
3.1 Schema Version
Peios / Advanced Peios / loregd / Storage
Every hive database carries a schema version in a single-row table:
(
version INTEGER NOT NULL
);
The current version is 1 — the only version that has existed.
loregd checks the version as it opens each database (§2.2, step 4) and takes one of three paths:
| State | Behaviour |
|---|---|
No schema_version table | The database is new. loregd creates the persistent tables, the volatile tables, and inserts version 1, all in one transaction. |
| Version equals 1 | Normal startup. |
| Version greater than 1 | Startup fails. The database was written by a newer loregd, and proceeding risks corrupting it by writing through an older understanding of its layout. |
| Version less than 1 | Startup fails. |
There are no migrations. loregd carries no migration table, no migration step list, and no upgrade path; an older database is reported as requiring migration and startup stops there. Since 1 is the only version ever assigned, this does not arise in practice — but a second version cannot be stamped without building the migration machinery first.
Two details of the check are worth knowing. The schema_version table
has no primary key, uniqueness constraint, or check constraint, so a
second row is not detected: the first row read wins. And because every
table is created with IF NOT EXISTS, a database holding the data tables
but no schema_version table is stamped as version 1 without any
validation that its contents match that layout.
3.2 Persistent Tables
Peios / Advanced Peios / loregd / Storage
Each hive is one SQLite database, and every hive database has the same four data tables. loregd creates them on first boot (§2.2, step 4).
The tables hold what the kernel gives loregd and nothing derived from it. Security descriptors are stored as opaque blobs, GUIDs and sequence numbers are assigned by the kernel, and no table records a resolved or filtered view of anything.
3.2.1 keys #
(
guid BLOB NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
name_folded TEXT NOT NULL,
parent_guid BLOB,
sd BLOB NOT NULL,
volatile INTEGER NOT NULL DEFAULT 0,
symlink INTEGER NOT NULL DEFAULT 0,
last_write_time INTEGER NOT NULL
);
| Column | Meaning |
|---|---|
guid | The 16-byte key GUID assigned by the kernel. Primary key. |
name | The key's own name component, with case preserved as written. |
name_folded | The folded form of name (§3.4), used for case-insensitive lookup. |
parent_guid | The parent key's GUID; null for the hive root, which is how the root is identified. |
sd | The security descriptor, in binary self-relative form. Opaque to loregd. |
volatile | 1 for a volatile key, 0 for a persistent one. In this table it is always 0 — volatile keys live in the volatile database (§3.3). |
symlink | 1 if the key is a symbolic link. |
last_write_time | Unix nanoseconds. |
3.2.2 path_entries #
(
parent_guid BLOB NOT NULL,
child_name TEXT NOT NULL,
child_name_folded TEXT NOT NULL,
layer TEXT NOT NULL,
target_type INTEGER NOT NULL,
target_guid BLOB,
sequence INTEGER NOT NULL,
PRIMARY KEY (parent_guid, child_name_folded, layer)
);
ON path_entries (target_guid)
WHERE target_type = 0;
A path entry is one layer's opinion about one child name under one parent. Several layers may hold entries for the same name; resolving between them is the kernel's job, not loregd's.
| Column | Meaning |
|---|---|
parent_guid | The parent key's GUID. |
child_name | The child name with case preserved. |
child_name_folded | The folded form, which is what the primary key uses — so a name collides case-insensitively within a layer. |
layer | The layer name. Compared as binary, so layer names are case-sensitive, unlike key names. |
target_type | 0 for a GUID entry (the key exists in this layer), 1 for HIDDEN (a tombstone masking lower layers). |
target_guid | The target key's GUID when target_type is 0; null for HIDDEN. |
sequence | The kernel-assigned sequence number. |
The partial index on target_guid covers only non-HIDDEN rows. It is
what makes the reverse lookup — which path entries point at this key —
cheap, and that reverse lookup is what orphan detection (§2.2, step 6)
and RSI_DROP_KEY need.
3.2.3 values #
[values] (
key_guid BLOB NOT NULL,
name TEXT NOT NULL,
name_folded TEXT NOT NULL,
layer TEXT NOT NULL,
type INTEGER NOT NULL,
data BLOB,
sequence INTEGER NOT NULL,
PRIMARY KEY (key_guid, name_folded, layer)
);
| Column | Meaning |
|---|---|
key_guid | The key this value belongs to. |
name | The value name, case preserved. The empty string is the key's default value. |
name_folded | The folded form; also the empty string for the default value. |
layer | The layer this value entry belongs to. |
type | The registry value type — REG_SZ is 1, REG_DWORD is 4, and so on. REG_TOMBSTONE (0xFFFF) marks a per-value tombstone. |
data | The value payload; null for a tombstone. |
sequence | The kernel-assigned sequence number. |
values is a reserved word in SQL, so every reference to this table is
quoted — [values], or main.[values] and volatile.[values] when the
schema is named explicitly. Unquoted, it is a syntax error.
3.2.4 blanket_tombstones #
(
key_guid BLOB NOT NULL,
layer TEXT NOT NULL,
sequence INTEGER NOT NULL,
PRIMARY KEY (key_guid, layer)
);
A blanket tombstone hides every value a key holds in the layers beneath
it, rather than naming one value the way a REG_TOMBSTONE entry does.
One row per key per layer.
3.3 The Volatile Store
Peios / Advanced Peios / loregd / Storage
Volatile keys exist only for the lifetime of the running system, so they
never reach the hive's database file. Each hive instead gets a second
SQLite database, held entirely in memory, attached to its connections
under the schema name volatile:
file:<HiveName>_volatile?mode=memory&cache=shared
The hive name in the URI is the case-preserved name from the command line, which is what keeps one hive's volatile database distinct from another's.
3.3.1 A mirror of the persistent schema #
The volatile database carries the same four tables as the persistent one
— keys, path_entries, [values], and blanket_tombstones — with
identical columns, identical primary keys, and the same partial
idx_path_entries_target index on non-HIDDEN path entries. Column
meanings are exactly those in §3.2.
There is one deliberate difference. In volatile.keys the volatile
column defaults to 1 rather than 0, and loregd writes 1 into it. Every
record in this database is volatile by definition, and the column carries
that fact back out in responses without a second lookup.
Because the two schemas are structurally identical, a query that needs
both stores is a UNION ALL across them rather than two queries merged
in application code:
SELECT 1 FROM main.keys WHERE guid = ?
UNION ALL
SELECT 1 FROM volatile.keys WHERE guid = ?
LIMIT 1
3.3.2 Why this shape matters #
Making the volatile store a SQLite database attached to the same connection buys transactional behaviour for free. A SQLite transaction spans every attached schema, so a transaction that mutates both persistent and volatile data commits or rolls back as one unit, with no separate mechanism to keep the two halves consistent. Volatile writes inside a transaction are invisible to other readers until commit and disappear on rollback for the same reason persistent ones do — see §4.3.
3.3.3 Lifetime #
A shared-cache in-memory database exists as long as at least one connection has it open. The write connection creates the volatile tables and holds the database; the read connections and any snapshot connection attach the same URI and see the same data through the shared cache.
Nothing persists it, and nothing tries to. When loregd exits, the memory goes with the process and every volatile key in every hive ceases to exist. That is the entire meaning of volatility here, and it is why the volatile tables are always empty at startup (§2.2, step 7).
3.3.4 Which store an operation uses #
For an operation naming a single key, the key's own storage decides:
loregd looks the GUID up in main.keys and volatile.keys, and whichever
holds it determines where the reads and writes go.
RSI_CREATE_KEY is the exception, because the key does not exist yet —
the volatile flag in the request decides which database receives it.
Two operations are not scoped to one store at all. RSI_LOOKUP and
RSI_ENUM_CHILDREN consult both and combine the results, because a
persistent parent may legitimately have volatile children. The reverse
does not arise: a persistent child beneath a volatile parent is forbidden
by the kernel's data model, so a volatile key's whole subtree is
volatile.
3.4 Case Folding
Peios / Advanced Peios / loregd / Storage
Key names and value names are case-insensitive but case-preserving. loregd
implements this by storing both forms: the name as written, and a folded
form alongside it in a _folded column.
The folded form is computed once, when the name is written, and it is the
folded column that appears in every WHERE clause and every primary key.
Lookups are therefore plain binary comparisons:
SELECT * FROM path_entries
WHERE parent_guid = ? AND child_name_folded = ?
This is why no custom SQLite collation is registered anywhere in loregd — the case-insensitivity has already happened by the time SQLite sees the query. The canonical name is what comes back in responses, so callers see the case they originally supplied while storage compares the folded form.
Hive names are folded too, though not stored: routing a request to a hive and rejecting duplicate hive declarations (§2.1) both compare folded names.
Layer names are not folded. They are compared as binary, so layer names are case-sensitive.
3.4.1 How the folded form is computed #
loregd derives the folded form from the Go standard library's
unicode.ToLower, with three corrections where lowercasing and simple
case folding disagree:
| Codepoint | Folds to | Why the correction |
|---|---|---|
| U+00B5 MICRO SIGN | U+03BC GREEK SMALL LETTER MU | Lowercasing leaves it unchanged. |
| U+017F LATIN SMALL LETTER LONG S | U+0073 LATIN SMALL LETTER S | Lowercasing leaves it unchanged. |
| U+0130 LATIN CAPITAL LETTER I WITH DOT ABOVE | unchanged | Its folding is a two-codepoint sequence, which simple folding does not produce. |
The standard library's case tables are Unicode 15.0.0.
4.1 Connections
Peios / Advanced Peios / loregd / Concurrency
Each hive holds a small, fixed set of SQLite connections, and which one an operation runs on decides both its isolation and what it can block on.
| Connection | Count | Used for |
|---|---|---|
| Write | One per hive | Every mutating operation, and every read inside a bound read-write transaction. |
| Read pool | min(NumCPU, 16), at least 1 | Reads outside a transaction. Selected round-robin. |
| Snapshot | One per active read-only transaction | Reads inside a read-only transaction. Created on demand, closed when the transaction ends. |
The pool size is compiled in and cannot be configured — loregd reads no configuration (§2.1).
4.1.1 One connection per handle #
Every one of these is a Go database/sql handle limited to a single
underlying connection. That single limit is what serialises writes:
there is no separate executor or lock arbitrating the write path, only
the fact that the hive's write handle can hand out one connection at a
time, so a second writer waits for the first to release it.
It also means the snapshot connections are genuinely separate handles rather than borrowed pool entries, which is what keeps a long-running read-only transaction from consuming a pool slot.
4.1.2 What every connection carries #
Connection state is established once, when the connection is opened (§2.2):
journal_mode=walon the hive database, verified after being set.foreign_keys=ON.busy_timeout=25000— 25 seconds (§4.4).- The volatile database attached as schema
volatile(§3.3).
The volatile database's own journal mode is memory, not WAL. The
persistent side therefore has multi-version concurrency — readers see a
consistent snapshot and do not block writers — and the volatile side does
not. §4.4 covers what follows from that.
The volatile tables are created only on the write connection. Read and snapshot connections attach the same shared-cache URI and see the tables through it.
4.1.3 Which connection an operation uses #
Nine mutating operations are routed to the write connection through the
transaction-aware write path: RSI_CREATE_KEY, RSI_WRITE_KEY,
RSI_DROP_KEY, RSI_CREATE_ENTRY, RSI_HIDE_ENTRY,
RSI_DELETE_ENTRY, RSI_SET_VALUE, RSI_DELETE_VALUE_ENTRY, and
RSI_SET_BLANKET_TOMBSTONE.
Four read operations are routed to the read pool, or to the transaction's
connection when one is bound: RSI_LOOKUP, RSI_ENUM_CHILDREN,
RSI_READ_KEY, and RSI_QUERY_VALUES.
RSI_DELETE_LAYER and RSI_FLUSH take the hive's write connection
directly rather than through the transaction-aware path. They do not
consult the request's transaction id, so they neither join a caller's
transaction nor decline a read-only one, and they open and commit work of
their own.
4.2 Request Dispatch
Peios / Advanced Peios / loregd / Concurrency
RSI requests arrive multiplexed on the /dev/pkm_registry file
descriptor. Each carries a request id and a transaction id in its header.
loregd reads messages into a single 16 MiB buffer, copies each one out, and hands it to a new goroutine — one per request, with no cap on how many may be in flight. Responses are serialised by a mutex, because one write to the device must correspond to exactly one response. On shutdown the in-flight goroutines are drained before the process exits.
4.2.1 Identifying the target hive #
Most operations name a key GUID (or, for lookups and enumerations, a parent GUID), and loregd must decide which hive owns it.
A guidCache maps GUID to hive. It is seeded at startup with every
hive's root GUID, and maintained as keys come and go:
RSI_CREATE_KEYstores the new GUID immediately, before the transaction that created it commits, and registers an abort hook to evict it if that transaction rolls back. The immediate store is necessary because the cache-miss probe reads through the pool, which cannot see uncommitted rows.RSI_DROP_KEYevicts immediately outside a transaction, or through a commit hook inside one.RSI_DELETE_LAYERevicts the GUIDs its deletion orphaned.
On a miss, loregd probes each hive in turn:
SELECT 1 FROM main.keys WHERE guid = ?
UNION ALL
SELECT 1 FROM volatile.keys WHERE guid = ?
LIMIT 1
Only hits are cached, so a GUID that exists nowhere is re-probed against every hive on every request that names it. The cache has no size bound; it shrinks only through the three eviction paths above.
A GUID that resolves to no hive produces RSI_NOT_FOUND for most
operations. RSI_DROP_KEY and the entry deletions treat it differently —
see §5.3 and §5.4.
4.2.2 Operations that resolve differently #
RSI_FLUSH carries a hive name rather than a GUID and resolves it by
folded name (§3.4), so the match is case-insensitive.
RSI_DELETE_LAYER does not resolve a hive at all: it applies to every
registered hive and concatenates the orphan sets (§5.6).
4.3 Transactions
Peios / Advanced Peios / loregd / Concurrency
Transaction identifiers are allocated by the kernel and carried on every
request. loregd binds them to connections lazily — RSI_BEGIN_TRANSACTION
does no SQLite work at all.
4.3.1 Beginning #
RSI_BEGIN_TRANSACTION carries the transaction id and a mode:
RSI_TXN_READ_WRITE (0) or RSI_TXN_READ_ONLY (1). loregd records the id
as pending in the requested mode and returns RSI_OK immediately. No
connection is taken and no SQLite transaction is opened.
loregd supports both modes — its SQLite backing provides atomic
read-write commits and stable read-only snapshots — so it never returns
RSI_TXN_NOT_SUPPORTED.
Re-using a transaction id that is already active returns RSI_INVALID.
If the mode field is absent from the request, the transaction is treated
as read-write.
4.3.2 Read-write transactions #
The transaction binds to a hive on its first mutating operation:
loregd identifies the hive from the operation's GUID, acquires that hive's
write connection, issues BEGIN IMMEDIATE, and records the binding. If
SQLite reports SQLITE_BUSY at that point, the operation returns
RSI_TXN_BUSY.
Once bound, every subsequent operation with that transaction id — reads included — runs on the same connection. That is what provides read-your-own-writes: uncommitted rows are visible to the transaction because it is the connection that wrote them. Reads issued before the transaction binds go to the read pool instead, since there is nothing uncommitted to see.
Because the connection has both the hive database and the volatile database attached, a single SQLite transaction spans both. Persistent and volatile mutations made inside one transaction commit together and roll back together, with no separate mechanism reconciling them.
An operation whose GUID belongs to a different hive than the transaction
is bound to is rejected with RSI_STORAGE_ERROR. The kernel enforces
hive-scoping before requests reach loregd, so this is a backstop.
4.3.3 Read-only transactions #
A read-only transaction binds on its first read. loregd identifies the
hive, opens a dedicated connection — deliberately not one from the
read pool, so a long-lived snapshot cannot starve ordinary reads — and
issues BEGIN DEFERRED. WAL fixes the snapshot at that first read, and
every later read with the same transaction id reuses the connection and
observes the same point in time.
The snapshot is exact for persistent data. Volatile data has no snapshot mechanism: a volatile read inside a read-only transaction observes the live store.
A mutating operation carrying a read-only transaction id is rejected with
RSI_INVALID before any state changes — for the nine operations that
route through the write path. RSI_DELETE_LAYER and RSI_FLUSH do not
check (§4.1).
4.3.4 Committing and aborting #
RSI_COMMIT_TRANSACTION issues COMMIT on the bound connection and
returns RSI_OK. If the commit fails, the transaction is left open so
the caller may retry or abort: a busy or locked failure returns
RSI_TXN_BUSY, anything else RSI_STORAGE_ERROR. Committing an unknown
transaction id returns RSI_STORAGE_ERROR.
Committing a read-only transaction releases the snapshot and returns
RSI_OK.
RSI_ABORT_TRANSACTION issues ROLLBACK, closes the connection, releases
any snapshot, runs the transaction's abort hooks, and always returns
RSI_OK — including for a transaction id it has never seen. Rollback
errors are logged and not reported. This is how the kernel releases a
read-only snapshot after a REG_IOC_BACKUP finishes or fails.
A transaction that is neither committed nor aborted is never cleaned up: there is no timeout and no reaper. It holds its hive's write connection until the process exits — see §4.4.
4.4 Waiting and Contention
Peios / Advanced Peios / loregd / Concurrency
Every connection is opened with busy_timeout set to 25 seconds,
deliberately shorter than the kernel's 30-second request timeout so that
loregd can answer RSI_TXN_BUSY before the caller is timed out from
above.
That bound governs one kind of waiting: contention for SQLite's own write lock on the hive database. Two other kinds arise, and neither is bounded by it. loregd issues no database operation with a deadline attached.
4.4.1 Waiting for the write connection #
Each hive's write handle owns exactly one connection (§4.1). When a
read-write transaction binds, it holds that connection until it commits or
aborts, so any other write to the same hive waits — and it waits inside
Go's connection pool, before SQLite is ever reached. busy_timeout is not
consulted, because there is no SQLite lock in contention; the second
writer simply has no connection to run on.
RSI_FLUSH is the one operation that refuses to join this queue. It
checks whether any transaction is bound to the hive and returns
RSI_TXN_BUSY immediately if one is, because a checkpoint on a connection
already held by a transaction would deadlock. The check is racy — a
transaction can bind between the check and the checkpoint. Note also that
it does not distinguish a read-only snapshot, which lives on its own
connection, from a write binding, so a flush during a backup returns
RSI_TXN_BUSY even though the checkpoint could have proceeded.
RSI_DELETE_LAYER, a non-transactional RSI_DROP_KEY, and the
conditional-write path of a non-transactional RSI_SET_VALUE all take the
write connection without that guard, and queue behind a bound transaction.
4.4.2 Waiting on the volatile store #
The volatile database is in shared-cache mode with journal mode memory
(§4.1). It therefore has no multi-version concurrency: readers and writers
contend for table locks rather than passing each other.
Contention confined to the persistent side behaves as WAL promises: a transaction writing only the hive database does not block reads of it, and a transaction writing only volatile tables does not block reads of the hive database.
4.4.3 Abandoned transactions #
Nothing reclaims a transaction that is never committed or aborted. There is no timeout, and no sweep at any point in the daemon's life. Such a transaction holds its hive's write connection — and, if it wrote volatile data, its volatile table locks — until the process exits.
5.1 Store Routing
Peios / Advanced Peios / loregd / Request Handling
Persistent data lives in the hive database's main schema; volatile data
lives in the attached volatile schema (§3.3). Most operations act on one
of the two, and loregd has to decide which before it can run any SQL.
5.1.1 Operations naming one key #
For an operation that names a single key, the key's own volatile column
selects the store. loregd reads it with one statement across both schemas:
SELECT volatile FROM main.keys WHERE guid = ?
UNION ALL
SELECT volatile FROM volatile.keys WHERE guid = ?
LIMIT 1
A GUID present in neither produces RSI_NOT_FOUND for most operations.
Note that routing follows the column value, not which table the row
came from. Rows loregd writes are always consistent about this — a row in
volatile.keys carries volatile = 1, a row in main.keys carries 0 —
so the distinction only matters if a database were modified externally.
RSI_CREATE_KEY cannot consult a key that does not exist yet, so it
routes on the volatile flag carried in the request instead.
RSI_CREATE_ENTRY routes on the volatile flag of the child key the
entry points at. When that child GUID is present in neither store, the
entry is written to the persistent table.
RSI_HIDE_ENTRY routes on the parent key, since a HIDDEN entry
belongs to the parent's child list and a volatile parent's whole subtree
is volatile.
5.1.2 Operations spanning both stores #
RSI_LOOKUP, RSI_ENUM_CHILDREN, RSI_READ_KEY and RSI_QUERY_VALUES
are not scoped to one store: a persistent parent may have volatile
children, and a persistent key may have volatile-store rows beneath it.
Each issues a single UNION ALL statement over the two schemas rather
than querying them separately, so the merge happens inside SQLite.
Nothing de-duplicates across the two stores. The primary keys that make
(parent_guid, child_name_folded, layer) unique apply per schema, so if
the same triple exists in both, both rows appear in the response. The same
holds for value entries keyed on (key_guid, name_folded, layer).
The deletions — RSI_DELETE_ENTRY, RSI_DELETE_VALUE_ENTRY and
RSI_DROP_KEY — do not route at all. They delete from both schemas
unconditionally.
5.2 Response Ordering
Peios / Advanced Peios / loregd / Request Handling
The queries backing enumerations and lookups carry no ORDER BY, and a
UNION ALL across the two stores yields rows in whatever order SQLite
produces them. That order is not stable across calls.
The kernel walks enumeration results by dense index across repeated calls, so an unstable order would make that walk duplicate or drop entries. loregd therefore sorts every affected array into a canonical order before encoding a response:
| Response | Sorted by |
|---|---|
RSI_LOOKUP path entries | layer, then sequence |
RSI_ENUM_CHILDREN children | folded child name |
RSI_ENUM_CHILDREN per-child entries | layer, then sequence |
RSI_QUERY_VALUES value entries | folded value name, then layer, then sequence |
| Key-metadata blocks, in any response | ascending GUID, compared bytewise |
This ordering is a wire-stability guarantee only. It has no bearing on layer resolution, which is order-independent — the kernel selects a maximum, not a first match.
5.2.1 Arrays that are not sorted #
Two arrays reach the wire in the order the query produced them:
- The blanket-tombstone array in an
RSI_QUERY_VALUESresponse. It comes from an unorderedUNION ALLlike everything else, but is emitted unsorted. - The orphan-GUID array in an
RSI_DELETE_LAYERresponse. That operation walks every registered hive and concatenates their orphan sets, and the walk follows Go's randomised map iteration, so the array order differs between otherwise identical calls.
5.2.2 Child display names #
RSI_ENUM_CHILDREN groups rows by child_name_folded and emits one
child block per folded name, carrying a display name taken from the
child_name column.
Where two rows share a folded name but differ in stored case — Foo in
one store and FOO in the other, say — the display name emitted is
whichever row the unordered union yielded first. The order of children
is stable, because it is sorted on the folded name; the case of the name
reported for such a child is not.
5.3 Key Operations
Peios / Advanced Peios / loregd / Request Handling
5.3.1 RSI_CREATE_KEY #
The request's volatile flag selects the target table (§5.1). For a persistent key:
INSERT INTO keys
(guid, name, name_folded, parent_guid, sd, volatile, symlink,
last_write_time)
VALUES (?, ?, fold(?), ?, ?, 0, ?, ?)
last_write_time is not carried in the request. loregd sets it to the
current wall-clock time in Unix nanoseconds at insertion.
Uniqueness comes from the target table's primary key on guid, surfaced
as RSI_ALREADY_EXISTS. Because that key is per-schema, a GUID already
present in the other store does not collide: creating a persistent key
whose GUID exists in volatile.keys succeeds, and the GUID then exists in
both. Subsequent metadata reads resolve such a GUID to the main row,
since the reading query takes the first row of a UNION ALL that puts
main first.
The new GUID is added to the hive cache immediately, before the enclosing transaction commits, with an abort hook to remove it if that transaction rolls back (§4.2).
An unresolvable parent GUID returns RSI_NOT_FOUND, after a fallback
check of the registered hives' root GUIDs.
5.3.2 RSI_READ_KEY #
SELECT name, parent_guid, sd, volatile, symlink, last_write_time
FROM main.keys WHERE guid = ?
UNION ALL
SELECT name, parent_guid, sd, volatile, symlink, last_write_time
FROM volatile.keys WHERE guid = ?
LIMIT 1
RSI_NOT_FOUND if the GUID is in neither store, and likewise if it
resolves to no hive. The volatile field in the response is the stored
column value.
5.3.3 RSI_WRITE_KEY #
Updates the two mutable fields of a key, selected by a field mask:
| Bit | Value | Field |
|---|---|---|
| 0 | 0x01 | sd |
| 1 | 0x02 | last_write_time |
Valid masks are therefore 0x00, 0x01, 0x02 and 0x03. Any other bit
set returns RSI_INVALID — it indicates an attempt to modify an immutable
field.
loregd builds one UPDATE from the mask, setting only the named fields:
-- mask 0x03
UPDATE keys SET sd = ?, last_write_time = ? WHERE guid = ?
A mask of 0x00 names no fields and acts as an existence check, returning
RSI_OK or RSI_NOT_FOUND. An update that matches no row also returns
RSI_NOT_FOUND.
Outside a transaction the update is a single auto-committed statement. Inside one it runs on the transaction's connection.
5.3.4 RSI_DROP_KEY #
Purges every trace of a GUID from both stores — four tables in each schema:
DELETE FROM keys WHERE guid = ?;
DELETE FROM path_entries WHERE target_guid = ?;
DELETE FROM [values] WHERE key_guid = ?;
DELETE FROM blanket_tombstones WHERE key_guid = ?;
Outside a transaction, the eight statements are wrapped in a
BEGIN IMMEDIATE transaction of their own so the purge is atomic. Inside
one, they run on the transaction's connection.
Dropping a GUID that does not exist returns RSI_OK; so does one that
resolves to no hive. The operation is idempotent. The GUID is evicted from
the hive cache (§4.2).
5.4 Path Entry Operations
Peios / Advanced Peios / loregd / Request Handling
5.4.1 RSI_LOOKUP #
Returns every layer's entry for one child name under one parent, together with metadata for the keys those entries point at.
SELECT layer, target_type, target_guid, sequence
FROM main.path_entries
WHERE parent_guid = ? AND child_name_folded = ?
UNION ALL
SELECT layer, target_type, target_guid, sequence
FROM volatile.path_entries
WHERE parent_guid = ? AND child_name_folded = ?
HIDDEN entries are returned as entries but contribute no metadata GUID.
For each distinct non-HIDDEN target_guid, loregd fetches the key's
metadata — one query per GUID — and emits the blocks in ascending GUID
order (§5.2).
loregd does no layer filtering and no resolution: every entry it holds is returned, and choosing between them is the kernel's job.
An unresolvable parent GUID returns RSI_NOT_FOUND.
If a path entry names a target_guid for which no key record exists, the
metadata fetch finds nothing and the whole request fails with
RSI_STORAGE_ERROR. This is reachable in ordinary operation: the kernel
issues RSI_CREATE_ENTRY before RSI_CREATE_KEY, so a lookup landing
between the two sees an entry whose key has not yet been written.
5.4.2 RSI_CREATE_ENTRY #
INSERT INTO path_entries
(parent_guid, child_name, child_name_folded, layer,
target_type, target_guid, sequence)
VALUES (?, ?, fold(?), ?, 0, ?, ?)
The target table follows the child key's volatile flag (§5.1); a child GUID in neither store lands in the persistent table.
RSI_ALREADY_EXISTS comes from the target table's primary key on
(parent_guid, child_name_folded, layer). Since that key is per-schema, an
identical entry in the other store does not collide, and the same triple
can come to exist in both — in which case both rows are returned by lookups
and enumerations (§5.1).
An unresolvable parent GUID returns RSI_NOT_FOUND.
5.4.3 RSI_HIDE_ENTRY #
Writes a tombstone that masks the same name in lower layers:
INSERT OR REPLACE INTO path_entries
(parent_guid, child_name, child_name_folded, layer,
target_type, target_guid, sequence)
VALUES (?, ?, fold(?), ?, 1, NULL, ?)
target_type is 1 and target_guid is null. The target table follows the
parent key's volatile flag, because a volatile parent's entire subtree
is volatile. A parent GUID in neither store returns RSI_NOT_FOUND.
5.4.4 RSI_DELETE_ENTRY #
Removes one layer's entry for one name, from both stores:
DELETE FROM path_entries
WHERE parent_guid = ? AND child_name_folded = ? AND layer = ?
No rows-affected check is made, so deleting an entry that is not there
succeeds. A parent GUID that resolves to no hive returns
RSI_NOT_FOUND rather than succeeding.
5.4.5 RSI_ENUM_CHILDREN #
Returns every layer's entry for every child under a parent:
SELECT child_name, child_name_folded, layer, target_type,
target_guid, sequence
FROM main.path_entries WHERE parent_guid = ?
UNION ALL
SELECT child_name, child_name_folded, layer, target_type,
target_guid, sequence
FROM volatile.path_entries WHERE parent_guid = ?
Rows are grouped by folded child name into one block per child, each
carrying that child's per-layer entries. Metadata for the distinct
non-HIDDEN target GUIDs is fetched and emitted exactly as for
RSI_LOOKUP, and the same RSI_STORAGE_ERROR arises for an entry whose
key record does not yet exist.
Ordering, and the treatment of two rows whose folded names match but whose stored case differs, are covered in §5.2.
5.5 Value Operations
Peios / Advanced Peios / loregd / Request Handling
5.5.1 RSI_QUERY_VALUES #
Returns every layer's entry for one value, or for all of a key's values when the request sets the query-all flag:
-- single value
SELECT name, layer, type, data, sequence
FROM main.[values] WHERE key_guid = ? AND name_folded = ?
UNION ALL
SELECT name, layer, type, data, sequence
FROM volatile.[values] WHERE key_guid = ? AND name_folded = ?
-- query all
SELECT name, layer, type, data, sequence
FROM main.[values] WHERE key_guid = ?
UNION ALL
SELECT name, layer, type, data, sequence
FROM volatile.[values] WHERE key_guid = ?
The response also carries the key's blanket-tombstone state:
SELECT layer, sequence
FROM main.blanket_tombstones WHERE key_guid = ?
UNION ALL
SELECT layer, sequence
FROM volatile.blanket_tombstones WHERE key_guid = ?
Value entries are sorted; blanket tombstones are not (§5.2).
An unresolvable GUID returns RSI_NOT_FOUND. An existing key with no
values returns RSI_OK with empty arrays.
5.5.2 RSI_SET_VALUE #
The key's volatile flag selects the store; a key GUID in neither store
returns RSI_NOT_FOUND.
INSERT OR REPLACE INTO [values]
(key_guid, name, name_folded, layer, type, data, sequence)
VALUES (?, ?, fold(?), ?, ?, ?, ?)
5.5.2.1 Conditional writes #
When the request carries a non-zero expected_sequence, the write is a
compare-and-swap. loregd reads the current entry's sequence and writes only
if it matches:
SELECT sequence FROM [values]
WHERE key_guid = ? AND name_folded = ? AND layer = ?
If the row is absent, or its sequence differs, the operation returns
RSI_CAS_FAILED and writes nothing.
Outside a transaction, the check and the write are wrapped in their own
BEGIN IMMEDIATE transaction so no other writer can interleave; if that
transaction cannot begin because the database is busy, the operation
returns RSI_TXN_BUSY. Inside a transaction, the caller's transaction
already provides the isolation.
5.5.3 RSI_DELETE_VALUE_ENTRY #
Removes one layer's entry for one value, from both stores:
DELETE FROM [values]
WHERE key_guid = ? AND name_folded = ? AND layer = ?
No rows-affected check, so deleting an absent entry succeeds. A GUID that
resolves to no hive returns RSI_NOT_FOUND.
5.5.4 RSI_SET_BLANKET_TOMBSTONE #
Sets or clears the tombstone that masks every value a key holds in lower
layers. The key's volatile flag selects the store, and a GUID in neither
returns RSI_NOT_FOUND.
-- set
INSERT OR REPLACE INTO blanket_tombstones (key_guid, layer, sequence)
VALUES (?, ?, ?)
-- clear
DELETE FROM blanket_tombstones WHERE key_guid = ? AND layer = ?
5.6 Layer and Maintenance Operations
Peios / Advanced Peios / loregd / Request Handling
Neither operation in this section consults the request's transaction id (§4.1). Both take the hive's write connection directly and commit work of their own.
5.6.1 RSI_DELETE_LAYER #
Removes every entry belonging to one layer and reports the keys that the removal left unreferenced.
The operation is applied to every registered hive, not to one identified from the request, and the per-hive orphan sets are concatenated into a single response array.
For each hive, inside one BEGIN IMMEDIATE transaction, loregd first
computes the orphan set and then deletes:
-- GUIDs referenced by the layer being removed
SELECT DISTINCT target_guid FROM main.path_entries
WHERE layer = ? AND target_type = 0
UNION
SELECT DISTINCT target_guid FROM volatile.path_entries
WHERE layer = ? AND target_type = 0
minus the GUIDs referenced by any other layer, gathered the same way
with layer != ?. What remains is reachable only through the layer being
deleted, and is therefore orphaned by it.
DELETE FROM path_entries WHERE layer = ?;
DELETE FROM [values] WHERE layer = ?;
DELETE FROM blanket_tombstones WHERE layer = ?;
Both schemas are covered, and all six deletions run inside the same transaction as the orphan computation, so nothing can be inserted between the two steps. The orphaned GUIDs are evicted from the hive cache (§4.2) and returned to the caller.
The response array's order is not stable across calls (§5.2).
Every failure is reported as RSI_STORAGE_ERROR; busy errors are not
classified separately, unlike the other write paths.
5.6.2 RSI_FLUSH #
Forces the hive's write-ahead log to be checkpointed so that all persistent data is durable on disk:
PRAGMA wal_checkpoint(TRUNCATE)
The request carries a hive name rather than a GUID. It is matched
case-insensitively against the registered hives by folded name (§3.4); a
name matching none returns RSI_INVALID.
Two conditions return RSI_TXN_BUSY instead of checkpointing:
- Any transaction is currently bound to the hive. A checkpoint on a connection already held by a transaction would deadlock, so loregd declines immediately rather than waiting (§4.4).
- The checkpoint itself reports that it could not complete because the database was busy.
The volatile store has no durability and is unaffected: nothing is flushed, and nothing needs to be.
5.7 Status Codes
Peios / Advanced Peios / loregd / Request Handling
loregd returns seven of the RSI status codes:
| Status | Value | Returned when |
|---|---|---|
RSI_OK | 0 | The operation succeeded. Also returned by the idempotent deletions when their target was already absent. |
RSI_NOT_FOUND | 1 | A named key GUID exists in neither store, or resolves to no registered hive. Also an update matching no row. |
RSI_ALREADY_EXISTS | 2 | A primary-key collision in the target table — a duplicate key GUID, path entry, or value entry within one schema. |
RSI_STORAGE_ERROR | 3 | A SQLite failure while serving the request. Also an operation whose GUID belongs to a hive other than the one its transaction is bound to, a mutating operation carrying an unknown transaction id, a failed commit that was not busy, and any RSI_DELETE_LAYER failure. |
RSI_TXN_BUSY | 6 | BEGIN IMMEDIATE found the database busy, a conditional write could not begin its transaction, or RSI_FLUSH found a transaction bound to the hive. |
RSI_INVALID | 7 | An unknown opcode, a request whose payload cannot be decoded, an out-of-range RSI_WRITE_KEY field mask, a mutating operation carrying a read-only transaction id, an RSI_FLUSH naming an unregistered hive, or an RSI_BEGIN_TRANSACTION re-using an active id. |
RSI_CAS_FAILED | 8 | A conditional RSI_SET_VALUE whose target was absent or whose sequence did not match. |
Three codes are defined by the interface and never produced by loregd:
RSI_NOT_EMPTY (4), RSI_TOO_LARGE (5), and RSI_TXN_NOT_SUPPORTED (9).
RSI_TXN_NOT_SUPPORTED is never needed because loregd supports both
transaction modes (§4.3).
RSI_TOO_LARGE is never returned because an oversized or malformed frame
is not answered at all. Framing is validated before an opcode is known, and
a frame that fails validation ends the connection instead of producing a
response (§2.3).
Appendix A Prior Art
Peios / Advanced Peios / loregd
A.1 SQLite #
loregd's storage engine is SQLite, and it is specified against SQLite
rather than against an abstract store. The schema, the concurrency model,
and the operational behaviour all name SQLite features directly: WAL mode
for concurrent readers alongside a serialised writer, savepoints and
transactions for atomicity, PRAGMA wal_checkpoint for durability on
demand, and its crash recovery for restart after an unclean shutdown.
Both halves of a hive are SQLite. Persistent data lives in the database file named on the command line; volatile data lives in a second, in-memory database attached to the same connections (§3.3). Using one engine for both is what makes a transaction spanning persistent and volatile data atomic without any additional machinery.
A different storage engine would have to supply equivalent transactional and concurrency semantics. Nothing in the design forbids that, but nothing accommodates it either.
A.2 The Windows registry hive format #
The Windows registry stores hives as binary REGF files managed directly by the kernel's Configuration Manager. loregd departs from that model completely: storage is a SQLite database managed by an unprivileged userspace daemon, not a kernel-managed binary file, and the kernel holds no storage of its own at all.
What the two share is the data model — keys, values, security descriptors — which Peios inherits through the registry's kernel-side specification rather than from the file format. The on-disk format has no relationship to REGF whatsoever, and no REGF file can be read by loregd or written by it.
A.3 The Registry Source Interface #
loregd implements the RSI, which is specified in PSPK rather than here. That specification defines the operations, the message format, the error model, and the obligations binding on any source; loregd's request handling (chapter 5) is a mapping of those operations onto SQL against the two stores.
Where this manual and the RSI specification disagree about wire behaviour, the specification is correct and this manual has a bug — the RSI is a contract with the kernel, and loregd is one implementation of one side of it.