Skip to content
Registry StackDocsv0.20.0

Publish a governed SQLite registry

For the data publisher and operator

View as Markdown

Registry Relay compiles one reviewed contract into a read-only HTTP API over a database an institution already holds. In this tutorial you will publish a small business register that answers with a company’s legal name and legal form, refuses to answer with its address, and never sees the registrar’s internal notes at all.

Outcome
One public register API serving two reviewed fields, refusing a third, with an audit line for the answer it gave.
Time
About 30 minutes
Level
Local development with synthetic data
Prerequisites
The sqlite3 command lineA shell with curlThe OpenSSL command lineAn editorLinux amd64, the platform the relay installer supports

Two boundaries stand between the database and the caller, and you author both of them.

flowchart LR
    T["businesses table<br/>8 columns, including registrar_note"]
    V["relay_registered_businesses view<br/>7 columns"]
    D["public disclosure profile<br/>legalName, legalForm"]
    C["Anonymous caller"]

    T -->|SQL view you write| V
    V -->|contract you review| D
    D -->|relay serve| C

The view decides which columns can leave the database at all. The disclosure profile decides which of those the API is willing to answer with. registrar_note never crosses the first boundary, and registeredAddress never crosses the second.

You need two binaries, and they install separately. relay serves a sealed package and has an installer. relayctl authors and checks a project and does not.

The installer accepts Linux amd64 only, and places relay in ~/.local/bin unless you set RELAY_INSTALL_DIR:

Terminal window
curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/relay-install.sh | bash
relay --version

Take relayctl as a plain release asset. Substitute the released tag, and the platform you are on, one of linux-amd64, linux-arm64, or macos-arm64:

Terminal window
curl -fsSL -o ~/.local/bin/relayctl \
"https://github.com/registrystack/registry-stack/releases/download/<released-tag>/relayctl-<released-tag>-linux-amd64"
chmod 0755 ~/.local/bin/relayctl
relayctl --version

If either version command is not found, add ~/.local/bin to your PATH.

relayctl init writes a starter project into a new directory:

Terminal window
relayctl init business-registry
cd business-registry

It reports the files it created:

{
"status": "success",
"diagnostics": [],
"details": {
"kind": "initialized",
"files": [
"registry.yaml",
"runtime.yaml",
"governance/identifier-lifecycle.yaml",
"governance/classification-review.yaml",
"governance/legal-basis.yaml",
"governance/processing.dpv.yaml",
"codelists/record-lifecycle.yaml"
]
}
}

registry.yaml is the contract: what the register means and what it may release. runtime.yaml is the deployment binding: where the database is, where audit goes, what to listen on. The governance/ files are the institutional record behind the contract. You will edit all three kinds before the end.

The institution’s database is an input, not part of the project. Create a small one here.

Open registry.sql in your editor and add this:

CREATE TABLE businesses (
registration_number TEXT PRIMARY KEY NOT NULL,
record_revision TEXT NOT NULL,
lifecycle_state TEXT NOT NULL,
recorded_at TEXT NOT NULL,
legal_name TEXT NOT NULL,
legal_form TEXT NOT NULL,
registered_address TEXT NOT NULL,
registrar_note TEXT NOT NULL
) STRICT;
INSERT INTO businesses VALUES
('BIZ-0001', '3', 'ACTIVE', '2026-02-11T09:00:00Z', 'Aurora Freight Cooperative', 'COOPERATIVE', '14 Harbour Road, Port Meridian', 'Renewal reviewed by registrar A'),
('BIZ-0002', '1', 'ACTIVE', '2026-01-04T09:00:00Z', 'Meridian Dairy Limited', 'COMPANY', '3 Mill Lane, Northfield', 'Filed on paper'),
('BIZ-0003', '7', 'RETIRED', '2025-11-20T09:00:00Z', 'Kestrel Analytics Limited', 'COMPANY', '77 Vantage Street, Port Meridian', 'Struck off, registrar B');
CREATE VIEW relay_registered_businesses AS
SELECT registration_number,
record_revision,
lifecycle_state,
recorded_at,
legal_name,
legal_form,
registered_address
FROM businesses;

The view is the point of the file. Relay binds a resource to a view, never to a base table, so the institution decides in SQL which columns are even eligible for publication. registrar_note is not in the view, so nothing later in this tutorial can reach it, whatever the contract says.

Create the database and make it read-only:

Terminal window
sqlite3 registry.sqlite < registry.sql
chmod 444 registry.sqlite

These commands print nothing when they succeed. Relay opens the database read-only in any case; the file mode makes that visible to anyone reading the deployment.

Ask relayctl what it sees in the database:

Terminal window
relayctl inspect registry.sqlite

The report lists every table, view, and column, and opens with a fingerprint of the whole schema:

{
"status": "success",
"diagnostics": [],
"details": {
"kind": "schema-inspection",
"fingerprint": "sha256:b3c73e50829bf63f8034bac74ce23c9b387fa4e84ca0afc27bb98d5eccc0fe18",
"objects": [ ... ]
}
}

Copy that fingerprint. Writing it into the contract is how you say which schema you reviewed. If someone later adds, drops, or retypes a column, the fingerprint changes and Relay refuses to serve rather than guessing whether your review still applies.

Replace registry.yaml with this. It names the register, binds the resource to the view, publishes three properties, and then discloses only two of them.

apiVersion: relay.registrystack.org/v2alpha1
kind: RegistryContract
metadata:
id: business-registry
version: draft-1
title: Business register
registry:
registryIdentifier: urn:example:registry:businesses
name: Business register
authority:
identifier: urn:example:authority:registrar
name: Companies Registrar
authoritativeScope: Reviewed authoritative records in the declared jurisdiction
baseUri: https://registry.example.invalid/
identifierLifecyclePolicyRef: governance/identifier-lifecycle.yaml
alignmentTargets:
- name: govstack-digital-registries
version: 3.0.0-alpha.2
status: directional
governance:
controller: urn:example:authority:registrar
publisher: urn:example:authority:registrar
auditOwner: urn:example:authority:registrar
semantics:
localVocabulary: https://registry.example.invalid/vocabulary/
classifications:
privacy:
scheme: https://w3id.org/dpv
version: "2.3"
institutional:
scheme: urn:example:classification
version: draft-1
handling:
scheme: https://id.registrystack.org/vocab/handling
version: "1"
provenanceRef: governance/classification-review.yaml
sources:
registry:
kind: sqlite
profile: snapshot
expectedSchemaFingerprint: sha256:b3c73e50829bf63f8034bac74ce23c9b387fa4e84ca0afc27bb98d5eccc0fe18
resources:
- id: registered-business
title: Registered business
description: One entry in the public business register.
semanticClass: local:RegisteredBusiness
source:
source: registry
view: relay_registered_businesses
classificationDefaults:
privacy: non-personal
institutional: public
handling: public
status: reviewed
recordContext:
recordIdentifier:
sourceColumn: registration_number
revisionIdentifier:
sourceColumn: record_revision
lifecycleState:
sourceColumn: lifecycle_state
codelist: codelists/record-lifecycle.yaml
recordedAt:
sourceColumn: recorded_at
sourceColumnClassifications: {}
properties:
legalName:
label: Legal name
description: Registered legal name.
sourceColumn: legal_name
type: string
sourceRequired: true
semanticTerm: local:legalName
legalForm:
label: Legal form
description: Registered legal form.
sourceColumn: legal_form
type: string
sourceRequired: true
semanticTerm: local:legalForm
registeredAddress:
label: Registered address
description: Registered office address.
sourceColumn: registered_address
type: string
sourceRequired: true
semanticTerm: local:registeredAddress
disclosureProfiles:
public:
properties:
- legalName
- legalForm
operations:
read:
defaultAccessProfile: public
accessProfiles:
public:
access: public
disclosureProfile: public
processingDescriptions:
- id: consultation
operationRefs:
- read
purpose: reviewed-consultation
recipientClass: anonymous-public
legalBasisRef: governance/legal-basis.yaml
dpvProfileRef: governance/processing.dpv.yaml
safeguards:
- property-minimization
metadataVisibility:
service: public
resources: public
semantics: public
classifications: public
processing: public

Paste your own fingerprint into expectedSchemaFingerprint if it differs from the one above.

Three parts of that file are worth reading twice.

properties declares registeredAddress, but disclosureProfiles.public lists only legalName and legalForm. A property that is declared and not disclosed is a property the register knows about and this audience does not get. Declaring it is what lets you disclose it later to a different audience without touching the view.

access: public means anonymous. That is the whole authorization decision for this deployment, which is why it will need no identity provider later.

metadataVisibility is set to public throughout because a public audience has to be able to resolve the schema and vocabulary the answer points at. A contract that publishes an answer anonymously but hides the documents that explain it is refused, not served.

Terminal window
relayctl check .

The check compiles the contract, opens the database read-only to confirm the view and columns exist, and reports the revision of what it compiled:

{
"status": "success",
"diagnostics": [],
"details": {
"kind": "check",
"contract_revision": "sha256:<digest>",
"production": false
}
}

Nothing is running yet. check reads, and writes nothing.

Terminal window
relayctl generate .

This writes 22 artifacts under generated/: an OpenAPI 3.1 description, JSON Schema and SHACL shapes, a JSON-LD context and vocabulary, the capability inventory, the audit event schema, and the review reports. Every one of them is derived from the contract, so none of them can describe a field the contract does not disclose.

Two of the generated files are review inputs rather than outputs. generated/reports/classification-inventory.json is the list of source columns and output properties with the handling each one carries. generated/governance/classification-review-starter.yaml is a pre-filled review record for exactly that inventory:

apiVersion: relay.registrystack.org/classification-review/v1
kind: ClassificationReview
registryIdentifier: urn:example:registry:businesses
classificationInventoryDigest: sha256:b01c845f9aabc500bae3756dd3daa855c182f540d48ccc5a341ccd2ca25e5e1f
method: generated
reviewer: urn:example:authority:registrar
reviewDate: pending-review
status: suggested
rationaleRef: pending-review
generatedIdentification: ...

status: suggested and reviewDate: pending-review are the tool saying it has an opinion and no authority. Only a person supplies the rest.

Terminal window
relayctl check . --production

The starter project is refused, and the diagnostics say exactly why:

{
"status": "refused",
"diagnostics": [
{"code": "codelist.unreviewed", "location": "codelists/record-lifecycle.yaml",
"message": "production codelists must be institutionally reviewed"},
{"code": "classification.review_inventory_stale",
"location": "governance/classification-review.yaml:classificationInventoryDigest",
"message": "the classification review does not bind the current inventory"},
{"code": "classification.review_registry_stale",
"location": "governance/classification-review.yaml:registryIdentifier",
"message": "the classification review is bound to another Registry"},
{"code": "classification.review_date_invalid",
"location": "governance/classification-review.yaml:reviewDate",
"message": "the review date must be a canonical calendar date"},
{"code": "classification.review_unreviewed",
"location": "governance/classification-review.yaml:status",
"message": "production classification requires reviewed institutional evidence"}
]
}

This is the governed result. A contract that compiles is not a contract an institution has agreed to publish, and --production is the difference between the two.

Replace governance/classification-review.yaml with the review a person signs off. The digest comes from the starter file generate just wrote:

apiVersion: relay.registrystack.org/classification-review/v1
kind: ClassificationReview
registryIdentifier: urn:example:registry:businesses
classificationInventoryDigest: sha256:b01c845f9aabc500bae3756dd3daa855c182f540d48ccc5a341ccd2ca25e5e1f
method: manual
reviewer: urn:example:authority:registrar
reviewDate: 2026-08-11
status: reviewed
rationaleRef: governance/classification-review-rationale.md

method: manual is the honest description of what you just did: a person read the inventory and accepted it. method: generated is for the case where tooling produced the classifications and the review binds the report and rule pack that produced them, which the starter file shows.

Write the rationale it points at, in governance/classification-review-rationale.md:

# Classification review
The registrar read `generated/reports/classification-inventory.json` on
2026-08-11 and accepted it unchanged. Legal name and legal form are already
published in the paper register, so disclosing them to anonymous callers adds
no new disclosure. Registered address stays undisclosed pending a separate
review.

Mark the legal basis reviewed in governance/legal-basis.yaml:

status: reviewed
legalBasis: Public inspection of the business register under the Companies Act.

And the codelist in codelists/record-lifecycle.yaml, which has to list every lifecycle value the view can produce:

id: record-lifecycle
version: draft-1
values: [ACTIVE, RETIRED]
status: reviewed

Now run the production check again:

Terminal window
relayctl check . --production
{
"status": "success",
"diagnostics": [],
"details": {
"kind": "check",
"contract_revision": "sha256:<digest>",
"production": true
}
}
Terminal window
relayctl package . --output package
{
"status": "success",
"details": {
"kind": "package",
"manifest": {
"packageRevision": "sha256:<package-digest>",
"contractRevision": "sha256:<contract-digest>",
"sourceSchemaFingerprints": {
"registry": "sha256:b3c73e50829bf63f8034bac74ce23c9b387fa4e84ca0afc27bb98d5eccc0fe18"
}
}
}
}

The package holds the compiled contract, the generated artifacts, and the governed files, with a digest for each. It does not hold the database. Packaging recompiles under the production profile, so a package cannot be produced from a revision that would fail check --production.

runtime.yaml from relayctl init already points at registry.sqlite and package, so it needs no edit. Read it once:

apiVersion: relay.registrystack.org/v2alpha1
kind: RelayRuntime
server: {bind: "127.0.0.1:8080"}
packagePath: package
sources: {registry: {path: registry.sqlite}}
authentication: {issuer: null}
audit: {sink: var/audit.jsonl, integrityKeyRef: secret:env/RELAY_AUDIT_KEY}
limits: {requestTimeoutMilliseconds: 1500, concurrentQueries: 8}

authentication: {issuer: null} works here only because every access profile in the contract is public. Add one protected profile and Relay refuses to start without a reachable token issuer, even for requests that would have been anonymous.

Create the audit directory and the integrity key, then start the service:

Terminal window
mkdir -m 700 var
export RELAY_AUDIT_KEY="$(openssl rand -base64 32)"
relay serve --runtime runtime.yaml

Mode 700 is required, not tidiness. The audit directory must be owner-only, and Relay refuses to start when any group or other bit is set on it. Keep the key for the life of the deployment, too. The chain is bound to it, so a new key against an existing var/audit.jsonl is a startup failure rather than a fresh start.

Leave that running and open a second shell in the same directory.

Terminal window
curl -s http://127.0.0.1:8080/ready
curl -s http://127.0.0.1:8080/v2/resources/registered-business/records/BIZ-0001

The record answer, abridged to the part that matters:

{
"data": {
"recordIdentifier": "BIZ-0001",
"revisionIdentifier": "3",
"lifecycleState": "ACTIVE",
"recordedAt": "2026-02-11T09:00:00Z",
"registryIdentifier": "urn:example:registry:businesses",
"authorityIdentifier": "urn:example:authority:registrar",
"domainData": {
"legalName": "Aurora Freight Cooperative",
"legalForm": "COOPERATIVE"
}
},
"meta": {
"accessProfile": "public",
"disclosureProfile": "public",
"selectedFields": ["legalName", "legalForm"],
"contractRevision": "sha256:<digest>",
"sourceRevision": {"profile": "snapshot", "status": "versioned", "value": "sha256:<digest>"}
}
}

domainData carries the two disclosed fields. The address is in the view and in the contract, and it is not here. Every answer also states which contract revision produced it and which source revision it read, so a caller can tell two answers apart without asking you.

A caller can narrow the answer:

Terminal window
curl -s 'http://127.0.0.1:8080/v2/resources/registered-business/records/BIZ-0001?fields=legalName'

domainData now contains legalName alone, and meta.selectedFields says so.

A caller cannot widen it:

Terminal window
curl -s 'http://127.0.0.1:8080/v2/resources/registered-business/records/BIZ-0001?fields=registeredAddress'
{
"type": "https://id.registrystack.org/problems/registry-relay/request/fields_invalid",
"title": "Field selection is invalid",
"status": 400,
"code": "request.fields_invalid"
}

The refusal is a request error, not a permission error, because from this audience’s side the field does not exist. An unknown record is a separate refusal:

Terminal window
curl -s http://127.0.0.1:8080/v2/resources/registered-business/records/BIZ-9999
{
"title": "Requested record was not resolved",
"status": 404,
"code": "consultation.unresolved"
}

Two more routes are worth a look. GET /v2 returns the register’s own description, authority, and capability list. GET /v2/resources returns the resources this audience may call. Both answer anonymously here because metadataVisibility said they may.

Stop the service with Ctrl+C and read the first audit line:

Terminal window
head -n 1 var/audit.jsonl
{
"envelope_id": "...",
"prev_hash": null,
"record": {
"operationIdentifier": "registered-business.read",
"resourceIdentifier": "registered-business",
"principalKind": "anonymous",
"accessProfile": "public",
"disclosureProfile": "public",
"selectedProperties": ["legalName", "legalForm"],
"processingDescriptionIdentifiers": ["consultation"],
"contractRevision": "sha256:<digest>",
"sourceRevision": {"profile": "snapshot", "status": "versioned", "value": "sha256:<digest>"},
"phase": "attempt"
},
"record_hash": "..."
}

Read what is not there. The line records that an anonymous caller reached the registered-business.read operation, and that the contract scoped the answer to the properties legalName and legalForm under the consultation processing description. It does not record what the values were.

Read the phase too. attempt is written before Relay reads the source, so this line proves the request was accepted and scoped, not that it was answered. The matching terminal line carries the outcome, and a refusal is written as refusal instead. prev_hash is null on the first line and links each subsequent line to the one before it, which is what the integrity key protects.

Terminal window
cd ..
rm -rf business-registry
unset RELAY_AUDIT_KEY
  • A public read-only API whose whole surface came from one reviewed contract, with no route, filter, or field created by convention from the database.
  • Two boundaries under separate control: a SQL view the institution owns, and a disclosure profile the contract owns.
  • A production gate that refused the register until a named reviewer, a date, a rationale, and a digest of the exact inventory they reviewed were on file.
  • A caller who can narrow an answer and cannot widen it.
  • An audit chain that records which properties were released, to whom, under which contract revision, without recording the values.
SymptomCauseFix
project.destination_not_empty from relayctl initThe target directory already has filesInitialize into a new directory name.
contract.yaml_invalid from relayctl checkA required key is missing, an unknown key is present, or the YAML does not parseEvery key in the contract above is required. registry.alignmentTargets needs at least one entry.
resource.view_unknownsource.view names something that is not a view in the databaseRelay binds to views only. Add a CREATE VIEW for the columns you intend to publish.
metadata.reference_visibility_invalidA public audience cannot resolve the schema or vocabulary the answer points atSet metadataVisibility.resources and .semantics to public when any access profile is public.
classification.review_inventory_staleThe contract changed after the review was recordedRun relayctl generate . again and copy the new digest from generated/governance/classification-review-starter.yaml.
the required audit sink is not ready at startupvar/ is not owner-only, or RELAY_AUDIT_KEY differs from the key that started the existing chainchmod 700 var, and either restore the original key or remove var/audit.jsonl to start a new chain.