Released docs. You are viewing the documentation published with v0.20.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, 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:
curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/relay-install.sh | bashrelay --versionTake 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:
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/relayctlrelayctl --versionIf either version command is not found, add ~/.local/bin to your PATH.
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:
{ "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.
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.
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 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 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.
Write the contract
Section titled “Write the contract”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/v2alpha1kind: RegistryContractmetadata: 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: publicPaste 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.
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:
{ "status": "success", "diagnostics": [], "details": { "kind": "check", "contract_revision": "sha256:<digest>", "production": false }}Nothing is running yet. check reads, and writes nothing.
Generate the artifacts
Section titled “Generate the artifacts”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/v1kind: ClassificationReviewregistryIdentifier: urn:example:registry:businessesclassificationInventoryDigest: sha256:b01c845f9aabc500bae3756dd3daa855c182f540d48ccc5a341ccd2ca25e5e1fmethod: 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:
{ "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.
Record the review
Section titled “Record the review”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/v1kind: ClassificationReviewregistryIdentifier: urn:example:registry:businessesclassificationInventoryDigest: sha256:b01c845f9aabc500bae3756dd3daa855c182f540d48ccc5a341ccd2ca25e5e1fmethod: manualreviewer: urn:example:authority:registrarreviewDate: 2026-08-11status: reviewedrationaleRef: governance/classification-review-rationale.mdmethod: 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` 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 . --production{ "status": "success", "diagnostics": [], "details": { "kind": "check", "contract_revision": "sha256:<digest>", "production": true }}Seal the package
Section titled “Seal the package”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.
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.
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/readycurl -s http://127.0.0.1:8080/v2/resources/registered-business/records/BIZ-0001The 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.
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"}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:
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.
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.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.
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. |
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.