M1, M2 and M3 complete * M1, M2 and M3 complete * review changes * docs * docs
12 KiB
Custom Metadata Guide (Mutable Metadata v4)
Overview
The custom metadata system lets platform operators define client-scoped JSON Schema definitions and allows end users to attach structured, validated metadata blobs to individual documents. This is distinct from — and mutually exclusive with — the legacy field extraction system (documentFieldExtractions).
When to Use Custom Metadata vs Legacy Field Extractions
| Custom Metadata | Legacy Field Extractions | |
|---|---|---|
| Schema definition | Per-client JSON Schema (flexible) | Fixed set of ~19 single-value + 112 array fields |
| Schema evolution | Via POST .../versions (preserved history) |
Schema is static |
| Validation | JSON Schema draft 2020-12 | Fixed column types |
| Who configures | super_admin |
Platform deployment |
| Who writes | client_user, user_admin, super_admin |
Any authenticated user |
A document may use one system or the other, never both. The mutual exclusivity invariant is enforced at three layers: service-layer row locks, Trigger 1 (trg_prevent_schema_reassignment), and Trigger 2 (trg_prevent_legacy_extraction_on_custom_document). See the Data Architecture doc for trigger details.
Authorization Matrix
| Role | SuperAdminSchemaService endpoints |
CustomMetadataService endpoints |
|---|---|---|
super_admin |
Full (GET, POST, PATCH, DELETE) | Full (GET, POST) |
user_admin |
No access (403 on all) | Full (GET, POST) |
client_user |
No access (403 on all) | Full (GET, POST) |
auditor |
GET only (read-only) | GET only (no POST) |
Important: user_admin does NOT have access to any /super-admin/custom-schemas or /super-admin/documents/*/schema endpoints. The /super-admin/ path prefix is intentionally restricted to super_admin only. This is a deliberate difference from the legacy /admin/ prefix, which did grant some access to user_admin.
Mutual Exclusivity
A document may have either legacy field extractions OR custom metadata — never both.
- Attempting to assign a custom schema to a document that already has legacy field extraction rows returns
409 Conflict. - Attempting to write legacy field extractions to a document that already has a custom schema assigned returns
409 Conflict. - The database enforces this via two triggers; the service layer reinforces it with explicit
FOR UPDATEparent-row locks on thedocumentsrow before any DML.
Platform Operator Workflow (super_admin)
This section describes how a platform operator sets up custom metadata for a client.
Step 1 — Define the schema
POST /super-admin/custom-schemas
Content-Type: application/json
{
"clientId": "AAA",
"name": "aircraft-engineering",
"description": "Schema for aircraft inspection reports",
"schemaDef": {
"type": "object",
"additionalProperties": false,
"properties": {
"aircraft_type": { "type": "string", "maxLength": 100 },
"flight_hours": { "type": "number", "minimum": 0 },
"inspection_date": { "type": "string", "format": "date" },
"critical_findings": {
"type": "array",
"items": { "type": "string" }
}
},
"required": ["aircraft_type", "inspection_date"]
}
}
The response includes id (the schema UUID), version: 1, and status: "active".
Step 2 — Assign the schema to a document
PATCH /super-admin/documents/{documentId}/schema
Content-Type: application/json
{
"schemaId": "019580df-ef65-7676-8de9-94435a93337a"
}
The schema and document must belong to the same client. The document must not already have legacy field extractions or existing custom metadata.
Step 3 — Users write metadata
Once a schema is assigned, users with client_user, user_admin, or super_admin roles can write metadata (see end-user workflow below).
End-User Workflow (client_user)
Write metadata
POST /custom-metadata
Content-Type: application/json
{
"documentId": "019580df-b3f8-7348-9ab1-1e55b3f18ed7",
"metadata": {
"aircraft_type": "Boeing 737",
"inspection_date": "2026-04-01",
"flight_hours": 12450.5,
"critical_findings": ["hydraulic leak in bay 3"]
}
}
Each call appends a new version. The metadata is validated against the schema currently bound to the document. Returns 400 with validation details if the payload does not conform.
Read current metadata
GET /custom-metadata?documentId=019580df-b3f8-7348-9ab1-1e55b3f18ed7
Returns the latest version of the metadata along with schemaId, schemaName, and schemaVersion from the bound schema.
Read a specific version
GET /custom-metadata/version?documentId=019580df-b3f8-7348-9ab1-1e55b3f18ed7&version=2
version must be >= 1. Returns 404 if that version does not exist.
List version history
GET /custom-metadata/history?documentId=019580df-b3f8-7348-9ab1-1e55b3f18ed7&limit=20&offset=0
Returns version summaries (version, createdAt, createdBy) in descending order. Does not return the full metadata payloads. limit must be 1–200.
Schema Definition Requirements
Custom schema definitions must conform to the following rules. Submissions that violate any rule are rejected with 400.
- Must be valid JSON — malformed JSON is rejected immediately.
- Must be valid JSON Schema (draft 2020-12) — the definition is meta-validated against the JSON Schema draft 2020-12 meta-schema.
- Root
typemust be"object"— schemas with a different root type (e.g."array","string") are rejected. additionalPropertiesmust be explicitly declared — the absence of this key is rejected. The author must make a deliberate choice:false(strict, no extra keys) ortrue(open, any extra keys allowed).- Maximum size is 64 KB — definitions larger than 65,536 bytes are rejected.
Sample Schema — Strict (no additional properties)
{
"type": "object",
"additionalProperties": false,
"properties": {
"aircraft_type": { "type": "string", "maxLength": 100 },
"flight_hours": { "type": "number", "minimum": 0 },
"inspection_date": { "type": "string", "format": "date" },
"critical_findings": {
"type": "array",
"items": { "type": "string" }
}
},
"required": ["aircraft_type", "inspection_date"]
}
Sample Schema — Open (additional properties allowed)
{
"type": "object",
"additionalProperties": true,
"properties": {
"document_class": { "type": "string" },
"reviewed_by": { "type": "string" }
}
}
Metadata Write Requirements
- Payload must be a JSON object (JSONB).
- Maximum size is 1 MB (1,048,576 bytes).
- Payload is validated against the schema currently bound to the document at write time.
- If the document has no schema assigned, the write returns
400. createdByis not accepted in the request body — actor identity comes from the JWTsubclaim.
Schema Versioning
A schema name within a client forms a lineage. Each version in the lineage is an independent row with a sequential version integer.
Creating a new version
POST /super-admin/custom-schemas/{currentActiveSchemaId}/versions
Content-Type: application/json
{
"clientId": "AAA",
"name": "aircraft-engineering",
"description": "v2: added optional maintenance_notes field",
"schemaDef": {
"type": "object",
"additionalProperties": false,
"properties": {
"aircraft_type": { "type": "string", "maxLength": 100 },
"flight_hours": { "type": "number", "minimum": 0 },
"inspection_date": { "type": "string", "format": "date" },
"critical_findings": { "type": "array", "items": { "type": "string" } },
"maintenance_notes": { "type": "string" }
},
"required": ["aircraft_type", "inspection_date"]
}
}
On success:
- A new row is inserted with
version = 2andstatus = "active". - The parent row (
schemaIdin the path) is updated tostatus = "superseded". - Both changes are applied atomically in a single transaction.
The HTTP method is POST, not PUT. Each call creates a new row with a new ID. The old version is preserved permanently and remains queryable.
Status values
| Status | Meaning |
|---|---|
active |
The current version for this schema name. Documents can be assigned to it. Only one active version per (client_id, name) lineage at any time. |
superseded |
A previous version. Still queryable. Documents bound to it retain the binding. Cannot be assigned to new documents. |
retired |
Explicitly removed via DELETE. No documents are bound (DELETE returns 409 if any are). |
Passing a non-active parent to POST .../versions
If the schemaId in the path points to a superseded or retired row, the request returns 409 Conflict. The active version for the lineage must be used as the path parameter.
Schema Upgrade Runbook (Reset-Metadata Flow)
Once a document has custom metadata written to it, its custom_schema_id is frozen by Trigger 1. To migrate a document to a newer schema version:
Warning: This process is irreversible. All metadata history for the document is permanently deleted.
Step 1 — Create the new schema version (if not already done)
POST /super-admin/custom-schemas/{currentActiveSchemaId}/versions
Note the new schema's id.
Step 2 — Reset the document
POST /super-admin/documents/{documentId}/reset-metadata
This single atomic operation:
- Deletes all rows in
document_custom_metadatafor the document. - Sets
documents.custom_schema_idtoNULL. - Returns a summary including
metadataVersionsDeletedand thepreviousSchemaId.
The operation is idempotent. If the document has no schema and no metadata, it returns 200 with metadataVersionsDeleted: 0.
Step 3 — Assign the new schema version
PATCH /super-admin/documents/{documentId}/schema
Content-Type: application/json
{
"schemaId": "<new-schema-version-id>"
}
Step 4 — Write new metadata
POST /custom-metadata
Content-Type: application/json
{
"documentId": "{documentId}",
"metadata": { ... }
}
Cascade Deletion Behavior
Custom metadata and schema data are cleaned up automatically via database FK cascades. No application-level cleanup steps are required.
DELETE /document/{id}(viaDeleteDocumentCascade): deletes alldocument_custom_metadatarows for the document viaON DELETE CASCADEon the FK fromdocument_custom_metadata.document_idtodocuments.id.DELETE /client/{id}(viaclient.HardDelete): deletes the client, which cascades toclient_metadata_schemas(FK withON DELETE CASCADE), then todocuments, and then todocument_custom_metadata.
No additional steps are needed in the delete code paths for this feature.
Actor Fields
createdBy and resetBy fields in all request bodies are ignored — they are absent from the v4 request schemas. Actor identity is always derived from the JWT sub claim (an opaque Cognito subject UUID). Response fields that carry the actor identity are plain strings, not email-formatted values.
Permit.io Deployment Note
Before deploying the SuperAdminSchemaService or CustomMetadataService controllers, the Permit.io resource and role configuration must be applied. Run the setup tool for each environment:
# Development
cmd/auth_related/permit.setup/run.tool.dev.sh
# UAT
cmd/auth_related/permit.setup/run.tool.uat.sh
# Production (requires explicit approval)
cmd/auth_related/permit.setup/run.tool.prod.sh
The setup tool applies the super-admin and custom-metadata resources and the role grants defined in cmd/auth_related/permit.setup/permit_policies.yaml. Without this step, all requests to the new endpoints will return 403 regardless of the user's Cognito role.