Data Model Reference
Data Model Reference
In one sentence. WIP's data model is four building blocks — terminologies/terms (controlled vocabulary), templates (versioned document schemas), documents (validated, identity-addressed instances of a template), and files (binary attachments) — unified by the Registry's canonical IDs and synonym resolution (wip://data-model).
Why it exists. WIP is one generic, template-driven platform, not a bespoke table per app. A template "defines the structure, validation rules, and constraints that documents must conform to" and "support[s] inheritance, allowing child templates to extend parent templates" (template.py, Template docstring). A document "support[s] versioning through identity-based upsert logic," using the identity hash "to detect when a document should be updated (new version) rather than created as a new entity" (document.py, Document docstring). Together, templates + identity give every document class real validation and safe schema evolution (multiple template versions can stay active at once) without every caller inventing its own dedup key.
The model
Terminologies & terms
A terminology is a namespaced controlled vocabulary; a term is one entry in it. Documents store both the value the caller submitted and the resolved term_id (wip://data-model).
| Terminology field | Notes |
|---|---|
terminology_id, namespace, value, label, description | identity/display |
case_sensitive, allow_multiple, extensible, mutable | booleans, default false; mutable=true implies extensible=true |
metadata | {source, source_url, version, language(default "en"), custom} |
status, term_count | lifecycle / derived count |
(schemas/def-store.json → TerminologyResponse, CreateTerminologyRequest, TerminologyMetadata)
| Term field | Notes |
|---|---|
term_id, namespace, terminology_id, value | identity |
aliases[] | alternative values that resolve to this term (e.g. ["MR.", "mr"]) |
label, description, sort_order, parent_term_id | display / hierarchy |
translations[] | {language (ISO 639-1), label, description} |
status, deprecated_reason, replaced_by_term_id | lifecycle |
(schemas/def-store.json → TermResponse, CreateTermRequest, TermTranslation)
Terms carry typed ontology relations to each other: is_a, part_of, has_part, regulates, positively_regulates, negatively_regulates, each {source_term_id, target_term_id, relation_type}. Relations support ancestor/descendant/parent/child traversal and bulk import from OBO Graph JSON (wip://data-model; schemas/def-store.json → CreateTermRelationRequest, TraversalResponse).
Templates & fields
| Template field | Notes |
|---|---|
template_id, namespace, value, label, description | identity/display; value unique within namespace |
version | incremented on update — see How it works |
extends, extends_version | parent template + its pinned version |
identity_fields[] | composite key that makes two documents "the same"; [] = append-only |
header_fields[] | fields shown in compact peer/header projections (see Gotchas) |
usage | entity (default) | reference (reserved, currently behaves like entity) | relationship. Immutable after creation. |
source_templates[], target_templates[] | relationship-only allow-lists for the edge's two endpoints |
versioned | true (default, updates create new versions) | false (updates overwrite in place). Immutable after creation, and requires non-empty identity_fields |
fields[] | FieldDefinition list |
rules[] | ValidationRule list, cross-field |
metadata | {domain, category, tags[], custom} |
reporting | PostgreSQL sync config — see below |
status | draft | active | inactive |
(template.py; schemas/template-store.json → TemplateResponse, CreateTemplateRequest)
FieldDefinition — one entry per field:
| Property | Notes |
|---|---|
name, label, type | type ∈ string, number, integer, boolean, date, datetime, term, reference, file, object, array |
mandatory, default_value | |
terminology_ref | type=term (legacy — prefer reference) |
template_ref + template_ref_version | type=object; the pinned version is mandatory whenever template_ref is set — nested data validates against exactly that (template_id, version) pair, never "latest" |
reference_type, target_templates[], include_subtypes, target_terminologies[], version_strategy | type=reference; reference_type ∈ document, term, terminology, template; version_strategy ∈ latest, pinned; for an array of references the same slots live on the field itself and reference_type is mandatory |
file_config | type=file: {allowed_types[] (MIME patterns), max_size_mb (≤100), multiple, max_files} |
array_item_type, array_terminology_ref, array_template_ref (+ array_template_ref_version), array_file_config | type=array |
validation | {pattern, min_length, max_length, minimum, maximum, enum} |
semantic_type | email, url (string) · latitude, longitude, percentage (number) · duration, geo_point (object) |
full_text_indexed | string-only; builds a Postgres tsvector + GIN column for the reporting search endpoint; requires reporting.sync_enabled=true |
inherited, inherited_from | populated at inheritance-resolution time, not stored |
(field.py; schemas/template-store.json → FieldDefinition, FieldType, ReferenceType, VersionStrategy, SemanticType, FileFieldConfig)
ValidationRule — cross-field rule:
| Property | Notes |
|---|---|
type | conditional_required, conditional_value, mutual_exclusion, dependency, pattern, range |
conditions[] | {field, operator (equals/not_equals/in/not_in/exists/not_exists), value} |
target_field / target_fields[] | single target, or the set for mutual_exclusion |
required, allowed_values[], pattern, minimum, maximum, error_message | per-type payload |
(rule.py; schemas/template-store.json → ValidationRule, RuleType, Condition)
reporting (PostgreSQL sync config attached to a template): sync_enabled (default true), sync_strategy (latest_only upsert | all_versions insert-all), table_name (auto-generated from value if unset), include_metadata, flatten_arrays, max_array_elements (1–100, default 10) (template.py → ReportingConfig). The reporting sync's own service-side models (jobs, alerts, search) are a separate surface — see Reporting & Analytics.
Documents
| Document field | Notes |
|---|---|
document_id, namespace | document_id from the Registry (UUID7, time-ordered) |
template_id, template_version, template_value | which template (and exact version) validated this document |
identity_hash | SHA-256 over the resolved identity_fields values; "" when the template has no identity_fields — see the ⚠ GAP note under Rules & invariants |
version | per-identity version counter (see How it works) |
data | the document content, validated against the template's fields |
term_references[], references[], file_references[] | resolved term/reference/file fields, one entry per resolved field |
status | active | inactive | archived — see Gotchas |
is_latest_version, latest_version | whether this row is the newest version, and what that version number is |
metadata | {source_system, warnings[], custom} |
created_at/by, updated_at/by |
(document.py; schemas/document-store.json → DocumentResponse, DocumentCreateRequest, DocumentStatus, DocumentMetadata)
Two document-derived projections exist for consumers that don't want the full document shape:
PeerProjection— a compact view of the entity at the other end of a relationship edge (?include=peers):{document_id, namespace, template_id, template_value, status, data, metadata}, wheredata/metadataare populated per the peer template'sheader_fieldsresolution (see Gotchas). Inactive peers are still resolved and returned, not filtered out (document_store.services.document_service;schemas/document-store.json→PeerProjection).TableViewResponse— a flattened, paginated table projection of a template's documents:columns[]({name, label, type, is_array, is_flattened}),rows[],total_documentsvstotal_rows(arrays can multiply rows),array_handling∈flattened, json, none(schemas/document-store.json→TableViewResponse,TableColumn).
Files
A file is a first-class Registry-identified entity stored in MinIO and referenced by documents through file-typed fields, just like any other reference type (document_store/models/file.py). Fields: file_id (UUID7), namespace, filename, content_type, size_bytes, status ∈ orphan (uploaded, not yet referenced by an active document) | active | inactive, metadata {description, tags[], category, custom}.
Registry, namespaces & synonyms (shape only)
These entities live behind components/registry/src, outside this doc's source; the shapes below come from the wire schema only. Behavior belongs to Identity & the Registry and Namespaces & Tenancy.
| Namespace field | Notes |
|---|---|
prefix, description | e.g. dev, staging, prod |
isolation_mode | open (cross-namespace refs allowed) | strict (same-namespace only) |
allowed_external_refs[] | optional allowlist of external namespace prefixes (open mode) |
id_config | per-entity-type ID algorithm config; defaults to UUID7 for all |
deletion_mode | retain (soft-delete only, default) | full (allows hard-delete) |
status |
(schemas/registry.json → NamespaceResponse, NamespaceCreate)
| Registry entry field | Notes |
|---|---|
entry_id, namespace, entity_type | entity_type ∈ terminologies, terms, templates, documents, files |
primary_composite_key, primary_composite_key_hash | the entry's primary composite key and its hash |
synonyms[] | {namespace, entity_type, composite_key, composite_key_hash, source_info {system_id, endpoint_url}, created_at, created_by} — alternate keys that resolve to the same entry |
search_values[], metadata, status |
(schemas/registry.json → EntryDetailResponse, Synonym, SourceInfo)
Every entity in the model above (terminology, term, template, document, file) is Registry-identified: the Registry mints the canonical ID and any registered synonyms resolve to it in O(1) (wip://data-model → "Registry & Synonyms").
How it works
Document write (document_store/services/document_service.py):
ValidationServicevalidatesdataagainst the template's resolved fields, producingterm_references/references/file_referencesarrays for every term/reference/file-typed field.- The template's
identity_fieldsare extracted intoidentity_values; for a bulk write,identity_valuesis sent to the Registry, which mints or resolvesdocument_idand returnsidentity_hash(DocumentService.bulk_create, Stage 2). A dry-run validate (no Registry call) computes the SHA-256 hash locally instead (_result_to_validation_response). - Document-store looks for an existing active document at that
document_id:- none found → insert version 1.
- found, but
data+ the three reference arrays are byte-identical to the current version → no-op: returns the existing version,is_new=false, no new version, no event (_data_has_changed). - found, data changed, template
versioned=true(default) → deactivate the current version (status → inactive) and insertversion+1with the samedocument_id(_create_new_version). - found, data changed, template
versioned=false→ overwrite the existing row in place: samedocument_id, sameversion, freshdata/timestamps, no history kept (_overwrite_in_place).
- A template with empty
identity_fieldsis append-only: every POST is a new, standalone document (nothing to match against), and PATCH is rejected outright with error codeappend_only. PATCHapplies an RFC 7396 JSON Merge Patch to the currentdata, re-validates against the sametemplate_versionthe document was created with (not the latest active version — see Gotchas), rejects any attempt to change anidentity_fieldsvalue (identity_field_change— create a new document with POST instead), and supportsif_matchfor serialized concurrency (bulk_patchapply loop).
Template write (template_store/services/template_service.py):
create_templateenforces relationship-template shape whenusage="relationship": non-emptysource_templates/target_templates, and matchingsource_ref/target_reffields ofreference_type=documentwhose owntarget_templatesequal the template-level lists (_validate_relationship_template_shape). For any otherusage,source_templates/target_templatesmust be empty.update_templatenever edits a template in place — it inserts a new template document with the sametemplate_idandversion= (max existing version for thisvalue) + 1. Old versions are not deactivated, so several template versions can be active at once, letting existing documents keep validating against the version they were created with while new documents target the latest (update_templatedocstring: "Creates a NEW template document with incremented version... allows multiple versions to exist simultaneously"). If nothing changed (_template_has_changed), no new version is written.usage,versioned,source_templates,target_templatesare carried over unchanged from the original on every update — they are immutable after the template's first version.- Draft mode: create with
status="draft"to skip reference validation (enables circular or order-independent creation of interdependent templates).POST /templates/{id}/activatevalidates the whole reachable set of draft references as one unit and activates them all together — any failure activates none (activate_template). - Inheritance (
InheritanceService.resolve_template): a template withextendsmust also setextends_version— there is no "resolve to latest parent" fallback (a stored template without it is a data-integrity error, not a cue to float). Fields merge child-over-parent by name, each taggedinherited/inherited_from; rules concatenate with no dedup;identity_fieldsresolve from the nearest ancestor (walking child→root) that declares any. - Delete: soft-delete sets the version to
inactive; hard-delete additionally requires the owning namespace'sdeletion_mode="full". Either is blocked while any other template stillextendsthis one (delete_template).
Rules & invariants
- Identity & upsert.
identity_hashis SHA-256 over the template'sidentity_fieldsvalues. Writing the same identity again versions the document in place (or overwrites, forversioned=false) rather than creating an unrelated new record. Identity is hashed over the raw submitted value (not the resolved term), so identity-bearing term fields must be alias-free (canonical reading list, "identity fields / identity hash").⚠ GAP: the exact normalization performed before hashing (case handling, nested-path extraction, type coercion) is implemented in
libs/wip-auth/src/wip_auth/document_identity.py, which is outside this doc'ssource_scope(document_store/services/identity_service.pyis a thin delegate to it and states as much in its module docstring). This doc can confirm the algorithm is SHA-256 overidentity_fieldsvalues and that it is delegated to that module, not what the normalization function itself does byte-for-byte. versioned:falserequiresidentity_fields. An overwrite-in-place template must have an identity to address the row being overwritten; the combinationversioned:false+ emptyidentity_fieldsis rejected at both create and update (_validate_versioned_requires_identity).usageis immutable after creation.entity(default, full lifecycle) /reference(reserved) /relationship(typed edge: requiressource_templates/target_templates+source_ref/target_reffields) — set once, never changed.versionedis immutable after creation, thoughidentity_fieldsitself can still be edited later — which is why rule 2 above is re-checked on every update, not just create.metadata.*cannot carry structural meaning.identity_fieldsandfull_text_indexedmust reference top-leveldata.<field>names; ametadata.-prefixed entry in either slot is rejected.metadata.*is caller-attached context with no schema commitment (_validate_no_metadata_in_declarative_slots).full_text_indexedisstring-only and sync-gated. Any other fieldtypeis rejected; the flag additionally requires the template'sreporting.sync_enabled=true(_validate_full_text_indexed_constraints).- Nested/array template references pin an exact version.
template_refandarray_template_refboth require their matching*_versionfield — nested data always validates against one fixed(template_id, version)pair, never "latest". extendspinsextends_version. Inheritance never floats to the parent's latest version.- Template updates always version forward. No template version is ever mutated; an update is a new document with the same
template_idand a higherversion. Multiple versions can be simultaneously active. - Identity fields are immutable via PATCH. Changing an
identity_fieldsvalue through PATCH is rejected (identity_field_change); the caller must POST a new document. - A template that other templates
extendscannot be deleted (soft or hard) until those children are removed or repointed. - PoNIF #1 — inactive peers still resolve. The relationships/peer-projection machinery does not filter by document status: an edge whose peer document is
inactivestill returns that peer rather than erroring or omitting it. See PoNIFs.
Gotchas
header_fields: []does not mean "project nothing but identity." An explicitly empty list is indistinguishable from an absent one and falls through to theidentity_fieldsauto-include tier. To project identity fields only, list them explicitly inheader_fields— an explicit non-empty list is the only thing that wins outright over the auto-include (document_service.py, peer-projection header-fields resolution, three-tier fallback: explicitheader_fields→identity_fields(+title/doc_statusif the template declares them) →["title", "doc_status"]).- The
title/doc_statusauto-include only fires if the template actually declares those fields (inheritance-resolved) — a template without atitlefield never gets one synthesized into its peer projection. - A "no-op" write still returns 200 with
is_new=false. Bulk-first: every write returns 200 with per-item results: a byte-identical POST comes back asunchanged, not as an error and not as a new version. - PATCH validates against the document's pinned
template_version, not the latest. If that version was later deactivated, the PATCH fails withtemplate_inactiverather than silently upgrading the document onto a newer schema. inactiveis overloaded. The sameDocumentStatus.INACTIVEvalue is set both automatically (a version superseded by a newer one) and explicitly (a soft-deleted document) — the status alone doesn't tell you which happened;version/is_latest_versionand the audit trail do (document_service.py:_create_new_versionvsdelete_document).- Append-only templates (
identity_fields=[]) reject PATCH outright, with error codeappend_only— every change to that data is a brand-new document, never an edit.
See also
- Identity & the Registry — the full identity/synonym/merge lifecycle behind the Registry entities sketched above.
- Namespaces & Tenancy — isolation modes,
deletion_mode, and namespace lifecycle behind theNamespaceshape sketched above. - PoNIFs — the full catalogue of non-intuitive platform behaviours, including PoNIF #1 above.
