# Characteristics: definitions, groups, templates, values and filters

**Status:** current · **Verified:** 2026-10-27, live requests with a `catalog:*` key against a stand built from
`production` (§10) and the handlers named in each section
**Owner:** Vizen API documentation · **Serves:** `GET /docs/characteristics` · **Skill:** `vizen-catalog`
**Related:** `/docs/catalogue` §8.3 (who sees a characteristic) · `/docs/catalog-import` (filling products, variants)

## 1. What you can do here

Describe what a shop sells in structured fields — material, width, power, warranty — instead of free text, and
decide where every field shows up: the product card, listing cards, the filter panel of a section, the
comparison table, variant switchers. Everything below is open to a key with `catalog:read` / `catalog:write`;
the one exception is a template's price layout (§7.4, `pricing:*`).

A characteristic is set up once per shop and then used in four independent places. Most failed set-ups miss
one of them, get a `200` everywhere and see nothing on the storefront:

```
definition (/attributes) ── options (select/multiselect)
   │            └─ access: who sees it, on which surface (/attributes/{id}/access) ← new = hidden
   ├─ value on a product            PUT /products/{id}/attributes · import "attributes"
   ├─ binding to ONE section        PUT /categories/{id}/attributes     → card order + filter panel (§6)
   └─ row of a template ─ group     /char-groups · /spec-templates/{id}/items
          └─ template bound to a section, inherited down the tree  PUT /categories/{id}/spec-settings
                └─ values of a section ("model")                  PUT /categories/{id}/values
```

## 2. Decide first

| You want… | Take this | Cost / catch |
|---|---|---|
| a field on the product card | definition → values on products → access `show_storefront_card` | card groups by `section` text, not by groups (§3.4) |
| a filter in one section | bind the definition to that section + `is_filterable` + access `show_storefront_list` | not inherited by subsections (§6.1) |
| the same filters for a whole branch of sections | a template with `filterable_here` rows bound to the branch root | replaces the section's own filter list (§6.2) |
| one value shared by every product of a section ("model": engine, size chart) | a template on the section + `PUT /categories/{id}/values` | buyers see it only in model blocks — **not on the regular product card, not in comparison** (§8) |
| a buyer picks a combination on the card | variant axis — a `select` characteristic | `/docs/catalog-import` §4–§5, §9 here |
| a column in the comparison table | the characteristic + its access + `/compare/settings` | `/docs/catalogue` §8.2 |

## 3. The definition — `/attributes`

| Call | Scope | Notes |
|---|---|---|
| `GET /attributes` | `catalog:read` | `result` is an **array** of definitions with their options |
| `POST /attributes` | `catalog:write` | `{"item": {…}}` |
| `PUT /attributes/{id}` | `catalog:write` | partial: a field you leave out is kept; `options`, when sent, replace the whole list (§4) |
| `DELETE /attributes/{id}` | `catalog:write` | soft delete (`deleted_after` +30 days); send `{}` as the body if your client sets a JSON content type. Cards drop it at once (§3.2) |

```http
POST /attributes
{ "item": { "code": "width", "name": "Ширина", "data_type": "number", "unit": "см",
            "section": "Размеры", "is_filterable": true, "sort_order": 10 } }
```

| Field | Meaning |
|---|---|
| `code` | machine key: `^[a-z][a-z0-9_-]{0,63}$` (`INVALID_CODE`), unique in the shop (`ATTRIBUTE_CODE_TAKEN`). The import, filters and templates address a characteristic by it |
| `name` | what the buyer reads, any language |
| `data_type` | §3.1; anything else → `INVALID_DATA_TYPE` |
| `unit` | appended to numbers on display: `12.5` + `кг` → `12.5 кг` |
| `section` | the heading the card groups it under (§3.4); empty → `characteristics` |
| `widget` | how a **switcher** draws the values: `text` · `select` · `icon` · `swatch` · `photo`, or empty; anything else → `INVALID_WIDGET` |
| `ui_settings` | switcher chips: `chip_size` `s`/`m`/`l`, `grid_cols` 0/2–6, `chip_w`/`chip_h` (`48px`, `8–512px` or `1–100%`), `select_style` `glyph`/`plain`; unknown keys are dropped, a bad value is rejected |
| `is_filterable` | allows the plain filter panel of a section to use it (§6.1) |
| `default_value` | used only when a template resolves values (§8), never written into products |
| `is_required`, `validation` | **stored, not enforced**: a product without the value and a value breaking `validation` both save with `200`. Check them yourself |
| `sort_order` | order on the card, unless a binding to the product's section sets its own (§3.4) |

### 3.1 Types — what to send and what the buyer sees

Measured on one product, every type written through `PUT /products/{id}/attributes`:

| `data_type` | Send as `value` | Card shows (`display`) | Filter (§6.3) | Variant axis |
|---|---|---|---|---|
| `string` | `"дуб"` | `дуб` | exact value | — |
| `number` | `12.5` (a JSON number, not `"12.5"`) | `12.5 кг` with `unit` | `min` / `max` | — |
| `bool` | `true` | `Да` | **does not work** | — |
| `select` | an option `value`: `"oak"` | the option's `label` | option values | **yes — the only axis type** |
| `multiselect` | a list of option values: `["x","y"]` | `Икс, Игрек` | **does not work** | — |
| `date` | `"2026-10-27"` or RFC 3339 | `2026-10-27` (read back as `2026-10-27T00:00:00Z`) | **does not work** | — |
| `color` | any JSON, e.g. `"#ff0000"` | `#ff0000` | **does not work** | — |
| `url` | `"https://…"` | the URL text | **does not work** | — |
| `group` | a list of records shaped by the set's schema (§5.1) | not on the card | **does not work** | — |

A wrong shape is a `400` naming the field: `attribute "width": expected number value`,
`unknown option "zzz"`, `invalid date "27.10.2026" (RFC3339 or YYYY-MM-DD)`. `null`, an empty list and an empty
string mean "no value" — the row is not written; for `color` and `url` an empty string **is** stored. A record
set (`group`) takes only a list of records (§5.1).

Pick the type by what the buyer will do with it: **choose** (`select` — filters, axes, swatches), **compare a
range** (`number` with `unit`), **read** (`string`). A characteristic meant to be both a fact and a switcher
(width 140 / 180 to choose from) must be `select` with options `140`, `180` and labels `140 см`, `180 см` — a
`number` can never become an axis (§9).

### 3.2 Changing a definition that already has values

- **A rename reaches every card at once** (since 2026-10-28). A new `name`, option `label`, `unit`, `section` or
  `sort_order`, a deleted characteristic and a new order of a section's bindings show on the product cards
  without re-sending values. A dev key's change shows after publication; until then the live cards keep the
  old text.
- **Changing `data_type` is accepted and breaks the old values.** `number` → `string` on a characteristic with
  values answers `200`; the cards keep showing the old number, and re-sending the products' old values now
  fails with `expected string value`. Create a new characteristic instead and move the values.
- **A record set cannot change type**, and no other characteristic can become one: `400 RECORDS_TYPE_LOCKED`.

### 3.3 Who sees it

A characteristic saved for the first time is **hidden from buyers** (`visibility: staff`, version 0) — on the
card, in listings, in filters and in the comparison table — until its access record is written. The full rules
are `/docs/catalogue` §8.3. The short form:

```http
GET /attributes/412/access                  # read version and flags first
PUT /attributes/412/access
{ "expected_version": 0,
  "access": { "visibility": "public", "show_storefront_card": true, "show_storefront_list": true,
              "show_admin_product": true, "show_admin_list": true, "show_api": true, "show_export": false } }
```

`PUT` replaces the whole record — a flag you leave out becomes `false`. `show_storefront_card` is the product
page and comparison; `show_storefront_list` is listing cards **and the filter panel** (§6). Opening a
characteristic opens it on every product of the shop at once — say so to the owner first.

### 3.4 Where it lands on the product card

The card ("About the product") lists the product's **own** values, grouped by the definition's `section` text.
The order has one key: the `sort_order` of the characteristic's binding to the product's section (§6.1) when there
is one, otherwise the definition's `sort_order`; ties go by id. A new order reaches the cards at once; moving a
product to another section changes its order only when its values are written again. When there are two or more sections, each gets a heading — and the
heading is the `section` string itself. The default `characteristics` is printed as is, so
**write `section` in the buyer's language** (`"Размеры"`, `"Материалы"`). Groups and templates (§7) do not
change the regular card.

## 4. Options — the values of `select` and `multiselect`

```json
"options": [ { "value": "oak", "label": "Дуб", "sort_order": 0 },
             { "value": "black", "label": "Чёрный", "color_hex": "#222222" },
             { "value": "walnut", "label": "Орех", "image_id": "<file uuid of an icon>" } ]
```

- `value` is the machine key products store: `^[a-z0-9][a-z0-9_-]{0,63}$` (a digit first is allowed: `128`).
- **Always send `label`.** Without it the card prints the raw value (`c`), and a variant switcher draws an
  **empty button** (`unit` is not added to buttons either — write `140 см` into the label).
- `color_hex` paints a `swatch`; `image_id` is an icon from the shop's files for `icon` / `photo`.
- **Options are a replace-set.** To add one, read the definition and send the full list plus the new option.
  Options get new ids on every write — products keep working because they store `value`, but anything you
  kept by option id must be re-read.
- Removing an option that products use is refused: `400 OPTION_IN_USE:<values>`. Move those products to
  another value first.
- `"options": []` in `PUT /attributes/{id}` means "leave the options as they are", not "remove all". There is no
  call that empties the list of an existing characteristic.

## 5. Values on products

| Call | What it does |
|---|---|
| `PUT /products/{id}/attributes` `{"items":[{"attribute_id":…,"value":…}]}` | **replaces the product's whole set**, up to 500 values; answers the card sections |
| `POST /products/import`, row `attributes: [{"code":…,"value":…}]` | the same replace-set, by **code**, for up to 5,000 products per request |
| `GET /products/{id}` → `attribute_sections` | what the card shows |
| `GET /products/{id}/spec` | the template view: every row of the effective template with its value and `value_source` (§8) |

Both writes replace the set: a product that had material, colour and width and receives only `warranty`
keeps only `warranty`. `PUT …/attributes` with `"items": []` clears the product. An import row replaces only
when `attributes` is non-empty; an empty list leaves the values untouched, and clearing takes
`"attributes": [], "attributes_replace": true`. To change one characteristic on many products, read each product's set and send it back
whole with the one value changed. Owner-side lists do not return hidden characteristics in bulk
(`attribute_codes` answers as a buyer), so reading is one `GET /products/{id}` per product.

**Record sets are the exception.** A record set (§5.1) that the request does not name keeps its records, in the
live catalogue and in a draft, even when it was written a moment earlier by someone else: an update of colour
and width by key never wipes the reviews. Two writers of the **same** set: the last one wins. To delete a set, send it with `null` or `[]`. The
same holds for `PUT /categories/{id}/values`. Import neither writes nor clears record sets: a row with a set
gets the row error `"<code>" is a record set`.

### 5.1 Record sets (`group`)

A record set keeps repeating records — gallery, reviews, Q&A, authors, documents — inside a product or a section:
`data_type: "group"`, the schema of one record in `ui_settings.records`, the list as the value. Records are not
inherited, a set has no default and is never a filter; sets left out of a values request keep their records. The
site reads them through the public `GET /record-sets` (base64 envelope, always pass `code`). Everything — schema,
values, buyer access, reading for the site, a component that shows a set — is in **`/docs/record-sets`**.

## 6. Filters in a section

The storefront asks `GET /products?filter.category_id=<id>` and draws the panel from `filterable_attributes`
of the answer. The panel is built in one of **two ways** — and the second silently replaces the first.

### 6.1 Without a template: the section's own bindings

A characteristic appears in the panel only when **all three** hold:

1. it is bound to **this** section — `PUT /categories/{id}/attributes`;
2. its definition has `is_filterable: true`;
3. its access is open on listings (`visibility: public` or a matching audience, `show_storefront_list: true`).

```http
PUT /categories/12/attributes
{ "items": [ { "attribute_id": 38, "sort_order": 0 },
             { "attribute_id": 41, "sort_order": 1 } ] }
```

Bindings are a replace-set per section (send all of them) and are **not inherited**: a subsection created
under "Tables" has an empty panel until it gets its own bindings. `is_required` and `default_value` on a
binding are stored for the cabinet form and not applied to products. The binding's `sort_order` also orders
the card (§3.4).

### 6.2 With a template: the template's rows

When the section has an effective template (§7.3 — its own or inherited from any ancestor), the panel is the
template's rows marked `filterable_here: true`, in template order, filtered by access as above. `is_filterable`
and the section's own bindings are then **ignored**. Measured: a section filtered by `material` through a
binding lost that filter the moment a template with only `width` was bound. Templates are inherited, so one
template on a root section gives every subsection the same panel.

### 6.3 What a filter can match

```
GET /products?filter.category_id=12&attribute_filters[0].attribute_id=38&attribute_filters[0].values=oak
GET /products?filter.category_id=12&attribute_filters[0].attribute_id=41&attribute_filters[0].min=100&attribute_filters[0].max=160
```

Indexed syntax, up to 20 filters, combined with AND (`/docs/catalogue` §4; with `curl` add `-g`, or the
brackets are taken as a glob). `values` match every type by its stored form (since 2026-10-28): `select`
option values, exact `string` values, any of the options of a `multiselect`, `true`/`false` for `bool`,
`YYYY-MM-DD` for `date`, the exact text of `color` and `url`; `min`/`max` match `number`. A buyer filtering by a characteristic hidden from listings gets
`400 ATTRIBUTE_FILTER_NOT_AVAILABLE`.

Filters match the **product's own values only**. A value a product inherits from its section or from a default
(§8) is in `/products/{id}/spec` but is not found by a filter — write values into products if buyers must filter
by them. A `model` section's own listing filters by its products' fields; the model's reference specs are
filtered on its parent section.

## 7. Groups and templates

Use them when several sections share one structured set (cars: engine, transmission, dimensions), when values
should be inherited from a section (§8), or when one branch needs its own filter panel (§6.2).

### 7.1 Groups — `/char-groups`

A named block of characteristics inside a template (`Двигатель`, `Размеры`):

```http
POST /char-groups
{ "item": { "code": "dimensions", "label": { "ru": "Размеры", "en": "Dimensions" }, "sort_order": 1 } }
```

`GET /char-groups` lists them; `PUT /char-groups/{id}` is partial; `DELETE` is soft. Reads return `id`, `code`,
`sort_order` and a `name` taken from `label.ru` (or any non-empty language) — not the stored `label`,
`description` or `display`, and there is no read of one group by id: keep what you wrote. A group is used only
through template rows — the regular product card keeps grouping by `section` (§3.4).

### 7.2 Templates — `/spec-templates`

```http
POST /spec-templates
{ "item": { "code": "tables", "name": "Столы", "applies_to": "product" } }

PUT /spec-templates/1/items                       # replace-set, up to 500 rows
{ "items": [
  { "char_group_id": 1, "attribute_id": 43, "group_sort_order": 0, "attribute_sort_order": 0,
    "is_summary": true, "filterable_here": true },
  { "char_group_id": 1, "attribute_id": 42, "group_sort_order": 0, "attribute_sort_order": 1,
    "default_value": "массив" } ] }
```

| Row field | Effect |
|---|---|
| `char_group_id`, `group_sort_order`, `attribute_sort_order` | grouping and order in `/spec`, comparison, model cards |
| `is_summary` | shown on the model's tile in a brand listing (up to four) |
| `filterable_here` | in the filter panel of every section using the template (§6.2) |
| `default_value` | the value when neither the product nor a section has one (§8) |
| `is_required_override` | stored for the cabinet form, not enforced |

`applies_to` on the template is free text for your own bookkeeping; the platform does not act on it.

**`GET /spec-templates/{id}/items` does not return `default_value`.** Reading the rows and writing them back
erases every row default. Keep the defaults on your side and send them again with every `PUT …/items`.

### 7.3 Binding a template to a section — `/categories/{id}/spec-settings`

```http
PUT /categories/12/spec-settings
{ "mode": "append", "bindings": [ { "template_id": 1, "sort_order": 0 } ] }
```

| `mode` | Effective set of this section (and of its subsections that do not set their own) |
|---|---|
| `""` | inherit the parent's set; local bindings are removed |
| `append` | the parent's set plus these templates; a characteristic present in both takes this level's row |
| `replace` | only these templates |
| `clear` | an empty set; inheritance stops here |

`append` and `replace` need at least one binding (`SPEC_MODE_REQUIRES_TEMPLATES`). Read the result with
`GET /categories/{id}/spec`: every row carries `source: "category:<id>"` — the level it came from.

### 7.4 Price rows in a template

A template can also carry price fields next to characteristics. That mixed layout is
`GET|PUT /spec-templates/{id}/layout` and needs `pricing:read` / `pricing:write`; it is described in
`/docs/pricing`. `PUT …/layout` replaces **both** parts at once: a body with characteristic rows only removes
the template's price rows. `PUT …/items` touches characteristic rows only. `GET …/layout` is also the one read
that returns row defaults.

## 8. Values of a section ("model") and the effective value

A section can carry values itself — the reference specs of a car model that every listing under it shares:

```http
PUT /categories/12/values                         # replace-set, up to 500
{ "items": [ { "attribute_id": 43, "state": "value", "value": 99 },
             { "attribute_id": 42, "state": "clear" } ] }
```

`GET /products/{id}/spec` resolves each row of the effective template, first match wins:

```
the product's own value  >  nearest section  >  ancestor sections  >  template default_value  >  definition default_value
```

`state: "clear"` stops the search: the row resolves to "no value" even when an ancestor or a default has one.
`state: "value"` with an empty or `null` value is not stored — the row goes back to inheriting. A product
cannot clear an inherited value: its own values have no `clear` state, only a value of its own overrides.
`value_source` names the winner: `product`, `category:<id>`, `template_default`, `definition_default`.
Measured: a product without its own weight resolved `99` from `category:79`; a product with `12.5` of its own
kept `12.5`.

**Buyers do not see section values on the regular product card or in the comparison table** — both list only
the product's own values (§3.4). Section values reach buyers only through the model blocks of `model` sections
(`GET /categories/{id}/spec`, the `is_summary` tiles); `GET /products/{id}/spec` is the shop team's read. To show
a shared value on every regular card, write it into each product (one import).

## 9. Characteristics as switchers

- **Variant axis** (choices inside one product): only `select`. `PUT /products/{id}/variant-axes`, then
  `PUT /products/{id}/variants` — a set without `is_active` is on, `false` switches it off (before
  2026-10-28 a set without it was saved off). Full scenario — `/docs/catalog-import` §5.
- **Grouping axis** (separate products linked into one switcher): only `select`; `/product-groups`. One
  characteristic cannot be both a variant axis and a grouping axis of the same product.
- The switcher look is the characteristic's `widget` and `ui_settings`; a product axis may narrow it with its
  own `display` (`""` · `text` · `icon` · `swatch`).
- A product's variant and its own characteristic of the same name are independent: a chair with a colour
  switcher and a characteristic `Цвет: Натуральный` shows both. Do not keep the same fact in both.
- `number` → switcher: create a separate `select` characteristic (`table_width`, options `140`/`180`,
  labels `140 см`/`180 см`) and use it as the axis; decide with the owner whether the old `number` stays on
  the card.

## 10. Recipe: one characteristic, end to end

Goal: "Width, cm" on every table, filterable on the Tables section and its subsections.

1. `GET /attributes` — reuse an existing `width` before creating one; two "widths" confuse every reader.
2. `POST /attributes` `{"item":{"code":"width","name":"Ширина","data_type":"number","unit":"см","section":"Размеры","is_filterable":true}}`.
3. Access: `GET /attributes/{id}/access` → `PUT` with `public`, `show_storefront_card`, `show_storefront_list`,
   `show_api` — after telling the owner it opens on every product.
4. Values: one `POST /products/import` with each table's full `attributes` (§5).
5. Filter: one section → `PUT /categories/{id}/attributes` with **all** its filters; a branch → a template row
   with `filterable_here` bound to the branch root with `append`.
6. Verify, not by `200`:
   - `GET /products/{id}` → `attribute_sections` has `Ширина 140 см`;
   - **without a key**: `GET /products?filter.company_id=<shop_id>&filter.category_id=<category_id>&page.number=1&page.limit=1`
     → `filterable_attributes` lists `width` (a keyless list without `filter.company_id` answers
     `403 AUTH_COMPANY_PROBLEM`);
   - with the key: `curl -g '…/products?filter.category_id=<category_id>&attribute_filters[0].attribute_id=<attribute_id>&attribute_filters[0].min=100&attribute_filters[0].max=160&page.number=1&page.limit=1'`
     → `total` drops;
   - the section page on the storefront shows the panel.

Rollback: unbind (`PUT /categories/{id}/attributes` with the previous list, or the previous `spec-settings`),
close access, remove the values from the products (an import with their remaining `attributes`, or
`"attributes": [], "attributes_replace": true` when nothing remains), and only then delete the definition.

## 11. Silently ignored or surprising

| What | What happens |
|---|---|
| `is_required`, `validation`, a binding's `is_required` / `default_value` | stored, never checked |
| a filterable characteristic not bound to the section | not in the panel |
| a subsection under a section with bindings | empty panel — bindings are not inherited |
| a template bound to a section | the section's own filter list stops being used |
| a new characteristic | hidden from buyers until access is saved |
| `data_type` change with existing values | `200`; old values stay and can no longer be re-sent |
| an option without `label` | raw value on the card, empty switcher button |
| section values (`/categories/{id}/values`) | not on the regular card and not in comparison |
| an import row with `"attributes": []` | values untouched — clearing needs `attributes_replace: true` |
| `section` left empty with several sections | heading `characteristics` on the storefront |
| `"options": []` in an edit | options kept — not cleared |
| `GET …/items` → `PUT …/items` | row defaults erased |
| `PUT …/layout` with characteristic rows only | the template's price rows removed |
| filter by a value inherited from a section or a default | not found — filters read products' own values |

## 12. Limits and errors

- options per definition: 10000; values per product write and per section write: 500; template rows: 500;
  bindings per `spec-settings`: 100; filters per list request: 20.
- A variant axis on the product card and in lists carries the whole dictionary up to 200 options; above 200,
  only the values the product's sets or grouping members use.
- Definitions and options: `400 INVALID_CODE` · `INVALID_DATA_TYPE` · `INVALID_WIDGET` · `INVALID_OPTION_VALUE` ·
  `INVALID_UI_SETTINGS` · `OPTION_IMAGE_NOT_FOUND` · `OPTION_IN_USE:<values>`; `409 ATTRIBUTE_CODE_TAKEN` ·
  `OPTION_VALUE_TAKEN:<value>` (a duplicate option value, case-insensitive).
- Values and bindings: `400 DUPLICATE_ATTRIBUTE_IN_SET` · a value-shape message naming the characteristic;
  `400 ATTRIBUTE_FILTER_NOT_AVAILABLE` for a buyer's filter on a hidden characteristic.
- Templates: `400 DUPLICATE_ATTRIBUTE_IN_TEMPLATE` · `SPEC_TEMPLATE_LAYOUT_INVALID` · `TEMPLATE_NOT_FOUND` ·
  `SPEC_MODE_REQUIRES_TEMPLATES`. Axes: `400 AXIS_MUST_BE_SELECT` · `AXIS_ALREADY_GROUP_AXIS` (variant axis already a
  grouping axis) · `AXIS_IS_MEMBER_VARIANT_AXIS` (the reverse) · `INVALID_AXIS_DISPLAY` · `DUPLICATE_AXIS_IN_SET`.
- Record sets: `400 RECORDS_SCHEMA_INVALID: <field path>: <reason>` · `RECORDS_VALUE_INVALID: <code>[<n>].<field>:
  <reason>` · `RECORDS_TOO_MANY` · `RECORDS_TOO_LARGE` · `RECORDS_NOT_FILTERABLE` · `RECORDS_NO_DEFAULT` ·
  `RECORDS_TYPE_LOCKED`; `GET /record-sets`: `400 RECORDS_FIELD_UNKNOWN` (the field is unknown or hidden) ·
  `RECORDS_RESPONSE_TOO_LARGE`, `403 RECORDS_ALL_NEEDS_ROLE`.
- Access: `409 ATTRIBUTE_ACCESS_VERSION_CONFLICT`.
- Another shop's or a deleted characteristic: `404 ATTRIBUTE_NOT_FOUND` from product values, section bindings,
  axes and access; `404 not_found` from `PUT`/`DELETE /attributes/{id}`; `400 UNKNOWN_ATTRIBUTE` from
  `PUT /categories/{id}/values`.

## 13. How this was verified

2026-10-27, a stand built from `production` (`d506644a`), a fresh shop and a key with `catalog:*` +
`storage:*`. Every type of §3.1 was created and written on one product and read back; rename and `data_type`
change were measured on the same product (§3.2; rename, label and delete re-measured on 2026-10-28 after the
fix, with a dev key and a foreign shop's key); `OPTION_IN_USE` by dropping a used option; filters by
reading `filterable_attributes` without a key after each step — no binding, binding with access closed, access
open, a subsection, a template bound with `append`, the template row opened (§6); facet matching per type with
the owner's key and as a buyer (§6.3); section values and `/products/{id}/spec` sources on two products (§8);
`DELETE /attributes/{id}` against the card of a product holding its value (§3.2). Read-projection limits of
groups and template rows, the layout replace and the axis exclusivity were traced in code by an independent
read-only audit the same day (`set_spec_template_items`, `spec_template_layout.go`, `list_char_groups.go`,
`set_product_variant_axes.go`, `set_group_axes.go`).
Sources: `internal/core/domain/attribute.go`, `internal/api/catalog/attributes_repack.go`,
`edit_attribute.go`, `get_products.go` (`categoryFilterableAttributes`),
`internal/adapters/repositories/pg/attributes.go` (`buildAttributeSections`,
`GetCategoryFilterableAttributes`), `internal/core/services/spec/resolve.go`,
`vizen-market/storefront/src/blocks/SystemProductCard.tsx`.
