Govern Metadata with Schemas
metadata is a free bag: whatever a writer attaches is what the next reader gets. In a corpus several agents and people write into, that is how one writer's quarter becomes another's period, and the reader finds out at query time.
A metadata schema states the structure where every writer sees it. This tutorial declares one for /reports, watches a bad write be refused, checks a batch before sending it, and narrows the rule for one corner of the corpus.
By the end you will have:
| What | Why it matters |
|---|---|
A declaration governing /reports | Every writer is judged against one rule, whichever door they came through |
| A refused write | The corpus cannot acquire a document that violates it |
| A dry run | A batch importer learns what it would be told, before sending anything |
| A nested declaration | One corner of the corpus can be different without loosening the rest |
Prerequisites
- SOAT running locally at
http://localhost:5047(Quick Start). jqon yourPATH.- Key Concepts for projects, documents and the admin credential.
- Documents for the
pathmodel this builds on.
- CLI
- SDK
- curl
export SOAT_BASE_URL=http://localhost:5047
import { SoatClient } from '@soat/sdk';
export SOAT_BASE_URL=http://localhost:5047
Step 1 — Log in and create a project
A declaration is project-scoped: it governs writes into the project it belongs to and nothing else.
- CLI
- SDK
- curl
ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN
PROJECT_ID=$(soat create-project --name "Quarterly Reports" | jq -r '.id')
echo "project: $PROJECT_ID"
const soat = new SoatClient({ baseUrl: 'http://localhost:5047' });
const { data: login, error: loginErr } = await soat.users.loginUser({
body: { username: 'admin', password: 'Admin1234!' },
});
if (loginErr) throw new Error(JSON.stringify(loginErr));
const adminSoat = new SoatClient({
baseUrl: 'http://localhost:5047',
token: login.token,
});
const { data: project, error: projectErr } =
await adminSoat.projects.createProject({ body: { name: 'Quarterly Reports' } });
if (projectErr) throw new Error(JSON.stringify(projectErr));
const PROJECT_ID = project.id; // proj_…
ADMIN_TOKEN=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/users/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"Admin1234!"}' | jq -r '.token')
PROJECT_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/projects" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Quarterly Reports"}' | jq -r '.id')
echo "project: $PROJECT_ID"
Step 2 — Declare what a report must carry
The declaration names three things: the resource_type it governs, the selector that type is addressed by, and the schema itself. A document is selected by path_prefix, because a path is what a document is filed under.
/reports is matched on a path boundary: it covers /reports/q1.txt and never /reports-archive/q1.txt.
- CLI
- SDK
- curl
REPORT_SCHEMA_ID=$(soat create-metadata-schema \
--project-id "$PROJECT_ID" \
--resource-type document \
--path-prefix /reports \
--schema '{"type":"object","required":["quarter","owner"],"properties":{"quarter":{"type":"string","enum":["Q1","Q2","Q3","Q4"]},"owner":{"type":"string","minLength":1}}}' \
| jq -r '.id')
echo "schema: $REPORT_SCHEMA_ID"
const { data: reportSchema, error: schemaErr } =
await adminSoat.metadataSchemas.createMetadataSchema({
body: {
project_id: PROJECT_ID,
resource_type: 'document',
path_prefix: '/reports',
schema: {
type: 'object',
required: ['quarter', 'owner'],
properties: {
quarter: { type: 'string', enum: ['Q1', 'Q2', 'Q3', 'Q4'] },
owner: { type: 'string', minLength: 1 },
},
},
},
});
if (schemaErr) throw new Error(JSON.stringify(schemaErr));
const REPORT_SCHEMA_ID = reportSchema.id; // mdschema_…
REPORT_SCHEMA_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/metadata-schemas" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"resource_type\":\"document\",\"path_prefix\":\"/reports\",\"schema\":{\"type\":\"object\",\"required\":[\"quarter\",\"owner\"],\"properties\":{\"quarter\":{\"type\":\"string\",\"enum\":[\"Q1\",\"Q2\",\"Q3\",\"Q4\"]},\"owner\":{\"type\":\"string\",\"minLength\":1}}}}" \
| jq -r '.id')
echo "schema: $REPORT_SCHEMA_ID"
The schema is compiled as it is stored, so one JSON Schema cannot parse is refused here rather than kept as a rule that silently governs nothing.
Step 3 — Write a report that satisfies it
Nothing about the write changes: the declaration is enforced inside POST /api/v1/documents, not by anything the caller has to remember to call.
- CLI
- SDK
- curl
soat create-document \
--project-id "$PROJECT_ID" \
--path /reports/q1.txt \
--content "Revenue grew 12% quarter over quarter." \
--metadata '{"quarter":"Q1","owner":"finance"}' | jq '{id, path, metadata}'
const { data: q1, error: q1Err } = await adminSoat.documents.createDocument({
body: {
project_id: PROJECT_ID,
path: '/reports/q1.txt',
content: 'Revenue grew 12% quarter over quarter.',
metadata: { quarter: 'Q1', owner: 'finance' },
},
});
if (q1Err) throw new Error(JSON.stringify(q1Err));
console.log(q1.id, q1.metadata);
curl -s -X POST "$SOAT_BASE_URL/api/v1/documents" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"path\":\"/reports/q1.txt\",\"content\":\"Revenue grew 12% quarter over quarter.\",\"metadata\":{\"quarter\":\"Q1\",\"owner\":\"finance\"}}" \
| jq '{id, path, metadata}'
Step 4 — Watch a bad write be refused
A misspelled quarter and a missing owner. The refusal names the declaration that produced it, so a writer knows which rule to read rather than guessing which of its fields was wrong.
- CLI
- SDK
- curl
# → expect-fail
soat create-document \
--project-id "$PROJECT_ID" \
--path /reports/q2.txt \
--content "Revenue was flat." \
--metadata '{"quarter":"Quarter 2"}'
const { error: refused } = await adminSoat.documents.createDocument({
body: {
project_id: PROJECT_ID,
path: '/reports/q2.txt',
content: 'Revenue was flat.',
metadata: { quarter: 'Quarter 2' },
},
});
// VALIDATION_FAILED, with meta.metadata_schema_id naming the declaration
console.log(refused);
curl -s -X POST "$SOAT_BASE_URL/api/v1/documents" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"path\":\"/reports/q2.txt\",\"content\":\"Revenue was flat.\",\"metadata\":{\"quarter\":\"Quarter 2\"}}" \
| jq '.error | {code, message, meta}'
{
"code": "VALIDATION_FAILED",
"message": "metadata does not satisfy the schema declared for '/reports': (root) must have required property 'owner'; /quarter must be equal to one of the allowed values",
"meta": {
"metadata_schema_id": "mdschema_V1StGXR8Z5jdHi6B",
"resource_type": "document",
"path_prefix": "/reports"
}
}
Two more things this refuses, for the same reason:
- Clearing the bag.
"metadata": nullonPATCH /api/v1/documents/{document_id}is a write of the metadata, so arequiredfield refuses it. - Moving a document in. Repathing a document into
/reportsis judged against/reports, because the move is what changed which rule applies.
A document that carries no metadata is judged as carrying an empty bag, so under /reports it is refused for the missing quarter. POST /api/v1/documents/ingest carries no bag at all: the document it files is judged when metadata is first written to it.
Step 5 — Check a batch before sending it
An importer with a thousand rows wants to know what it would be told, without writing anything. POST /api/v1/metadata-schemas/validate answers exactly that: which declaration governs the path, and the error the write would be refused with.
- CLI
- SDK
- curl
soat validate-metadata \
--project-id "$PROJECT_ID" \
--path /reports/q3.txt \
--metadata '{"quarter":"Q3"}' | jq
const { data: verdict, error: verdictErr } =
await adminSoat.metadataSchemas.validateMetadata({
body: {
project_id: PROJECT_ID,
path: '/reports/q3.txt',
metadata: { quarter: 'Q3' },
},
});
if (verdictErr) throw new Error(JSON.stringify(verdictErr));
console.log(verdict.valid, verdict.error);
curl -s -X POST "$SOAT_BASE_URL/api/v1/metadata-schemas/validate" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"path\":\"/reports/q3.txt\",\"metadata\":{\"quarter\":\"Q3\"}}" | jq
{
"valid": false,
"resource_type": "document",
"metadata_schema_id": "mdschema_V1StGXR8Z5jdHi6B",
"path_prefix": "/reports",
"error": {
"code": "VALIDATION_FAILED",
"message": "metadata does not satisfy the schema declared for '/reports': (root) must have required property 'owner'",
"meta": {
"metadata_schema_id": "mdschema_V1StGXR8Z5jdHi6B",
"resource_type": "document",
"path_prefix": "/reports"
}
}
}
It reports and refuses nothing. The enforcement stays in the document write path, because a check a writer has to call is advisory — and the writer who skips it is the one the rule exists for.
Step 6 — Make one corner of the corpus different
Legal reports carry a reviewing counsel that ordinary reports do not. Declare a second schema for the nested prefix: the longest matching prefix decides, and it decides alone (Metadata Schemas — a declaration is a row). A nested declaration replaces the outer rule rather than adding to it, which is what lets this corner differ instead of only ever being stricter.
- CLI
- SDK
- curl
soat create-metadata-schema \
--project-id "$PROJECT_ID" \
--resource-type document \
--path-prefix /reports/legal \
--schema '{"type":"object","required":["counsel"],"properties":{"counsel":{"type":"string","minLength":1}}}' \
| jq '{id, path_prefix}'
# Governed by /reports/legal, so `counsel` alone is enough — `owner` is the
# outer rule's requirement and the outer rule does not apply here.
soat create-document \
--project-id "$PROJECT_ID" \
--path /reports/legal/q1-review.txt \
--content "No material litigation exposure this quarter." \
--metadata '{"counsel":"external"}' | jq '{path, metadata}'
const { error: legalErr } =
await adminSoat.metadataSchemas.createMetadataSchema({
body: {
project_id: PROJECT_ID,
resource_type: 'document',
path_prefix: '/reports/legal',
schema: {
type: 'object',
required: ['counsel'],
properties: { counsel: { type: 'string', minLength: 1 } },
},
},
});
if (legalErr) throw new Error(JSON.stringify(legalErr));
const { data: review, error: reviewErr } =
await adminSoat.documents.createDocument({
body: {
project_id: PROJECT_ID,
path: '/reports/legal/q1-review.txt',
content: 'No material litigation exposure this quarter.',
metadata: { counsel: 'external' },
},
});
if (reviewErr) throw new Error(JSON.stringify(reviewErr));
console.log(review.metadata);
curl -s -X POST "$SOAT_BASE_URL/api/v1/metadata-schemas" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"resource_type\":\"document\",\"path_prefix\":\"/reports/legal\",\"schema\":{\"type\":\"object\",\"required\":[\"counsel\"],\"properties\":{\"counsel\":{\"type\":\"string\",\"minLength\":1}}}}" \
| jq '{id, path_prefix}'
curl -s -X POST "$SOAT_BASE_URL/api/v1/documents" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"path\":\"/reports/legal/q1-review.txt\",\"content\":\"No material litigation exposure this quarter.\",\"metadata\":{\"counsel\":\"external\"}}" \
| jq '{path, metadata}'
Step 7 — Tighten the rule without touching what is stored
Reports now need a published_at. Replace the schema on the existing declaration with PATCH /api/v1/metadata-schemas/{metadata_schema_id}: the next write of the fields it governs is judged against it, and the documents already stored are left alone.
- CLI
- SDK
- curl
soat update-metadata-schema \
--metadata-schema-id "$REPORT_SCHEMA_ID" \
--schema '{"type":"object","required":["quarter","owner","published_at"],"properties":{"quarter":{"type":"string","enum":["Q1","Q2","Q3","Q4"]},"owner":{"type":"string","minLength":1},"published_at":{"type":"string"}}}' \
| jq '{id, path_prefix}'
# The Q1 report stored in Step 3 still reads back exactly as written.
soat list-documents --project-id "$PROJECT_ID" --path-prefix /reports \
| jq '[.data[] | {path, metadata}]'
# A new report without the new field is refused.
# → expect-fail
soat create-document \
--project-id "$PROJECT_ID" \
--path /reports/q4.txt \
--content "Revenue grew 4%." \
--metadata '{"quarter":"Q4","owner":"finance"}'
const { error: tightenErr } =
await adminSoat.metadataSchemas.updateMetadataSchema({
path: { metadata_schema_id: REPORT_SCHEMA_ID },
body: {
schema: {
type: 'object',
required: ['quarter', 'owner', 'published_at'],
properties: {
quarter: { type: 'string', enum: ['Q1', 'Q2', 'Q3', 'Q4'] },
owner: { type: 'string', minLength: 1 },
published_at: { type: 'string' },
},
},
},
});
if (tightenErr) throw new Error(JSON.stringify(tightenErr));
const { error: q4Err } = await adminSoat.documents.createDocument({
body: {
project_id: PROJECT_ID,
path: '/reports/q4.txt',
content: 'Revenue grew 4%.',
metadata: { quarter: 'Q4', owner: 'finance' },
},
});
// VALIDATION_FAILED: published_at is required now.
console.log(q4Err);
curl -s -X PATCH "$SOAT_BASE_URL/api/v1/metadata-schemas/$REPORT_SCHEMA_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"schema":{"type":"object","required":["quarter","owner","published_at"],"properties":{"quarter":{"type":"string","enum":["Q1","Q2","Q3","Q4"]},"owner":{"type":"string","minLength":1},"published_at":{"type":"string"}}}}' \
| jq '{id, path_prefix}'
curl -s -X POST "$SOAT_BASE_URL/api/v1/documents" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"path\":\"/reports/q4.txt\",\"content\":\"Revenue grew 4%.\",\"metadata\":{\"quarter\":\"Q4\",\"owner\":\"finance\"}}" \
| jq '.error.code'
Step 8 — Read the contract, and stop enforcing it
GET /api/v1/metadata-schemas is what a new writer reads to learn what the project expects. Deleting a declaration stops governing new writes; the documents already stored keep the metadata they hold, because the rule governed writes rather than rows.
- CLI
- SDK
- curl
soat list-metadata-schemas --project-id "$PROJECT_ID" \
| jq '[.data[] | {resource_type, path_prefix, required: .schema.required}]'
soat delete-metadata-schema --metadata-schema-id "$REPORT_SCHEMA_ID"
# Free again: /reports/legal still has its own rule, /reports has none.
soat create-document \
--project-id "$PROJECT_ID" \
--path /reports/q4.txt \
--content "Revenue grew 4%." \
--metadata '{"quarter":"Q4"}' | jq '{path, metadata}'
const { data: declared, error: listErr } =
await adminSoat.metadataSchemas.listMetadataSchemas({
query: { project_id: PROJECT_ID },
});
if (listErr) throw new Error(JSON.stringify(listErr));
console.log(declared.data.map((d) => [d.path_prefix, d.schema]));
await adminSoat.metadataSchemas.deleteMetadataSchema({
path: { metadata_schema_id: REPORT_SCHEMA_ID },
});
curl -s "$SOAT_BASE_URL/api/v1/metadata-schemas?project_id=$PROJECT_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| jq '[.data[] | {resource_type, path_prefix}]'
curl -s -X DELETE "$SOAT_BASE_URL/api/v1/metadata-schemas/$REPORT_SCHEMA_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" -o /dev/null -w '%{http_code}\n'
curl -s -X POST "$SOAT_BASE_URL/api/v1/documents" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"path\":\"/reports/q4.txt\",\"content\":\"Revenue grew 4%.\",\"metadata\":{\"quarter\":\"Q4\"}}" \
| jq '{path, metadata}'
What you built
A corpus whose structure is stated once and enforced everywhere it is written:
| Rule | Where it holds |
|---|---|
| A prefix is a path boundary | /reports covers /reports/q1.txt, never /reports-archive/q1.txt |
| The longest matching prefix decides, alone | /reports/legal replaces /reports for what it covers |
| No bag is an empty bag | A create without metadata and a clearing null are both judged as {} |
| Tightening applies forward | Stored documents are untouched; the next write of those fields is judged |
| The check is not the enforcement | validate reports; the document write path refuses |
The same declaration governs a document resource in a formation, so a template can ship a corpus and the rule governing it together.
Today document is the resource type the registry enforces. A type joins it in the change that gives its write path the same gate — the declaration's shape does not change, only which selector it carries.
Next steps
- Metadata Schemas — the module reference.
- Documents — Metadata schemas — how the gate reads on the document side.
- Tag-Based Access Control — tags govern who may see a document; a metadata schema governs what it must say.