Server-Client Metadata Contract
VUEDA's client generates its UI entirely from server-provided metadata. The contract between the two sides is not a formal schema negotiation or a versioned wire protocol. It is a set of structural conventions: the server produces metadata in a predictable shape, and the client consumes it with specific expectations about what is present, what is absent, and what is permission-sensitive. When those expectations are met, the client can generate routes, forms, views, and Action buttons without per-model wiring. When they are violated, the failures are specific and diagnosable.
This page describes the metadata contract, where each section originates, how permissions shape visibility, how the client normalizes and caches metadata, and the compatibility guarantees across releases. For where the contract sits within the broader architecture, see Architecture Overview. For the practical steps of registering a model so it participates in this contract, see Create a CRUDL Surface.
Why This Contract Exists
The alternative to a metadata contract is per-model client code: hand-written route definitions, form field lists, action buttons, and permission checks for every model in the application. That approach scales linearly with the number of models but breaks whenever a field is added, renamed, or removed.
The metadata contract eliminates that coupling. The server describes its own shape (fields, actions, filters, ordering, permissions, expand capabilities), and the client reads that description at runtime to generate everything it needs. Adding a field to a serializer makes it appear in forms and tables without a client code change. Removing a permission hides the corresponding action button without a client code change. The contract is the mechanism that makes convention-over-configuration work across the network boundary.
The contract is also the mechanism that keeps the server as the sole authorization boundary. The client uses metadata to make UX decisions (hiding buttons, disabling fields), but it treats those decisions as advisory. The server enforces permissions independently on every request. The metadata contract provides the client with enough information to build a good user experience without granting it authority.
Metadata Sections
Metadata shape is derived from serializers and viewsets, not from database tables. This is a foundational distinction. A model can have database columns that never appear in metadata because they are not in the serializer's fields list. Conversely, a serializer can declare computed or method fields that have no database column. The serializer definition is the canonical field contract.
The metadata sections have different source authorities:
model_fields comes from the canonical registered serializer. Each field entry carries its label, type information (database type, model field class, serializer field class), read/write status, required flag, constraint metadata (min/max values, lengths, decimal precision), and a many flag indicating whether the field returns multiple values. Choice fields include either static choice lists or a pointer to the choices endpoint for relational fields. Fields may also include display_choices, which are read-only display labels for stored values and do not change validation or editable widget choice behavior. The PK field is marked with a pk: true flag, which the client requires for object identity. For models with a composite primary key, field metadata derivation retrieves the primary key field from model._meta rather than from a pk attribute on the model class, because composite primary key models do not expose a pk class attribute.
type_db/type_model are resolved through the serializer field's source (a dotted path such as author.name walks the named relation to its terminal field), not through the serializer field's own name. This walks source exactly as DRF resolves it at runtime — split only on ., never reinterpreting a literal __ as a relation traversal — and an intermediate segment that only matches a foreign key's scalar *_id attname (rather than its relation name) is not treated as traversable, since DRF's own attribute lookup would return the raw column value there, not the related object, so a further segment would not actually be reachable. A traversing formatted_name_lookup_expression such as "state__name" is resolved separately, through Django's own query-expression semantics (the same mechanism annotate_formatted_name uses to feed it to models.F()), so it also recognizes a transform, not just a relation — "when__year" correctly reports the type behind ExtractYear, for example. Both type_db/type_model are null for a field whose source does not resolve to a concrete model field, and the payload carries no key distinguishing why: /info/ stays tolerant either way, an unresolved source never raises, it just reports null. That null is an overloaded signal. A field bound to the whole object (field.source == "*") is never model-backed, by design, and reports null without comment — SerializerMethodField forces this, and a plain field that computes its own value from the instance can set it explicitly the same way (ObjectRevisionField, AvailableActionsField, and AvailableTransitionField all do, since each overrides get_attribute() outright and never reads source at runtime). So does a field whose model defines a matching get_<field_name>() method — the same convention formatted_name's get_formatted_name() establishes, for a model that opts out of a generated column in favor of a plain method. Absent either opt-out, a source that fails to resolve surfaces through the vueda_info.W001 system-check warning (see Failure Modes and Recovery below) at manage.py check time, rather than requiring anyone to notice the null at request time — whether source was set explicitly or left to DRF's default (the field's own name), and whether the failure is at its very first segment or partway through a relation — unless get_field_model_info (see Customization Hooks below) already fills in a non-null type_db/type_model for that field, in which case the check treats the field as described rather than unresolved and does not warn. A <field>_lookup_expression failure always surfaces that way too, even at its first segment, and get_field_model_info cannot silence it: unlike source, it has no legitimate non-model-backed reading, since it is fed straight to models.F() for queryset annotation and to Django admin's lookup_field(), both of which resolve purely at the database level — describing the field's metadata doesn't change what those two runtime consumers will do with a broken expression.
model_actions comes from the canonical registered viewset. Standard CRUDL actions (list, retrieve, create, update, partial_update, destroy) are included when the viewset supports them. Extra actions (defined with @action decorators) are included when get_allowed_extra_actions permits them. Each action entry carries a name, description, HTTP methods, boolean flags for detail (operates on a single object) and bulk (operates on multiple objects), and required parameters (for example, pks to identify the target objects for bulk operations).
model_ordering is an object with two keys: default, the field names DRF actually orders by when a request omits the ?o= param (the viewset's own ordering when declared, otherwise the model's Meta.ordering), and fields, the fields a client may explicitly order by via ?o= — derived from the viewset's ordering_fields (or DRF's own default source-based resolution when ordering_fields isn't declared), each carrying a semantic type (alpha, numeric, boolean, date, datetime, time) derived from the field's database type. Every field named in default also appears in fields, carrying an ascending key for its direction; a field in fields that isn't part of the default carries no ascending key. Both keys hold real model field names a client may send back in ?o=: a server-side "pk" ordering term is reported as the field it aliases (or, for a CompositePrimaryKey, every field the key is built from), a term built from a scalar database function such as Lower("name") is reported as the field it reads, and default is empty rather than partial when one of its terms can't be reported under a single field name. fields is the broader of the two: it covers each column of a multi-column default term and each queryset annotation ordering_fields offers, even where default has nothing to say about them. It is what a client may request, not an exhaustive list of what the server accepts — the backend also takes the literal "pk" it never advertises, and an annotation named only in the default ordering, which reaches neither key. See Filtering and Ordering Semantics for the full projection rules, including the __all__ shorthand, database functions in a default ordering, and the serializer-only-registration case.
model_filtering comes from the viewset's filterset_class. Each filter entry carries its label, type information (database type, model field class, filter class), lookup expressions, required flag, a hidden flag indicating whether the filter is shown in the UI, and choice metadata. Like field choices, filter choices for relational fields point to a dedicated endpoint rather than inlining all values; so do value-derived filters (AllValuesFilter, AllValuesMultipleFilter), whose options come from the values a column currently holds and so change over time. Entries carry additional optional properties when applicable: constraint metadata (min/max values, decimal places, digit and length limits), input behavior (input type, input formats, suffixes for range filters), validation (error messages, help text, validators), and null/empty handling (null_label, null_value, empty_label, empty_value).
model_expands comes from the serializer's generated expand descriptors (see Customization Hooks below). Each entry describes a relation that can be embedded inline in API responses when requested via the e query parameter. Each entry carries the relation name, the related model's app_label and model name, a many flag indicating whether the relation returns multiple objects, a read_only flag, and field metadata for the related serializer's fields. The field metadata is produced by the same function as model_fields and follows the same per-field structure.
model_permissions comes from Django's permission framework for the model's content type. This is a list of permission codenames and display names, and it is not filtered by the requesting user. For most canonical serializers it reflects the full content-type permission catalogue. When the canonical serializer subclasses VuedaReadonlySerializer, it contains only the mapped list and read permissions because that serializer exposes no write surface. This is discovery metadata filtering only. Other permission rows remain in the database, and server authorization remains authoritative.
A model must be registered to appear as a top-level entry in this contract. Registration is the discoverability boundary described in Canonical Registration and Model Discovery. Without registration, the model has no model_info entry and the client cannot discover it independently. However, an unregistered model can still contribute field metadata if it is referenced as an expandable field on a registered model's serializer; in that case, its field data appears within the parent model's model_expands section.
Customization Hooks
model_fields and model_expands generation both follow a two-layer pattern: an internal generation step builds the metadata from what the serializer can determine mechanically, and a public hook lets the serializer author correct or extend the result afterward. This split exists because a SerializerMethodField (whether declared directly on the serializer or listed in Meta.expandable_fields) has no model column and no fixed field class, so the generation step can only guess at its shape.
For model_fields, ModelInfoSerializer.get_model_fields_data inspects the canonical serializer's fields (via serializer().get_fields()) and builds the per-field metadata dict, including its best-effort guess for method fields. ModelInfoSerializer.get_model_fields then passes that dict through the canonical serializer instance's get_field_model_info(fields) and returns the result. VuedaExpandableFieldsSerializerMixin.get_field_model_info applies the serializer's field_display_choices mapping by default; serializer authors override it to patch entries for method fields whose generated metadata doesn't match what they actually return. Overrides should call super().get_field_model_info(fields) when they also rely on field_display_choices. See Customize Model Info Field and Expand Metadata for usage.
get_field_model_info has a second caller besides /info/ itself: the vueda_info.W001 system check (see Failure Modes and Recovery below) runs it too, on its own independently-built copy of the fields dict, before deciding whether an unresolved source= is worth a warning. This is why overriding get_field_model_info to describe a source= failure's real type also silences the check for that field — the check treats "has get_field_model_info filled in a non-null type_db/type_model" as equivalent to "resolved," not just as a payload patch applied after the fact.
For model_expands, VuedaExpandableFieldsSerializerMixin.generate_expand_model_info walks Meta.expandable_fields and builds one descriptor per entry, including nested field metadata (via get_model_fields_data) when the entry is a real serializer class with a Meta.model. ModelInfoSerializer.get_model_expands calls generate_expand_model_info() and then passes the result through the canonical serializer instance's get_expand_model_info(expands). VuedaExpandableFieldsSerializerMixin.get_expand_model_info is a no-op by default; serializer authors override it (without calling super(), since the default has nothing to add) to patch descriptors for SerializerMethodField-backed expandable_fields entries.
generate_expand_model_info is the internal generation chain and is not the intended customization surface; serializer authors should override get_expand_model_info and get_field_model_info instead, which is why the guide linked above only documents those two.
All three — generate_expand_model_info, get_expand_model_info, and get_field_model_info — are defined on VuedaExpandableFieldsSerializerMixin, which VuedaSerializer includes. Neither is required by Canonical Registration; only Meta.model is. ModelInfoSerializer checks for each hook with getattr before calling it, so a canonical serializer that doesn't inherit the mixin still produces model_fields (from the generic get_model_fields_data inspection) but reports an empty model_expands, even if it declares Meta.expandable_fields, and offers neither hook to override. If you register a serializer that doesn't inherit VuedaSerializer and want it to report model_expands, inherit VuedaExpandableFieldsSerializerMixin directly.
Response Structure
The Model Info endpoint (Get model info) returns all metadata sections in a single response. The sections are expandable: the client explicitly requests them via the fields and expand query parameters. In practice, the client always requests all sections together.
The base response always includes id (content type PK), app_label, model, verbose_name, and verbose_name_plural. The expanded sections (model_fields, model_actions, model_ordering, model_filtering, model_expands, model_permissions) are only present when requested.
Each section is self-contained. model_fields describes field shapes independently of model_filtering, even though some fields are also filterable. model_actions describes available operations independently of model_permissions, even though action visibility depends on permissions. The sections are not cross-referenced in the response; the client is responsible for correlating them (for example, matching filter names to field names, or checking whether an action's required permission is in the permission list).
The model_actions section sorts its entries in a stable order: standard CRUDL actions alphabetically, followed by extra actions alphabetically. This ordering is cosmetic and does not imply priority.
Permission-Sensitive Behaviour
Action metadata is filtered by the requesting user's permissions. When a user requests model-info, the server evaluates each standard action by constructing a synthetic request with the user's identity and the action's HTTP method, then running the viewset's permission checks. If the check raises PermissionDenied or Http404, the action is excluded from the response. Extra actions are filtered through get_allowed_extra_actions if the viewset defines it.
This means two users requesting model-info for the same model may receive different model_actions lists. A user with only read and list permissions will see list and retrieve actions; a user with full CRUDL permissions will see all five standard actions. In both cases, any extra actions permitted for the requesting user are included on top of the standard ones.
Navigation is gated by the action list. The client requireModelInfo route guard reads the cached model_actions list before allowing navigation to a model route. If the target action is not in the list — because the server excluded it during permission filtering — the guard blocks navigation. This means a user who lacks permission for an action cannot reach that route even by constructing the URL manually. The server remains the authority; the route guard uses the metadata to prevent the attempt before it reaches the API layer.
Object-level action availability is separate from model-level. The model-info action list reflects model-level permission checks. Individual objects expose their own available_actions field through the serializer (via AvailableActionsField), which reflects object-level permission checks. A user might have model-level update permission but lack object-level permission for a specific object due to row-level access rules or workflow state. The model-info contract advertises what is structurally possible; the object-level field reflects what is currently permitted.
Choices and Filter-Choices Contract
Field choices and filter choices are served by dedicated endpoints rather than being inlined in the main model-info response. This separation exists because choice lists can be large (thousands of related objects), are not always needed, and need to be paginated rather than returned in a single unbounded response.
Field choices are served at List field choices. For static choice fields (CharField with choices), the endpoint returns [{label, value}] pairs sorted by label. For relational fields (ForeignKey, ManyToMany), the endpoint queries the related model's queryset, annotates each object with a label (derived from formatted_name) and a value (the PK cast to string), and returns the results sorted by label. For static choice fields, the [{label, value}] pairs are already included inline in the model-info field and expand metadata, so calling this endpoint for them is unnecessary, though it will still work and return the same data.
Filter choices are served at List filterset field choices. The endpoint operates on filterset field definitions and omits empty-valued options. For filters, clearing the selection is represented by omitting the filter query parameter, not by selecting an empty choice. The primary use case for this endpoint is dynamic search: the client passes the user's input as a query parameter, and the endpoint returns a filtered subset of choices suitable for updating a dropdown as the user types. Filters that use startswith or contains lookup expressions work particularly well here, since they narrow results progressively with each keystroke.
Permission checks differ between choice types. Requesting choices for any field on a model requires read permission for that model. Requesting choices for a relational field additionally requires list permission for the related model. If either check fails, the endpoint returns a 403 status code. This means a user who can view a model's form may not be able to load choice options for a related field if they lack permission on the related model.
Choice values are normalized to strings in the response. Even for integer PKs, the value field in the response is a string representation. The client handles this normalization transparently.
Client Normalization and Caching Rules
The client does not consume the server response as-is. storeModelInfo applies several normalization steps before storing the data.
Prefix stripping. The model_ prefix is removed from all section keys. model_fields becomes fields, model_actions becomes actions, and so on.
Key renaming. expands is renamed to expand to match the client's singular convention.
Case conversion. Snake_case keys throughout the response are converted to camelCase, with two exceptions: field names (the keys in the fields dict) and filter names (the keys in the filtering dict) are preserved as-is. This means a field named first_name on the server remains first_name as a field key in the client store, but its metadata properties (readOnly, helpText, etc.) are camelCased.
PK detection. After normalization, the store scans the fields object for an entry with pk: true and records the field name as data.pk. If no PK field is found, the store throws an error. Every registered model's serializer must include a PK field in its fields list, or the client will fail on metadata fetch.
Caching on success. Normalized metadata is stored by {app}.{model} key. Subsequent requests for the same model return the cached data immediately without a server round-trip. The cache persists for the lifetime of the SPA session.
Caching on failure. If a metadata fetch fails, the error is stored in the cache by the same key. Subsequent requests for the same model are immediately rejected with the cached error without retrying the server. This prevents a failing model from generating repeated requests (self-DDoS), but it also means that transient failures require a page reload to retry. There is no automatic retry or cache expiration for errors.
Request deduplication. If multiple components request metadata for the same model simultaneously, only one server request is made. All callers receive the same promise. The in-flight promise is cleaned up after resolution, regardless of the outcome.
Compatibility Expectations
There is no metadata for wire version or schema negotiation between the server and the client. The server does not advertise a metadata version, and the client does not request a specific version. Compatibility depends on both sides preserving the same structural conventions across releases.
Metadata shape stability. The top-level section names, the field metadata properties, and the action entry structure are treated as stable interfaces. Adding new properties to existing sections is backward-compatible; the client ignores properties it does not recognize. Removing or renaming existing properties is a breaking change that requires coordinated updates.
Action name compatibility. The server uses DRF canonical action names (retrieve, partial_update, destroy). The client maps a small set of UI-friendly route names to these canonical names through a static alias table. Currently, the only alias is read to retrieve. All other action names pass through unchanged. This means the client can use read in route paths while the server advertises retrieve in metadata, and the mapping is handled automatically.
Serializer-only registration is a reduced contract. A model registered with register_serializer (no viewset) produces metadata with model_fields, model_expands, and model_permissions, but model_actions and model_filtering are empty; filtering has no meaning without a viewset's filterset_class to declare it. model_ordering is the exception: default and fields still reflect the model's own Meta.ordering, since that comes from the model rather than the viewset. The client can discover the model and its field schema, but cannot generate standalone routes for it because there are no actions to gate navigation. This is the expected contract for models referenced via expands or choices that do not have their own CRUDL surface. When a serializer-only model is used as an expandable inline on a registered parent model, the parent's form renders the inline fields correctly using the field metadata from the serializer-only registration.
This reduced contract assumes the canonical serializer inherits VuedaSerializer. A serializer-only registration that doesn't, will still produce model_fields and model_permissions, but an empty model_expands unless it also inherits VuedaExpandableFieldsSerializerMixin — see Customization Hooks above.
This reduced contract assumes the canonical serializer inherits VuedaSerializer. A serializer-only registration that doesn't, will still produce model_fields and model_permissions, but an empty model_expands unless it also inherits VuedaExpandableFieldsSerializerMixin — see Customization Hooks above.
Failure Modes and Recovery
Unregistered model returns 404. Requesting model-info for a model that is not in the registration registry returns a 404. The client wraps this in a ModelInfoError, displays a "Model Not Found" toast, and redirects to the configured actionRedirect target.
Missing PK field is a client error. If the server response does not include a field with pk: true, storeModelInfo throws during normalization. This is a configuration error: the serializer's fields list must include the model's primary key field. The error message identifies the model, but the symptom is a failed metadata fetch that gets cached as an error.
Cached fetch failures block retry. Because failed fetches are cached, a transient server error (network timeout, deployment in progress) will block all subsequent requests for that model until the user reloads the page. This is a deliberate tradeoff: preventing request storms is prioritized over automatic recovery. If the failure is permanent (e.g., an unregistered model or a misconfigured serializer), the cache behaviour is correct.
Action naming drift. If the server introduces a non-standard action name that collides with a client alias (for example, a custom action literally named read), the client's normalization will map it to retrieve, which may not match the server's intent. In practice, custom actions should avoid names that overlap with standard CRUDL action names.
Choice endpoint failures. Choice endpoints can fail if the model's formatted_name resolution is misconfigured. If the model sets formatted_name = None without providing formatted_name_lookup_expression or get_formatted_name(), the choice endpoint returns a 500 when it attempts to annotate a non-existent field. A system check (vueda_info.E001) detects this misconfiguration at startup, so the problem is reported before any choice endpoint is first requested. See Create a CRUDL Surface for formatted_name configuration strategies.
Unresolved field source or lookup expression. The vueda_info.W001 system check reports, for each registered serializer, a field whose source or model <field>_lookup_expression fails to resolve to a model field. It names the serializer, the field, and the path that failed. Unlike vueda_info.E001, this is advisory: it never blocks manage.py check, a management command, or /info/ from completing, since the corresponding field simply reports null for type_db/type_model.
A source= failure can avoid this warning three ways, and only three ways — never merely because source happened to default rather than being set explicitly. The first two are structural opt-outs, checked before the field is even resolved: a field bound to the whole object (field.source == "*" — SerializerMethodField forces this, and ObjectRevisionField, AvailableActionsField, and AvailableTransitionField set it explicitly, since each overrides get_attribute() outright and never reads source at runtime), or a model defining a callable get_<field_name>() (the same convention formatted_name's get_formatted_name() already establishes: a model may opt out of a generated column, e.g. formatted_name = None, in favor of a plain method, and that method is exactly as legitimate a source of the value as a real column). The third is a correction applied after the fact: the check builds the same model_fields metadata dict /info/ would return, including whatever the canonical serializer's get_field_model_info corrects, and only warns if type_db/type_model are still null once that correction is applied. Absent all three, a source is reported whenever it fails to resolve, whether it was set explicitly or left to DRF's default (equal to the field's own name) — a field name that fails to resolve is exactly as likely to be a typo or a stale rename as an explicit source= that fails partway through a relation, and DRF's default gives no way to tell the difference from the outside.
A <field>_lookup_expression is always reported when it fails to resolve, including at its first segment, and none of the three source= escapes apply to it: it is fed directly to models.F() for queryset annotation (annotate_formatted_name in vueda.core.models) and to Django admin's lookup_field(), both of which resolve purely at the database level and can never reach a @property or method — any failure is a real misconfiguration that will also break whatever annotates or looks up the value it names, and describing the field's metadata with get_field_model_info does not change that.
Correct a flagged field's metadata with get_field_model_info (see Customize Model Info Field and Expand Metadata) when a source= failure is genuinely not model-backed and neither structural opt-out applies — doing so both fills in the real type_db/type_model in /info/ and silences the check for that field, since the check treats a described field the same as a resolved one. This option only ever applies to a source= failure, never to a <field>_lookup_expression failure. Otherwise, fix the field's source=/the model's lookup expression if the break was unintentional.
Serializer-only registration blocks navigation. A serializer-only registration produces an empty model_actions list. The requireModelInfo route guard computes an empty allowlist and blocks all navigations to the model with "Action Not Found" toasts. This is correct behaviour for models that are not intended to have their own CRUDL surface, but it can be confusing if the registration was intended to be full. Verify that register (not register_serializer) was called with both a serializer and a viewset. Models registered with only a serializer that are rendered as expandable inlines on a parent model are expected to block standalone navigation; the inline fields are still rendered correctly through the parent's expand metadata.
Generated API docs are pointers, not the full contract. The generated API reference pages document endpoint signatures and field types, but they do not capture all behavioral nuances (permission sensitivity, choice resolution, error shapes). When behaviour questions arise, validate against the source code and tests rather than relying solely on generated docs.