--- title: Catalog object model description: Understand canonical catalog fields, typed identities, variant groups, category and brand relationships, and field visibility. slug: concepts/catalog-object-model docKind: concept hub: luigisbox-ai --- A catalog object is the canonical record Luigi's Box AI indexes after mapping a source feed or API request. Source field names and catalog field names are separate concerns. ## Registered fields Fields beginning with `@` come from a closed registry. Customers cannot define arbitrary `@` fields, and an unrecognized `@` field is rejected during ingestion. The currently supported registered fields are: | Field | Authority | Requirement | Purpose | |---|---|---|---| | `@id` | Client | Required | Stable typed identifier, such as `product/123`. | | `@type` | Client | Required | Object type. A product uses exactly `product`. | | `@title` | Client | Required | Human-readable object name. | | `@category` | Client | Optional | Product-to-category relationship using typed category IDs, such as `category/cat-t-shirts`. | | `@brand` | Client | Optional | Product-to-brand relationship using a typed brand ID. | | `@parent` | Client | Optional | The object's immediate parent, as a typed ID. Builds category trees — see [Hierarchies](#hierarchies). | | `@group_id` | Client | Optional | Non-empty string shared by all products in one variant group; omitted for standalone products. | | `@group_primary` | Client | Optional | Boolean set to `true` for the primary product in a group; omitted together with `@group_id` for standalone products. | | `@updated_at` | System | System-managed | Last update timestamp maintained by Luigi's Box AI. Do not send this field in client feeds or API requests. | A complete product object can combine registered `@` fields with ordinary public attributes: ```json { "@id": "product/sku-1001", "@type": "product", "@title": "Blue Cotton T-Shirt", "@category": ["category/cat-t-shirts"], "@brand": "brand/northwear", "@group_id": "northwear-t-shirt-1001", "@group_primary": true, "url": "https://example.com/products/blue-cotton-t-shirt", "image_url": "https://cdn.example.com/products/sku-1001.jpg", "price": 29.90, "list_price": 39.90, "availability": 1 } ``` Canonical object examples are shown as JSON because this is the post-mapping representation. XML, JSON, JSON Lines, and CSV are source feed formats; see the [feed type references](/indexing/feeds/#feed-types) for examples. ## Types and IDs Use the configured type exactly. A product object uses `product`. The source value used as identity must include the object type prefix: ```text product/123 ``` For a product, the prefix must be `product/` and must match the configured `@type`: ```json { "@id": "product/123" } ``` For example, `item/123` is rejected when the object type is `product`; it is not silently rewritten. :::caution[Keep IDs stable] An ID must identify the same catalog object for its entire lifetime. Changing or reusing an ID breaks the connection between the object, analytics events, ranking history, and recommendations. ::: ## Relationships Two registered fields link one object to another. Both hold the target's typed identity — never a copy of the target itself. `@category` connects a product to categories by typed category ID: ```json { "@id": "product/123", "@type": "product", "@title": "Blue Cotton T-Shirt", "@category": [ "category/shirts", "category/summer" ] } ``` The category objects use the corresponding IDs: ```json { "@id": "category/shirts", "@type": "category", "@title": "Shirts" } ``` The target category does not need to exist when the product is ingested. Product and category feeds can therefore be processed independently. `@brand` connects a product to a brand by typed brand ID: ```json { "@id": "product/123", "@type": "product", "@title": "Blue Cotton T-Shirt", "@brand": "brand/northwear" } ``` The brand object uses the corresponding ID: ```json { "@id": "brand/northwear", "@type": "brand", "@title": "Northwear" } ``` The target brand does not need to exist when the product is ingested. Product and brand feeds can therefore be processed independently. ### Hierarchies `@parent` expresses a category tree: each category names its **immediate** parent, and Luigi's Box assembles the tree from those edges. Do not send a whole path on a category. ```json { "@id": "category/clothing", "@type": "category", "@title": "Clothing" } ``` A child names its parent: ```json { "@id": "category/shirts", "@type": "category", "@title": "Shirts", "@parent": ["category/clothing"] } ``` And a grandchild names only its own parent, not the whole chain: ```json { "@id": "category/dress-shirts", "@type": "category", "@title": "Dress shirts", "@parent": ["category/shirts"] } ``` The rules: - **`@parent` is an array**, holding one typed identity — the object's immediate parent. - **A root category omits it**, or sends an empty array. - **The parent need not exist yet.** Like `@category` and `@brand`, the link is stored as sent; ordering between feeds does not matter. - **Send one edge per object.** Naming a grandparent as well has no defined resolution. - **It is not restricted to categories** — any object type may carry it. :::caution[Do not create a cycle] A parent chain that loops back on itself (`a → b → a`) is not detected at ingestion. Nothing rejects it, and what a hierarchy facet does with it is undefined. Validate the tree in your own export. ::: ## Variant groups Every variant is a complete product with its own stable `@id`. Products in the same variant group carry the same string in `@group_id`; the value is an opaque group identifier and does not need to be a UUID or the `@id` of another product. Use `@group_primary: true` for the primary product in a group. Both fields are optional. When a group has no primary product, set it to `false` on all members or omit it. A standalone product omits both fields. Catalog ingestion preserves the optional `@` fields exactly as sent; it does not add grouping defaults to the client-owned slots. In a partial update, omitting a field leaves its stored value unchanged; send `false` explicitly to demote an existing primary product. A partial update can change `@group_primary` without restating an unchanged group ID. Send `@group_id: null` to remove a product from its current group; its derived group ID then falls back to its own identity and its derived primary flag becomes `false`. Catalog ingestion validates each product independently and does not enforce uniqueness across a group. Clients are responsible for sending at most one primary product per group. Luigi's Box derives two search attributes for every product: `lbx:group_id` copies `@group_id` or falls back to the untyped identity part of the product's own `@id` (`product/prod_123` becomes `prod_123`), and `lbx:group_primary` copies `@group_primary` or falls back to `false`. The fields you supplied are stored unchanged. Every group member is stored independently as a complete product. Catalog ingestion does not copy attributes from a primary product onto its variants. ```json { "@id": "product/t-shirt-blue-m", "@type": "product", "@title": "Blue Cotton T-Shirt, M", "@group_id": "northwear-t-shirt-1001", "@group_primary": true, "color": "blue", "size": "M" } ``` `@group_id` must be a non-empty string and `@group_primary` must be a JSON boolean, not the strings `"true"` or `"false"`. ## Field namespaces An object is a flat set of fields. A field's **name** says who wrote it and who may read it, through a namespace: a prefix ending in a colon, in front of the field name. ```text color no prefix private:margin the private: namespace lbx:color the lbx: namespace ``` There are no per-field visibility settings. The prefix decides, so a field's name says what it is in a feed, in an API payload and in a discovery response. | Form | Example | Written by | Who can read it | |---|---|---|---| | No prefix | `color`, `price` | You | Everyone — including the browser | | `@` slot | `@id`, `@title`, `@category` | You (a closed set) | Everyone | | `private:` | `private:margin` | You | Server-side integrations only | | `lbx:` | `lbx:color`, `lbx:group_id` | Luigi's Box | Everyone | Three rules hold across all of them: - **One prefix, never two.** Namespaces do not stack: there is no `private:lbx:something`. - **`@` is not a namespace.** It marks a registered structural slot from a [closed set](#registered-fields), not a prefix you can extend. - **An unrecognized prefix is rejected at ingest.** A colon in a field name that is not a registered namespace fails the object rather than being stored as a literal name — so a field called `size:eu` is an error, not an attribute. ### What you can write You own the fields with no prefix, the registered `@` slots, and everything under `private:`. Luigi's Box owns the rest, and sending a field in one of its namespaces is rejected — including `lbx:` fields derived from what you sent, like [`lbx:group_id`](/discovery/variants/). ## How to structure your data **Send what you already have, under the names you already use.** Beyond the three required slots — `@id`, `@type`, `@title` — there is no prescribed field list. Your attributes keep your names, in your language: ```json { "@id": "product/sku-1001", "@type": "product", "@title": "Blue Cotton Dress Shirt", "@category": ["category/dress-shirts"], "@brand": "brand/northwear", "url": "https://example.com/p/blue-cotton-dress-shirt", "image_url": "https://cdn.example.com/sku-1001.jpg", "price": 29.9, "list_price": 39.9, "availability": 1, "color": "blue", "size": "M", "material": "cotton", "sleeve_length": "long", "collar": "spread", "care": ["machine wash 30", "iron medium"], "private:margin": 8.4 } ``` `color`, `sleeve_length` and `collar` are not predefined fields. They are attributes this shop has, and they are searchable because they were sent. ### What to include Include an attribute if you can answer yes to any of these: | Ask | Example | |---|---| | Would a shopper filter or facet on it? | `color`, `size`, `material`, `availability` | | Would a shopper sort by it? | `price`, `rating`, `created_at` | | Does the storefront display it? | `title`, `image_url`, `url`, `list_price` | | Does it describe what the thing *is*? | `description`, `brand`, `params`, specifications | Ranking works from the text and attributes you provide. Descriptions, specifications and category placement are what let a search for "long sleeve cotton office shirt" find the right product. Leave out presentation markup, internal keys nobody will query, and anything personal. ### What matters more than completeness - **Consistent names.** The same attribute keeps the same name across every record and every export. `color` in one product and `colour` in another are two attributes. - **Consistent types.** The first value a field receives fixes its type for the catalog, and a later conflicting type is rejected rather than coerced. `29.9` is a number; `"29.90"` is text, and a text price can never be range-filtered or shown as a price slider. See [Catalog metadata](/indexing/catalog-metadata/). - **Omit rather than fake.** A missing value should be absent, not `""`, `0` or `"N/A"` — those become real values that show up in facets and match filters. ### Nesting Objects are allowed, and nested fields are addressed by dot-path in filters, facets and field projections: ```json { "params": { "material": "cotton", "fit": "regular" } } ``` That field is `params.material` in filters, facets and field projections. Prefer flat attributes unless your source data is nested. ### Keeping data out of the browser Fields with no namespace are **public**: they are eligible to appear in browser-facing discovery responses, depending on the surface configuration. Because browser tokens and their requests are visible in developer tools, a public field must not contain anything a storefront visitor should not be able to read. Use the `private:` namespace for anything that must reach only backend integrations authenticated with a machine-to-machine token — cost, margin, supplier codes, internal notes: ```json { "color": "blue", "price": 29.9, "private:margin": 8.4 } ``` Private fields are never returned to a request authenticated with a browser token. Keep machine-to-machine credentials secret; leaking those credentials can expose private fields. Which fields a given request actually receives also depends on what it asks for — see [Selecting fields](/discovery/fields/). ## See also - [Object types and relationships](/concepts/object-types/) — what a catalog can hold - [Selecting fields](/discovery/fields/) — which fields a discovery request receives - [Feeds overview](/indexing/feeds/) - [Product feeds](/indexing/feeds/products/) - [Category feeds](/indexing/feeds/categories/) - [Feed troubleshooting](/indexing/feeds/troubleshooting/)