Released docs. You are viewing the documentation published with v0.34.0. Development docs are available at Latest.
Publish a governed SQLite registry
For the data publisher and operator
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.
Understand the flow
Section titled “Understand the flow”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.
Install Registry Relay
Section titled “Install Registry Relay”You need two binaries: relay serves a sealed package, while relayctl authors and checks a
project. The installer accepts Linux amd64 only and places both in ~/.local/bin unless you set
RELAY_INSTALL_DIR:
curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/relay-install.sh | bashrelay --versionrelayctl --versionIf either version command is not found, add ~/.local/bin to your PATH.
The installer checks both binaries against the release SHA256SUMS before either one reaches
your PATH, which catches a truncated or corrupted download but not a substituted release.
Replace | bash with | less to read the installer first, and before you run these binaries
anywhere but your own machine, verify the signed checksum chain described in
OpenSSF and release trust.
Start a project
Section titled “Start a project”relayctl init writes a starter project into a new directory:
relayctl init business-registrycd business-registryIt reports the files it created. Every relayctl command reports in the same shape: one line
saying what happened, then the detail underneath. Add --json to any of them for the full report
as JSON, which carries more than the summary a person reads.
Initialized an authoring project. 7 files written. registry.yaml runtime.yaml governance/identifier-lifecycle.yaml governance/classification-review.yaml governance/legal-basis.yaml governance/processing.dpv.yaml codelists/record-lifecycle.yamlregistry.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.
Build the register database
Section titled “Build the register database”The institution’s database is an input, not part of the project. Create a small one here.
Create registry.sql and put this in it:
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 ASSELECT registration_number, record_revision, lifecycle_state, recorded_at, legal_name, legal_form, registered_addressFROM 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:
sqlite3 registry.sqlite < registry.sqlchmod 444 registry.sqliteThese 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.
Record the schema you reviewed
Section titled “Record the schema you reviewed”Ask relayctl what it sees in the database:
relayctl inspect registry.sqliteThe report opens with a fingerprint of the whole schema, then lists every table, view, index, and column:
Inspected the SQLite structure. 3 objects. fingerprint sha256:b3c73e50829bf63f8034bac74ce23c9b387fa4e84ca0afc27bb98d5eccc0fe18
index sqlite_autoindex_businesses_1 on businesses table businesses registration_number TEXT not null primary key 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 view relay_registered_businesses registration_number TEXT nullable record_revision TEXT nullable lifecycle_state TEXT nullable recorded_at TEXT nullable legal_name TEXT nullable legal_form TEXT nullable registered_address TEXT nullableThe fingerprint covers the schema statements SQLite stored, not the rows. Inserting or editing
records never changes it. Reformatting a CREATE TABLE or CREATE VIEW does change it, even
when the resulting schema is the same, because the stored statement text is part of what is
hashed. The same stored schema always produces the same fingerprint, on any machine and on every
run, so a value that differs from the one this tutorial prints means your schema text differs, not
that your run went wrong.
Copy your own fingerprint out of that report. 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.
Write the contract
Section titled “Write the contract”The contract names the register, binds the resource to the view, publishes three properties, and
then discloses only two of them. It is shown here one section at a time. Empty registry.yaml
first, then append each block below in the order it appears; together they are the file
relayctl check reads.
Start with what the document is:
apiVersion: relay.registrystack.org/v2alpha1kind: RegistryContractmetadata: id: business-registry version: draft-1 title: Business registermetadata.version is your own label for this revision of the contract. It is not the revision
Relay computes and reports, which is a digest of the compiled contract and which you cannot set
by hand.
Next, who the register belongs to and what it claims to be authoritative about:
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: directionalauthoritativeScope is the sentence a caller reads to decide whether this register answers their
question at all. alignmentTargets needs at least one entry, and status: directional is the
honest setting for an alignment you have read but not conformance-tested.
Then the three roles that have to be attributable to someone, and where locally defined terms live:
governance: controller: urn:example:authority:registrar publisher: urn:example:authority:registrar auditOwner: urn:example:authority:registrar
semantics: localVocabulary: https://registry.example.invalid/vocabulary/All three roles are the registrar here because one institution holds all three. They are separate keys because in a real deployment they often are not the same body, and the audit chain is only meaningful when someone is named as answerable for it.
Next, the vocabularies this contract classifies against:
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.yamlEvery classification later in the file is a term from one of these three schemes, pinned to a
version. provenanceRef points at the review record that says a person agreed with those terms,
which is the file the production gate will check later in this tutorial.
Now the source, which is where your fingerprint goes:
sources: registry: kind: sqlite profile: snapshot expectedSchemaFingerprint: sha256:<your-schema-fingerprint>Replace sha256:<your-schema-fingerprint> with the value relayctl inspect printed for your
database. Nothing else in the tutorial substitutes for it: a contract carrying anyone else’s
fingerprint is refused with source.schema_fingerprint_mismatch.
The resource is the largest section, so it arrives in five parts. First its identity and the view it binds to:
resources: - id: registered-business datasetIdentifier: businesses entityTypeIdentifier: 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: revieweddatasetIdentifier and entityTypeIdentifier are not inferred from id, so a cursor stays scoped
to this resource even if id is later renamed. view names the view you wrote, never a base
table. classificationDefaults applies to anything in this resource that does not classify
itself, so the defaults are what you would have to override to publish something more sensitive.
Then the four columns that carry record identity rather than content:
recordContext: recordIdentifier: sourceColumn: registration_number revisionIdentifier: sourceColumn: record_revision lifecycleState: sourceColumn: lifecycle_state codelist: codelists/record-lifecycle.yaml recordedAt: sourceColumn: recorded_at sourceColumnClassifications: {}recordContext is why an answer can say which record it is, which revision of it you got, and
whether that record is still current. The codelist has to list every lifecycle value the view can
produce, so a value the register invents later is a refusal rather than a surprise in an answer.
Then the properties the contract knows about:
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:registeredAddressAll three properties are declared, including registeredAddress. Declaring a property is not
publishing it: the next block decides who gets which of them.
Then the disclosure and access decision:
disclosureProfiles: public: properties: - legalName - legalForm operations: read: defaultAccessProfile: public accessProfiles: public: access: public disclosureProfile: publicdisclosureProfiles.public lists only legalName and legalForm. A property that is declared
and not disclosed is one the register knows about and this audience does not get, and declaring
it is what lets you disclose it later to a different audience without touching the view.
access: public means anonymous, and that is the whole authorization decision for this
deployment, which is why it will need no identity provider later.
Then why the register is doing this at all:
processingDescriptions: - id: consultation operationRefs: - read purpose: reviewed-consultation recipientClass: anonymous-public legalBasisRef: governance/legal-basis.yaml dpvProfileRef: governance/processing.dpv.yaml safeguards: - property-minimizationA processing description binds an operation to a purpose, an audience, and a legal basis on file. Its identifier is what the audit chain records against each released answer, so a released field can always be traced back to the stated reason for releasing it.
Finally, what a caller may read about the register itself:
metadataVisibility: service: public resources: public semantics: public classifications: public processing: publicmetadataVisibility 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.
Check the contract
Section titled “Check the contract”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. The two counts are the accepted
configuration key paths: how many keys registry.yaml and runtime.yaml will each take, not how
many yours uses:
Authoring check passed. contract revision sha256:<authoring-contract-revision> registry key paths <n> runtime key paths <n>Your run prints real values where this page shows placeholders. The counts belong to the relayctl release you installed rather than to anything you wrote, and the revision changes whenever the compiled contract does, so pinning either one here would only tell you what an older release once printed. Both are stable for a given release and a given contract, which is what lets a later step compare one revision against another.
Nothing is running yet. check reads, and writes nothing.
Generate the artifacts
Section titled “Generate the artifacts”relayctl generate .This writes the published description of the register 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. Running generate
again on an unchanged project rewrites the same bytes.
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. Its classificationInventoryDigest is a digest of your own inventory,
so it changes whenever the contract changes what is classified:
apiVersion: relay.registrystack.org/classification-review/v1kind: ClassificationReviewregistryIdentifier: urn:example:registry:businessesclassificationInventoryDigest: sha256:<inventory-digest>method: generatedreviewer: urn:example:authority:registrarreviewDate: pending-reviewstatus: suggestedrationaleRef: pending-reviewgeneratedIdentification: ...status: suggested and reviewDate: pending-review are the tool saying it has an opinion and no
authority. Only a person supplies the rest.
See what production refuses
Section titled “See what production refuses”relayctl check . --productionThe starter project is refused, and the diagnostics say exactly why. Each one gives its severity, its code, the file and key it is about, and the sentence underneath:
Production check refused.
error codelist.unreviewed codelists/record-lifecycle.yaml production codelists must be institutionally reviewed error classification.review_inventory_stale governance/classification-review.yaml:classificationInventoryDigest the classification review does not bind the current inventory error classification.review_registry_stale governance/classification-review.yaml:registryIdentifier the classification review is bound to another Registry error classification.review_date_invalid governance/classification-review.yaml:reviewDate the review date must be a canonical calendar date error classification.review_unreviewed governance/classification-review.yaml:status production classification requires reviewed institutional evidence
5 errors, 0 warnings.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.
Record the review
Section titled “Record the review”Replace governance/classification-review.yaml with the review a person signs off. Copy
classificationInventoryDigest out of the starter file generate wrote, and use the date on
which you actually read the inventory:
apiVersion: relay.registrystack.org/classification-review/v1kind: ClassificationReviewregistryIdentifier: urn:example:registry:businessesclassificationInventoryDigest: sha256:<inventory-digest>method: manualreviewer: urn:example:authority:registrarreviewDate: 2026-08-11status: reviewedrationaleRef: governance/classification-review-rationale.mdmethod: manual is the honest description of what you 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` on2026-08-11 and accepted it unchanged. Legal name and legal form are alreadypublished in the paper register, so disclosing them to anonymous callers addsno new disclosure. Registered address stays undisclosed pending a separatereview.Mark the legal basis reviewed in governance/legal-basis.yaml:
status: reviewedlegalBasis: 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-lifecycleversion: draft-1values: [ACTIVE, RETIRED]status: reviewedNow run the production check again:
relayctl check . --productionProduction check passed. contract revision sha256:<contract-revision> registry key paths <n> runtime key paths <n>The contract revision is not the one the first check reported: run this yourself and you will see a different digest here than the authoring check printed above. The revision covers the governed files the contract points at, so recording the review changed it, and every answer the service gives will carry this value rather than the earlier one.
Seal the package
Section titled “Seal the package”relayctl package . --output packageSealed a deployment package. <n> artifacts, <n> files. package version relay.registrystack.org/package/v1alpha3 package revision sha256:<package-revision> contract revision sha256:<contract-revision> artifact bindings <n>
source schema fingerprints registry sha256:b3c73e50829bf63f8034bac74ce23c9b387fa4e84ca0afc27bb98d5eccc0fe18That report summarizes package/relay-package.json, which names every file and artifact in
the package with a digest for each and records the full observed schema of every source. The
package holds the compiled contract, the generated artifacts, and the governed files. 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.
Serve it
Section titled “Serve it”runtime.yaml from relayctl init already points at registry.sqlite and package, so it
needs no edit. Read it once:
apiVersion: relay.registrystack.org/v2alpha1kind: RelayRuntimeserver: {bind: "127.0.0.1:8080"}packagePath: packagesources: {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:
mkdir -m 700 varexport RELAY_AUDIT_KEY="$(openssl rand -base64 32)"relay serve --runtime runtime.yamlMode 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.
Relay writes line-delimited JSON to standard output. It logs relay startup began, and then
relay service listening with the bound address once the port is actually held. If the second
line does not appear, the service did not start and the log says why.
Leave that running and open a second shell in the same directory.
Ask the register a question
Section titled “Ask the register a question”curl -s http://127.0.0.1:8080/ready{"status":"ready"}Then ask for a record:
curl -s http://127.0.0.1:8080/v2/resources/registered-business/records/BIZ-0001The answer, abridged to the part this step is about. The real one also carries the URLs a caller follows to the generated schema, vocabulary, and JSON-LD context:
{ "data": { "recordIdentifier": "BIZ-0001", "revisionIdentifier": "3", "lifecycleState": "ACTIVE", "recordedAt": "2026-02-11T09:00:00Z", "authorityIdentifier": "urn:example:authority:registrar", "domainData": { "legalName": "Aurora Freight Cooperative", "legalForm": "COOPERATIVE" } }, "meta": { "accessProfile": "public", "disclosureProfile": "public", "selectedFields": ["legalName", "legalForm"], "contractRevision": "sha256:<contract-revision>", "sourceRevision": {"profile": "snapshot", "status": "versioned", "value": "sha256:<digest>"}, "registryIdentifier": "urn:example:registry:businesses", "datasetIdentifier": "businesses", "entityTypeIdentifier": "business" }}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.
Ask for less, then try to ask for more
Section titled “Ask for less, then try to ask for more”A caller can narrow the answer:
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:
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", "detail": "field selection is invalid", "traceId": "<trace-id>"}The refusal is a request error, not a permission error, because from this audience’s side the
field does not exist. traceId is the same identifier the service logged for that request, which
is how an operator ties a caller’s complaint to one log line without the caller quoting the
answer. An unknown record is a separate refusal:
curl -s http://127.0.0.1:8080/v2/resources/registered-business/records/BIZ-9999{ "type": "https://id.registrystack.org/problems/registry-relay/consultation/unresolved", "title": "Requested record was not resolved", "status": 404, "code": "consultation.unresolved", "detail": "the requested record was not resolved", "traceId": "<trace-id>"}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.
Read the audit entry
Section titled “Read the audit entry”Stop the service with Ctrl+C and read the first audit line:
head -n 1 var/audit.jsonlThe line is one JSON object. Abridged to the fields this step is about:
{ "envelope_id": "<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:<contract-revision>", "sourceRevision": {"profile": "snapshot", "status": "versioned", "value": "sha256:<digest>"}, "phase": "attempt" }, "record_hash": "<record-hash>"}The envelope also carries timestamp_unix_ms. The full record carries more than is shown: the
registry identifier, the revision of the access rules that were applied, the operation surface
and wire format, the trace identifier shared with the log line, and the handling levels the
answer was released under. None of them is a field value either.
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.
Clean up
Section titled “Clean up”cd ..rm -rf business-registryunset RELAY_AUDIT_KEYWhat you built
Section titled “What you built”- 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.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Cause | Fix |
|---|---|---|
project.destination_not_empty from relayctl init | The target directory already has files | Initialize into a new directory name. |
contract.yaml_invalid from relayctl check | A required key is missing, an unknown key is present, or the YAML does not parse | Every key in the contract above is required. registry.alignmentTargets needs at least one entry. |
resource.view_unknown | source.view names something that is not a view in the database | Relay binds to views only. Add a CREATE VIEW for the columns you intend to publish. |
source.schema_fingerprint_invalid | expectedSchemaFingerprint still holds the sha256:<your-schema-fingerprint> placeholder | Run relayctl inspect registry.sqlite and paste the value it prints. |
source.schema_fingerprint_mismatch | The contract holds a fingerprint from a different schema, usually because the SQL was retyped rather than copied | Run relayctl inspect registry.sqlite again and paste the current value. |
metadata.reference_visibility_invalid | A public audience cannot resolve the schema or vocabulary the answer points at | Set metadataVisibility.resources and .semantics to public when any access profile is public. |
classification.review_inventory_stale | The contract changed after the review was recorded | Run relayctl generate . again and copy the new digest from generated/governance/classification-review-starter.yaml. |
the required audit sink is not ready at startup | var/ is not owner-only, or RELAY_AUDIT_KEY differs from the key that started the existing chain | chmod 700 var, and either restore the original key or remove var/audit.jsonl to start a new chain. |
- Understand Relay’s product boundary to decide whether Relay fits an institutional register.
- Author a Registry Relay project to add list and search operations, protected access profiles, and a second disclosure profile for a named audience.
- Review semantics, classification, and disclosure before assigning published names and handling levels to real fields.
- Operate Registry Relay to bind a real token issuer, trusted paths, audit retention, and a private listener.