# Record sets: repeating records in products and sections

**Status:** current · **Verified:** 2026-10-31, `tools/mcpcheck/scenarios/record-sets.json` (REST steps by key)
and three storefront components built by key on a stand (§7)
**Owner:** Vizen API documentation · **Serves:** `GET /docs/record-sets` · **Skill:** `vizen-catalog`
**Related:** `/docs/characteristics` (definitions, access §3.3, templates §7) · `/docs/components` (reusable blocks)

## 1. What you can do here

Keep galleries, reviews, questions and answers, authors, team members or documents as structured records inside
a product or a section, and show them on the site. A record set is a characteristic of type `group`: the
definition holds the fields of one record, each product or section holds its own list of records. The site reads
them through one public endpoint, for many products at once, with only the fields it shows.

## 2. Decide first

| If you want… | Take this path | Cost |
|---|---|---|
| reviews, Q&A, a gallery per product or section | a record set (this document) | one characteristic per kind of list |
| a few fixed facts (material, width) | an ordinary characteristic, `/docs/characteristics` | — |
| a picture gallery of the product card itself | the product gallery (`/docs/catalog-import`) | not a record set |
| records rendered on the server, in the page source | not available: sets are not in the template scope (`/docs/vz-keys.json`) | report the need to the platform owner |
| records in your own block | a level-3 component reading `GET /record-sets` in the browser (§4.4) | text appears after JavaScript |

Records are never inherited: a product shows its own set, a section its own. A set has no default value and is
never a filter.

## 3. Objects and where they live

**Definition.** `POST /attributes` with `data_type: "group"`; the schema is `ui_settings.records`:

```jsonc
{"item": {"code": "faq", "name": "Questions", "data_type": "group",
 "ui_settings": {"records": {
   "fields": [
     {"code": "question", "name": {"ru": "Вопрос", "en": "Question"}, "type": "text", "required": true},
     {"code": "answer", "name": "Ответ", "type": "textarea", "required": true},
     {"code": "note", "name": "Заметка", "type": "text", "internal": true}],
   "columns": ["question"], "title": "{{question}}", "max_items": 50,
   "views": [{"code": "acc", "kind": "accordion", "default": true}]}}}}
```

- **Field types:** `text` (≤ 500 characters), `textarea` (≤ 10,000), `number` (`min`/`max`), `bool`, `select`
  (`options: [{value, label}]`), `date` (`YYYY-MM-DD`), `url` (`https://`, `http://` or `/…`), `image` and `file`
  (`{"file_id": "<uuid>"}`; `image` also takes `alt`, `file` takes `title`), `product` and `category` (an id of
  this shop). `max_len` narrows a text field. Unknown keys in the schema are an error.
- `columns` and `title` shape the row in the back office; `views` and `seo` are hints for the site, the back
  office does not edit them.
- **`internal: true`** — stored, returned by the source reads below, never by `/record-sets` to buyers.
- `is_filterable` → `400 RECORDS_NOT_FILTERABLE`; a default value → `400 RECORDS_NO_DEFAULT`; changing the type
  to or from `group` → `400 RECORDS_TYPE_LOCKED`.

**Values.** To change a few records, use `PATCH …/records/{code}` (§4.5): it touches only the records you name.
To replace a whole set: a product — `PUT /products/{id}/attributes`. A section: `PUT /categories/{id}/values` — every row also
needs `"state": "value"`:

```http
PUT /products/7/attributes
{"items": [{"attribute_id": 42, "value": [
  {"question": "Other colours?", "answer": "24 colours, 7 extra days."},
  {"_id": "q2", "question": "Assembly?", "answer": "Free in town.", "_active": false}]}]}

PUT /categories/9/values
{"items": [{"attribute_id": 42, "state": "value", "value": [{"question": "Delivery?", "answer": "Two days."}]}]}
```

- **A record set that is missing from the request keeps its records.** To delete a record set, send `[]` as its
  value.
- Ordinary characteristics are replaced: send their values in the same request, otherwise they are cleared
  (`/docs/characteristics`).
- When you change a record set with `PUT`, send the whole list of its records. Records that are missing from the
  new list are deleted.
- `POST /products/import` does not write record sets: a row with one fails. Import the products, then fill sets
  with `PATCH …/records/{code}` or `PUT`.
- The server gives each new record `_id` (8 characters) and `_created`; a changed record keeps `_created` and gets
  `_updated`. Your own `_id` (`^[a-z0-9]{1,16}$`) makes a retry idempotent. A record **without** `_id` is new: it
  gets a new id and date, so always send existing records back with their `_id`.
- An unchanged record is accepted as it is even after the schema changed; a new or changed record is checked
  against the current schema (a field removed from the schema must be dropped from a changed record).
- Files, products and sections must belong to the shop and be alive; a draft file only in its own draft. An
  `image` field takes a picture: MIME type or extension says so, or — right after the upload, while both are
  empty — a file created with `fileType: "image"`.

**Reading the source before you edit** (file ids, internal fields, switched-off records): one set —
`GET /products/{id}/records/{code}` or `GET /categories/{id}/records/{code}` (§4.5). All values — a product:
`GET /products/{id}/spec` (the template's characteristics plus the product's own sets outside the template); a
section: `GET /categories/{id}/values`.

## 4. Recipes

### 4.1 Open a set to buyers

A new characteristic is closed; `/record-sets` then answers without that set. Read `GET /attributes/{id}/access`,
then send the **whole** record with `PUT /attributes/{id}/access` and `expected_version` from the read — omitted
flags become `false`:

```json
{"expected_version": 3, "access": {"visibility": "public", "show_storefront_card": true,
 "show_storefront_list": true, "show_admin_product": true, "show_admin_list": true, "show_api": true}}
```

Needs `catalog:read`/`catalog:write` and an owner or administrator behind the key. `visibility: "audience"` limits
the set to an audience: `all` (everyone), `authenticated` (signed-in buyers) or `roles` (customer roles). Managing
audiences and roles is `customers:*` (sensitive, closed to a draft key) — a public component never needs it.

### 4.2 Read for the site — `GET /record-sets`

Public. A key without `catalog:read` still gets the anonymous answer; only `all=true` needs it. Call it on the
**API origin** (`api_base` from `GET /v1/account/token`), not on the storefront: the storefront has no
`/record-sets` proxy. Check from the storefront page that this origin is reachable for a visitor (CORS allows the
storefront, HTTPS on an HTTPS page); `127.0.0.1` works only on the same machine.

| Parameter | Meaning |
|---|---|
| `company_id` | the shop, required |
| `product_ids`, `category_ids` | comma-separated, at least one, ≤ 50 resources in total |
| `code` | sets, comma-separated, ≤ 10. **Always pass it**: without it every eligible set of the shop is taken, and a shop with more than 10 answers `400 INVALID_ARGUMENT` — even if the resource fills fewer |
| `fields` | fields to return (`_id` always comes); an internal or unknown field → `400 RECORDS_FIELD_UNKNOWN` (internal fields are allowed with `all=true`) |
| `item_ids` | exact records, one resource only |
| `offset`, `limit` | page; `limit` default 20, ≤ 200 |
| `sort` | `order` (back-office order), `<field>:asc|desc`, `_created:desc` |
| `filter` | `<field>:<value>`, exact, simple fields only |
| `stats` | number fields: `count`, `avg`, `min`, `max` over the filtered set (not just the page) |
| `schema=true` | add the field list (public fields; all fields with `all=true`) |
| `all=true` | staff or a key of this shop: switched-off records, internal fields and closed sets; still only resources a buyer may open; live catalogue even for a draft key; files as `url`. Never in public code, never a source to send back |

More than 10 resources need exactly one `code` and `limit` ≤ 20. The HTTP answer is `{"result": "<base64>"}`.
Decode `result` from base64 into bytes, decode the bytes as UTF-8, then parse the JSON. Do not use
`JSON.parse(atob(…))`: it breaks Cyrillic letters.

```js
const url = new URL('/record-sets', apiBase);
url.search = new URLSearchParams({company_id: '1', product_ids: '7', code: 'reviews',
  fields: 'author,rating,text', stats: 'rating', limit: '20'});
const res = await fetch(url, {credentials: 'omit'});
if (!res.ok) throw new Error('record sets ' + res.status);
const {result} = await res.json();
const bytes = Uint8Array.from(atob(result), (c) => c.charCodeAt(0));
const data = JSON.parse(new TextDecoder('utf-8').decode(bytes));
const set = data.resources.find((r) => r.kind === 'product' && r.id === 7)?.sets.find((s) => s.code === 'reviews');
```

```jsonc
{"resources": [{"kind": "product", "id": 7, "name": "Sofa", "url": "/sofas/island",
  "sets": [{"code": "reviews", "name": "Reviews", "total": 12, "offset": 0, "limit": 20, "next_offset": null,
    "view": {"code": "cards", "kind": "cards"}, "stats": {"rating": {"count": 12, "avg": 4.7, "min": 3, "max": 5}},
    "items": [{"_id": "q1", "author": "Ira", "rating": 5, "text": "Great sofa"}]}]}]}
```

- The answer has only what the buyer sees. It has published products and sections the buyer may open. It has
  records with `_active` and fields without `internal`. It has sets open to this buyer. A set for signed-in buyers
  or for a role shows only with **that buyer's** token. A merchant key or a staff session without `all=true` reads
  as an anonymous buyer, so it cannot prove a role set.
- `image`/`file` come as `{"url", "alt"|"title"}`; `product`/`category` as `{"id", "name", "url"}`, or `null` when
  the buyer cannot open it.
- Another shop's, hidden or unknown resources are absent without an error; a resource with no filled sets comes
  with `"sets": []`. A heavy request → `400 RECORDS_RESPONSE_TOO_LARGE`: narrow `code`, `fields` or `limit`.
- Read from the live catalogue: draft records show after publication.

### 4.3 Show only what you need

Q&A: list with `fields=question`, then one answer on click with `item_ids=<_id>&fields=answer`. Reviews of a
listing: `product_ids=1,2,…&code=reviews&limit=3`. Average rating: `stats=rating`. Pages: follow `next_offset`
until `null`.

### 4.4 A component backed by a record set

1. Read `GET /v1/account/token`: `capabilities.html_level3_allowed` must be true. A ZIP release needs
   `catalog:write` **and** `storage:write` plus owner/admin.
2. Define, fill and open the set (§3, §4.1).
3. Create `POST /html-documents` with `kind: "component", level: 3` (level 1 strips scripts). Package
   `index.html`, `component.json`, CSS and JS; declare parameters such as `company_id`, `resource_kind`,
   `resource_id`, `code`, `api_base` and a heading. Never put a merchant key into the folder or the insertion.
4. The script calls `/record-sets` as in §4.2 (public projection, `credentials: 'omit'`, never `all=true`), renders
   loading, error and empty states (an empty set hides the block), initialises each root once and survives soft
   navigation (`/docs/components` §5). A long set is loaded in pages: ask `limit=10`–`20`, show «Show more» while
   `next_offset` is not `null` and request `offset=<next_offset>`; never load 200 records for the first screen.
5. Upload the release (`POST /html-documents/{id}/releases/zip?publish=1` — `200` when activated, `201` without
   `publish`), re-read `active_release_id`, then mount the insertion on the page (`/docs/components` §4.7.1).
6. Verify on the storefront page in a browser: the network answer, the rendered text, mobile width, and that
   switched-off records and internal fields are absent. Do not claim server-rendered records or SEO.

### 4.5 Edit records by key — `GET` / `PATCH …/records/{code}`

One set of a product or a section, addressed by its `code`. `GET` returns every record as stored (switched-off
records, internal fields, `file_id`), the field list and a `revision`. `PATCH` applies operations to the current
set; other records, other sets and ordinary characteristics stay as they are.

```http
GET /products/7/records/faq            (a section: /categories/9/records/faq)
→ {"set": {"attribute_id": "42", "code": "faq", "revision": "3f2a…", "total": 2,
           "items": [{"_id": "q1", "question": "…", "answer": "…", "_active": true, "_created": "…"}, …],
           "schema": {"fields": […]}}}

PATCH /products/7/records/faq
{"ops": [
  {"op": "add",    "record": {"question": "Warranty?", "answer": "Two years."}, "position": 0},
  {"op": "update", "id": "q1", "set": {"answer": "Yes, 24 colours.", "note": null}},
  {"op": "remove", "id": "q2"},
  {"op": "move",   "id": "q1", "position": 0}]}
→ the same answer as GET, with the new records' `_id`

PATCH /products/7/records/faq        (refuse if someone changed the set since your GET)
{"expected_revision": "<revision from GET>", "ops": [{"op": "update", "id": "q1", "set": {"answer": "Updated"}}]}
```

- `add` puts the record at `position` (0 is first; default — last). `update` merges `set` into the record: `null`
  removes a field, `_active` switches it on or off. `move` needs `position`. Operations run in order.
- The result is checked like `PUT` (schema, files, links). An error names the record or the operation:
  `ops[1].id` → `404 RECORD_NOT_FOUND`, a bad operation → `400 RECORDS_OP_INVALID`.
- Parallel `PATCH` calls are safe: if the set changes while your request runs, the server re-reads it and applies
  your operations again (after 3 tries the answer is `409` too). A `PUT` of the whole set — the back office saves
  this way — still replaces it. Pass `expected_revision` from your `GET` to refuse instead: a changed set →
  `409 RECORDS_REVISION_CONFLICT` and no operation is applied; `GET` again and decide, do not just retry.
- A retry-safe `add`: put your own `_id` in `record`. A repeat then answers `400 RECORDS_OP_INVALID … duplicate
  _id` — the record is already there. Without `_id` a repeated `add` creates a second record.
- Removing the last record deletes the set (`revision: "0"`). A draft key reads and writes its own draft.
- Needs `catalog:read` for `GET`, `catalog:write` for `PATCH`. Another shop's or a deleted product →
  `404 PRODUCT_NOT_FOUND`, section → `404 CATEGORY_NOT_FOUND`, an unknown `code` → `404 ATTRIBUTE_NOT_FOUND`.

## 5. Silently ignored

- A set left out of `PUT …/attributes` or `PUT /categories/{id}/values` is kept, not cleared (by design).
- `_created`/`_updated` you send are ignored.
- In `PATCH …/records/{code}`: `id` in `add` (the record's own `_id` counts), `position` in `update` and `remove`,
  `record` outside `add` and `set` outside `update`.
- A closed set (no buyer access) is absent from `/record-sets` with `200`; asking for its fields by name is a `400`.
- A merchant key without `catalog:read` still gets the anonymous answer from `/record-sets`; it only cannot use
  `all=true`.
- `views` and `seo` are stored hints; nothing on the platform renders them by itself yet.

## 6. Limits

| What | Limit |
|---|---|
| fields per set | 30 |
| records per set | 200 (`max_items` narrows) |
| value size | 256 KB |
| `columns` / `views` / select `options` | 6 / 5 / 100 |
| `/record-sets` resources / codes / limit | 50 / 10 / 200 (more than 10 resources: one code, limit ≤ 20) |
| operations per `PATCH …/records/{code}` | 200 |
| linked products / sections resolved per answer | 200 / 50 |
| answer | `RECORDS_RESPONSE_TOO_LARGE` above 2 MB |

Errors: `RECORDS_SCHEMA_INVALID`, `RECORDS_VALUE_INVALID`, `RECORDS_TOO_MANY`, `RECORDS_TOO_LARGE` (with the field
address after the code, e.g. `faq[0].answer: required`), `RECORDS_NOT_FILTERABLE`, `RECORDS_NO_DEFAULT`,
`RECORDS_TYPE_LOCKED`, `RECORDS_FIELD_UNKNOWN`, `RECORDS_OP_INVALID`, `RECORD_NOT_FOUND`, `RECORDS_REVISION_CONFLICT`, `RECORDS_ALL_NEEDS_ROLE`, `RECORDS_RESPONSE_TOO_LARGE`,
`DUPLICATE_ATTRIBUTE_IN_SET`; another shop's section → `CATEGORY_NOT_FOUND`, its attribute → `UNKNOWN_ATTRIBUTE`.

## 7. How this was verified

2026-10-31 on an isolated stand built from `production`: `tools/mcpcheck/scenarios/record-sets.json` — 55 REST
steps by key (also three pages to `next_offset: null`, more than 10 resources with and without `code`, a draft key
reading its own draft; `GET`/`PATCH …/records/{code}`: add, update, a stale revision, an unknown id, read-only,
foreign-shop and draft keys, the ordinary value and the public answer after a `PATCH`) (schema errors, public/internal fields, one answer by `item_ids`, pages, `all=true` and its role
gate, read-only and foreign-shop keys, a draft key not changing the live answer, sets kept when omitted, `[]`
deleting). A buyer with a customer role saw a role-limited set; an anonymous buyer and a buyer without the role
did not. Three level-3 components (reviews with average rating, Q&A loading answers on click, gallery with a
lightbox) were built and mounted by key only, checked in Chromium at 390 px and desktop.
