# Storefront redirects: full reference

> **Резюме по-русски.** Полный справочник области «Редиректы»: правила
> «откуда → куда» (точное, маска, шаблон), условие по параметру, переход «не
> найдено → ближайший раздел», порядок разбора адреса витриной, обмен CSV,
> проверка адреса, петли, жизненный цикл адреса при переносе, переименовании и
> удалении, коды отказов, машинный блок лимитов и тариф (§11), журнал 404
> (§12). Короткий вход —
> `/docs/redirects`; раздел отсюда берётся отдельно:
> `GET /docs/redirects-reference?section=<slug>`.

**Status:** draft · **Verified:** 2026-09-25 against the code of branch
`production` (stages A, B and C of the redirects plan): unit tests of the
matcher and the shared Go/TS vectors, integration tests on PostgreSQL 17
(`TestSFR*`, incl. `TestSFRStageB*`, `TestSFRStageC*`), a run on a local copy
of a live shop ·
**Owner:** redirects line
**Serves:** `GET /docs/redirects-reference`

The short entry — what redirects are, the lifecycle of an address and a quick
start — is `GET /docs/redirects`. Read this reference one section at a time
with `?section=<slug>` (a wrong slug answers 404 with the list of slugs), e.g.
`GET /docs/redirects-reference?section=7-url-lifecycle`.

## 1. What you can do here

Keep the traffic of an address that stopped existing. A rule says «this path of
my storefront → that path (or an external URL)». You list, create, edit,
delete, reorder and import rules, switch a rule off without deleting it, ask
what the storefront will do with a given address, see how often each rule
fired, and turn the shop-wide «missing address → nearest section» fallback on
or off. The 404 log lists the addresses visitors still miss, ready to become
rules (§12). When you move a category, `POST /categories/move` tells you which
addresses change and can pin them with rules in the same call.

Scope: only the shop's own storefront (`<slug>.vizen.shop` and its custom
domain). The token needs `redirects:read` / `redirects:write`; the person
behind it must be the shop's **owner or admin** (an editor gets
`REDIRECTS_FORBIDDEN`). Rules live only in the production contour: a draft key
reads them, a draft key's writes are refused.

## 2. Decide first (forks)

| If you want… | Do this | Note |
|---|---|---|
| nothing — a product or category was renamed or moved | nothing | the storefront follows the slug journal and the last URL segment itself (§7) |
| one old address → one new address | exact rule `/old` → `/new` | cheapest: matched by key |
| one old address, but only with `?ID=5` | exact rule + `query_match: "ID=5"` | a rule with a condition wins over the same path without one |
| a family of old addresses with a shape | mask (`kind: "pattern"`) `/old/*.html` → `/c/$1` | masks are limited by the plan (§11) |
| a whole section, keep the tail | prefix `/shop/*` → `/catalog/*` | first matching prefix in `sort_order` wins |
| a whole section to one landing page | prefix `/shop/*` → `/catalog` | the section page itself still opens |
| to override a page that still exists | `override_live: true` | applied before rendering; deliberate |
| deleted pages to land somewhere sensible | keep `miss_fallback` on (default) | nearest live section, never the home page |
| a moved category to stay pinned forever | `POST /categories/move` with `pin_rules: true` | ask the owner first (§7.1) |
| to see which addresses visitors still miss | the 404 log `GET /storefront-redirects/misses` | then a rule per address (§12) |

## 3. Quick start

```bash
# 1. the set, its version and the fallback switch
curl -s "$API/storefront-redirects" -H "$T"
# 2. one rule (enabled must be sent: proto3 drops a false bool)
curl -s -X POST "$API/storefront-redirects" -H "$T" -H "$J" \
  -d '{"item":{"from_path":"/old-page","to_target":"/catalog/sofas","code":301,"enabled":true}}'
# 3. what the storefront will do with the address
curl -s "$API/storefront-redirects/check?url=/old-page" -H "$T"
```

Step 3 answers `{"result":{"matched":true,"rule_id":"12","code":301,
"location":"/catalog/sofas","chain":["/old-page","/catalog/sofas"],
"steps":[{"kind":"rule","from":"/old-page","to":"/catalog/sofas","rule_id":"12","code":301}]}}`.

## 4. Requests

### 4.1. Objects

A rule (`StorefrontRedirect`):

| Field | Who writes | Meaning |
|---|---|---|
| `id` | server | 64-bit, a JSON string |
| `kind` | you or server | `exact`, `pattern` or `prefix`; empty = derived from `from_path` (no `*` → exact; one trailing `/*` → prefix, unless the target uses `$1`; any other `*` → pattern). Send it only for `/goods/*`, which can be both |
| `from_path` | you | a path of your storefront, as typed. No query string: use `query_match` |
| `to_target` | you | a path of your shop, or an absolute `http(s)` URL with `external: true` |
| `external` | you | required for an off-shop target (`REDIRECT_EXTERNAL_NOT_ALLOWED` otherwise) |
| `code` | you | `301` or `302`; `0` means 301 |
| `enabled` | you | **defaults to false** — send `true` |
| `override_live` | you | `false` (default): the rule acts only where no page exists; `true`: before rendering, over a live page |
| `drop_query` | you | `false` (default): the visitor's query goes to the target; `true`: dropped, ad tags excepted |
| `query_match` | you | exact rules only: `key=value` (a leading `?` is stripped), compared byte for byte |
| `sort_order` | server | order of masks and prefixes; change with `PUT /storefront-redirects/order` |
| `note` | you | ≤ 500 chars. Rules pinned by a category move carry `category move` |
| `hits`, `last_hit_at` | server | how often and when it last fired; list only, approximate |

The comparison key is derived, not sent: percent-decoded, repeated `/`
collapsed, trailing `/` dropped, ASCII-lowercased — the storefront computes the
request key the same way.

### 4.2. Endpoints

| Method and path | Scope | What |
|---|---|---|
| `GET /storefront-redirects` | `redirects:read` | the set in matching order, `version`, `miss_fallback`, the plan limits `rules_limit` / `patterns_limit` (0 = the plan does not limit) and the ceilings `rules_ceiling` / `patterns_ceiling` (§11) |
| `GET /storefront-redirects/check?url=` | `redirects:read` | the trace of an address (§4.4) |
| `POST /storefront-redirects` | `redirects:write` | `{"item": {...}}` → the saved rule |
| `PUT /storefront-redirects/{id}` | `redirects:write` | `{"item": {...}}` — the whole input again |
| `DELETE /storefront-redirects` | `redirects:write` | `{"ids": ["12","13"]}` → `{"deleted": 2}` |
| `PUT /storefront-redirects/order` | `redirects:write` | `{"ids": [...]}` — every live rule exactly once |
| `POST /storefront-redirects/import` | `redirects:write` | CSV text, `dry_run` (§4.3) |
| `PUT /storefront-redirects/settings` | `redirects:write` | `{"miss_fallback": false}` (§5) |
| `GET /storefront-redirects/misses` | `redirects:read` | the 404 log, paged (§12) |
| `POST /storefront-redirects/misses/hide` | `redirects:write` | `{"paths": [...], "unhide": false}` (§12) |
| `POST /categories/move` | `catalog:write` (+ `redirects:write` for `pin_rules`) | move with a summary (§7.1) |
| `GET /slug-redirects/resolve?company_id=&entity_type=&slug=` | public | the slug journal: `product`, `category`, `article` (§7.3) |
| `GET /storefront/redirects?slug=` | public | the compiled set the storefront applies (§4.6) |

Every mutation bumps the set's `version` in the same transaction and purges
the storefront cache of the shop.

### 4.3. CSV import and export

`POST /storefront-redirects/import` with `{"text": "...", "dry_run": true}`. One
rule per line, eight columns, only the first two required:

```
# from;to;code;kind;params;condition;always;note
/old-page;/catalog/sofas;301
/bitrix/*.php;/b/$1;301;pattern;drop
/catalog.php;/catalog;302;exact;keep;SECTION_ID=7;1;moved from Bitrix
```

| Column | Values | Empty means |
|---|---|---|
| `from` | a storefront path | — |
| `to` | a path or an `http(s)` URL | — |
| `code` | `301` / `302` | 301 |
| `kind` | `exact` / `prefix` / `pattern` | derived from `from` |
| `params` | `keep` / `drop` | keep |
| `condition` | `key=value` (exact only) | none |
| `always` | `1` / `true` / `yes` = `override_live` | only where no page exists |
| `note` | the rule's `note`, ≤ 500 chars; takes the rest of the line, separators included | empty note — **but a line with no `note` column keeps the stored note** |

The `note` column is optional and backward-compatible: a line of two to seven
columns (an older export, a Shopify file) never touches the note of an
existing rule, so re-importing such a file keeps every note — the
`category move` mark included — and an otherwise identical rule reports
`UNCHANGED`. A present `note` column, even an empty one (`…;always;`), sets the
note; an empty one clears it.

Write the column names as a `#` comment, not as a header line: only a Shopify
header is recognised, any other becomes an error row. The separator is the first of tab, `→`, `->`, `;`, `,` found outside quotes; a
field may be double-quoted CSV-style (a quote inside doubled). Blank lines,
lines starting with `#` and a Shopify header `Redirect from,Redirect to` are
skipped; a two- or three-column file (a Shopify export) reads as before.
Imported rules are **enabled**. A key repeated in the file keeps its first
line; a key that exists updates the rule. Each report row carries `status`
(`created|updated|skipped|error`) and a machine `reason`: `LINE_MALFORMED`,
`CODE_INVALID`, `DUPLICATE_IN_FILE`, `UNCHANGED`, `RESERVED_PATH`, `LOOP`,
`CHAIN_TOO_LONG`, `EXTERNAL_NOT_ALLOWED`, `INVALID_SOURCE`, `INVALID_TARGET`,
`LIMIT_REACHED`, `KIND_UNKNOWN`, `OPTION_INVALID`, `QUERY_INVALID`,
`NOTE_INVALID`, `PATTERN_LIMIT_REACHED`.
Run with `dry_run: true`, show the report, repeat without it; a second
identical run reports zeros and leaves `version` alone.

Export has no endpoint: write the same eight columns from
`GET /storefront-redirects` (`from_path`, `to_target`, `code`, `kind`,
`drop_query` → `drop`/`keep`, `query_match`, `override_live` → `1`/empty,
`note`). Quote a field that holds a separator, a quote or a space. `enabled`
is not exchanged: imported rules are enabled.

### 4.4. Check an address

`GET /storefront-redirects/check?url=/old/sofa.html?ID=5` models what the
storefront does and returns every step: `steps[]` of `{kind, from, to,
rule_id, code, sent_code, override_live}` where `kind` is `rule` (a seller
rule), `html` (the built-in `…/x.html → …/x`, 308) or `fallback` (nearest
section, 308). `matched`, `rule_id`, `code`, `sent_code`, `location`, `chain`,
`fallback` summarise it; `reserved: true` means a service path no rule can
take; `too_long: true` means the chain runs past 10 hops (`chain` and `steps`
show the first 10; such a rule cannot be saved, §8); `reason` is Russian prose
for a person. The check cannot know whether a page exists at an address — a
rule without `override_live` is reported as matched with that caveat in
`reason`.

**`code` vs `sent_code`.** `code` is the code stored in the first matching rule
(301/302). `sent_code` is what the storefront actually sends for it (§5): the
same 301/302 for a rule with `override_live` or a rule on a `.html` address;
**308** for a 301 and **307** for a 302 of a rule without `override_live` on
any other address. On steps, `sent_code` is the code of that hop (308 for the
built-in ones). `sent_code` is `0` when no rule matched. Both fields are
additive: older clients that read only `code` keep working.

### 4.5. Settings

`PUT /storefront-redirects/settings {"miss_fallback": false}` turns the nearest
section fallback off for the whole shop; `true` turns it back on. It does not
bump `version`; the storefront gets it with the host resolve. There is no
«leave it 404» per address: whoever wants 404s switches the fallback off.

### 4.6. The public set and hit counters

`GET /storefront/redirects?slug=<shop>` (or `?domain=`) is anonymous and returns
the enabled rules split by kind: `{"result":{"version","exact":[{k,id,to,code,
ext,ovr,dq,q}],"pattern":[…],"prefix":[…]}}`. A closed (PIN) site answers 404.
The storefront counts hits in memory and flushes them to the platform together
with the 404 log (§12); neither ever bumps `version`.

## 5. What the storefront does with an address

In this order, first match wins:

1. **Middleware, before the page:** rules with `override_live: true` — exact
   (with a condition first), then masks, then prefixes in `sort_order`. Answers
   with the rule's own **301/302**.
2. Built-in: `…/x.html` → `…/x` (308); canonical host (308).
   **Exception — a rule on the `.html` address itself** (exact `/page.html` or
   a mask such as `/old/*.html`) answers here, in middleware, with its own
   **301/302**, even without `override_live`: it must beat the built-in `.html`
   hop, and no live page ever sits on a `.html` address. The loop gate and the
   address check model the same order.
3. **The page resolves:** a live category, product or article renders. An
   address whose chain broke but whose last segment is a live slug answers
   **308** to that entity's canonical address.
4. **Slug journal:** an old slug of a product, category or article → 308 to
   the current address.
5. **Rules without `override_live`** (the default), same order as in step 1.
   Here Next.js can only answer **308** (for 301) or **307** (for 302) — the
   pair search engines treat the same.
6. **`miss_fallback`** (on by default): the address itself is a live section
   typed in another Latin case (`/CATALOG/BEDS`) → that section; a deleted
   product → its last section if alive; otherwise the nearest live section by
   the address chain (longest matching prefix). Never `/`. 308, the query
   string carried. A **file address** — the last segment ends in `.xml`,
   `.csv`, `.txt`, `.json` or `.yml` (`/catalog/feed.yml`) — never falls back:
   a missing feed answers 404, not the section page. `.html` is a page address
   and falls back as usual.
7. 404 — and the address goes to the 404 log (§12).

Query strings: kept by default; `drop_query` drops them except ad tags —
`utm_*`, `gclid`, `gbraid`, `wbraid`, `dclid`, `yclid`, `ysclid`,
`_openstat`, `fbclid`, `msclkid`, `ttclid` always travel.

## 6. Mask grammar

- `*` — a piece **inside one segment**, at least one character, never crosses
  `/`; at most one `*` per segment: `/old/*.html`, `/p/*-sale`.
- `**` — the whole **last** segment: a tail of zero or more segments.
  `/shop/**` matches `/shop` and `/shop/a/b`.
- In the target `$1…$9` are the stars left to right; a capture is
  path-escaped (`$1` in a query part is query-escaped). Up to 9 stars.
- Matching is linear, case-insensitive for ASCII, no regex.
- `query_match` is not allowed on a mask (`REDIRECT_INVALID_QUERY`).

Examples: `/catalog/*/*.html` → `/c/$1/$2`; `/old/**` → `/new/$1`;
`/index.php` + `query_match: "ELEMENT_ID=5"` → `/sofa`.

## 7. URL lifecycle

### 7.1. Moving a category

The category's address, every subcategory's and every product's whose
**primary** category is in the moved branch change. Old addresses keep working
without any rule: the storefront finds the entity by the last segment (§5
step 3). Always:

1. `POST /categories/move` with `"dry_run": true` — nothing changes, the same
   answer as a real move:

```json
{"from_id": "41", "to_id": "7", "type": "inside", "dry_run": true}
→ {"categories_affected": 3, "products_affected": 120, "rules_created": 0,
   "url_changes": [{"old_path": "/sofas/corner", "new_path": "/living/sofas/corner",
                    "kind": "category", "covered_by": "auto"}, …]}
```

2. **Show the summary to the user and ask:** move (old addresses follow
   automatically) · move and pin with rules · cancel.
3. Repeat without `dry_run`, with `"pin_rules": true` if asked.

`url_changes` holds at most 20 examples, categories first; the counters and
the pinning cover **every** changed address. `covered_by`: `auto` — the
storefront follows it (§5 step 3); `rule` — a rule (existing, or created by
this move); `fallback` — the entity is unpublished, the nearest section takes
the address; `none` — 404.

`pin_rules: true` creates, in the same transaction as the move, an exact 301
rule old → new (`override_live: false`, note `category move`) for every
changed address not already covered by an enabled rule and whose key is not
taken; a rule that would close a loop (the branch moved back where a pinned
rule points from) is skipped. `rules_created` says how many. If they do not
fit the rule limit, the whole call is refused with `REDIRECT_LIMIT_REACHED`
and **nothing is moved**. It needs the owner/admin role and, for a token,
`redirects:write` (`PAT_SCOPE_MISSING (required_scope: redirects:write)`
otherwise); `dry_run` needs only `catalog:write`. A draft key cannot move
categories, `dry_run` included: 403 `PAT_CONTOUR_MISMATCH` (the passport lists
`MoveCategory` in `live_operations_denied`); a cabinet session in the draft
contour gets `DEV_MODE_UNAVAILABLE`.

### 7.2. Moving a product to another category

Changing `category_id` changes the product's address; the old one follows by
the last segment. To pin it, create an exact rule old → new yourself
(`POST /storefront-redirects`).

### 7.3. Renaming a slug

A new slug of a product, category or article written in the live contour is
recorded in the slug journal; the old address answers 308 to the current one
(chains `A → B → C` collapse). A draft slug reaches the journal on publish.
`GET /slug-redirects/resolve?company_id=11&entity_type=article&slug=old` →
`{"entity_id":"5","current_slug":"new","entity_type":"article"}`; a slug held
by a live entity again is not a redirect (404). To keep an old address even if
the slug is later reused, add an exact rule.

### 7.4. Deleting

A deleted product's address goes to its last section (the journal answers
`fallback_category_id`), a deleted category's addresses to the nearest live
section — both through `miss_fallback`. To send them elsewhere create an exact
rule to the chosen page before or after deleting.

## 8. Loops

Every mutation checks the set it would leave behind inside its own
transaction: exact rules, masks (on probe addresses), prefixes with tails, the
built-in `.html` hop and the nearest-section fallback.

- A chain that returns to a visited address (or keeps growing — a tail
  substituted into itself) is refused with `REDIRECT_LOOP`.
- A finite chain is allowed up to **10 hops inclusive**; the 11th hop is
  refused with `REDIRECT_CHAIN_TOO_LONG`. Every hop counts: rules, the built-in
  `.html` hop and the fallback. Fix it by pointing the first rule straight at
  the final address. The length is counted whichever end of the chain the new
  rule extends.

Both are checked on create, edit, import (row reasons `LOOP` /
`CHAIN_TOO_LONG`), reorder, delete and a category move. A set that failed
before the change is not locked: you can always delete or fix the rule at
fault.

## 9. Refusal codes

| Code | gRPC / HTTP | When |
|---|---|---|
| `REDIRECTS_FORBIDDEN` | PermissionDenied / 403 | not owner or admin |
| `PAT_SCOPE_MISSING (required_scope: …)` | 403 | the token lacks the scope |
| `REDIRECT_LIMIT_REACHED` | ResourceExhausted / 429 | the plan's `redirects_max` (§11), incl. `pin_rules`; the body carries `"limit_code": "redirects_max"` — only another plan helps |
| `REDIRECT_PATTERN_LIMIT_REACHED` | ResourceExhausted / 429 | the plan's `redirect_masks_max`; `"limit_code": "redirect_masks_max"` |
| `REDIRECT_CEILING_REACHED` / `REDIRECT_PATTERN_CEILING_REACHED` | ResourceExhausted / 429 | the hard ceiling (20000 rules / 200 masks) whatever the plan; no `limit_code` |
| `REDIRECT_LOOP` | InvalidArgument / 400 | the chain returns to itself (§8) |
| `REDIRECT_CHAIN_TOO_LONG` | InvalidArgument / 400 | the chain is finite but longer than 10 hops (§8) |
| `REDIRECT_RESERVED_PATH` | 400 | a service path (`/cart`, `/api`, `/robots.txt`, `/_next`…) |
| `REDIRECT_INVALID_SOURCE` / `REDIRECT_INVALID_TARGET` | 400 | not a path; query in the source; bad `$n` |
| `REDIRECT_EXTERNAL_NOT_ALLOWED` | 400 | absolute URL without `external` |
| `REDIRECT_INVALID_QUERY` | 400 | condition not `key=value`, or on a non-exact rule |
| `REDIRECT_DUPLICATE` | AlreadyExists / 409 | same kind, key and condition |
| `REDIRECT_IMPORT_TOO_LARGE` | 400 | more than 1000 lines |
| `REDIRECT_NOT_FOUND` | NotFound / 404 | unknown or foreign id; a reorder list that does not name every live rule once; journal miss |
| `PAT_CONTOUR_MISMATCH` | PermissionDenied / 403 | a draft key tried to write (rules, settings, the 404 journal, a category move) |
| `DEV_MODE_UNAVAILABLE` | FailedPrecondition | the same write from a cabinet session in the draft contour |

## 10. Silently ignored

- `enabled` left out → a switched-off rule, 200, never fires.
- `sort_order` in the input — there is none; a new prefix is placed above a
  prefix that would shadow it, everything else goes to the tail.
- `kind` that contradicts `from_path` is refused, not ignored; but an empty
  `kind` on `/goods/*` makes a prefix.
- `drop_query` never drops ad tags (§5).
- `url_changes` beyond 20 are not listed; the counters and pinning still
  count them.

## 11. Limits

```json
{
  "rules_ceiling": 20000,
  "patterns_ceiling": 200,
  "tariff_limits": ["redirects_max", "redirect_masks_max"],
  "import_rows_max": 1000,
  "from_max_chars": 1024,
  "to_max_chars": 2048,
  "note_max_chars": 500,
  "query_match_max_chars": 256,
  "chain_hops_max": 10,
  "pattern_stars_max": 9,
  "move_url_samples_max": 20,
  "codes": [301, 302],
  "kinds": ["exact", "pattern", "prefix"],
  "covered_by": ["auto", "rule", "fallback", "none"],
  "miss_key_max_chars": 512,
  "miss_new_paths_per_day": 1000,
  "miss_rare_retention_days": 90,
  "miss_retention_days": 365,
  "miss_page_max": 200,
  "miss_hide_paths_max": 500
}
```

**Plan limits.** Rules and masks are limited by the shop's plan: `redirects_max`
(seeded Free 200, Pro 2000) and `redirect_masks_max` (Free 20, Pro 50); the
platform owner may change them. A plan without the row (Corporate) is limited
only by the ceilings. The shop's current values come with
`GET /storefront-redirects` (`rules_limit`, `patterns_limit`; 0 = the plan does
not limit). Only **adding** is refused: editing, deleting, reordering and an
import of updates work above the limit (after a downgrade).

## 12. 404 log

The storefront records every address that ended in «not found» after all of
§5 — the live page, the slug journal, the rules and the fallback — so the shop
can turn it into a rule.

- **Kept:** per address and day — hits, last seen, the **host** of the last
  referrer (no path, no query), a hidden flag. No visitor IP, no user agent.
  The address is the comparison key of §4.1 (decoded, ASCII lower-case, no
  trailing `/`): a rule with `from_path` = `path` matches it exactly.
- **Not kept:** bot junk — script, config, dump, backup and archive extensions
  (`.php`, `.asp`, `.jsp`, `.env`, `.sql`, `.bak`, `.zip`…), scanner folders
  (`wp-admin`, `wp-content`, `xmlrpc`, `cgi-bin`, `phpmyadmin`…), hidden files
  and folders (`/.env`, `/.git`, `/.well-known`), `apple-touch-icon*`, keys
  longer than 512 bytes or with invisible characters; service paths (§9
  `REDIRECT_RESERVED_PATH`), the home page, draft hosts, closed sites, embedded
  fragments. Old `index.php?ID=` addresses of a previous CMS are migrated with
  CSV (§4.3), not from the log.
- **Timing and caps:** sent in batches every few seconds with the hit
  counters, so an address appears within ~10 s; at most 60 new addresses per
  shop per minute from one storefront process and 1000 new addresses per shop
  per day (known ones keep counting). Day rows with one hit expire after 90
  days, any row after a year.
- **Read** — `GET /storefront-redirects/misses` (`redirects:read`): `q`
  (substring, case-insensitive), `sort` `hits` (default) | `last_seen`,
  `include_hidden`, `limit` (≤ 200, default 50), `offset`. Answer:
  `{"result":{"items":[{"path":"/old/page","hits":"9","last_seen":"…",
  "referrer":"yandex.ru","hidden":false,"rule_id":"0"}],"total":2,"hidden":1}}`
  — `hits` summed over the kept days; `rule_id` is the rule that would catch
  the address now (0 = none; past hits stay after you add a rule); `total` —
  addresses under the filter; `hidden` — hidden addresses matching `q`.
- **Hide** — `POST /storefront-redirects/misses/hide` (`redirects:write`)
  `{"paths": ["/old/page"], "unhide": false}` → `{"updated": 1}`; ≤ 500 paths,
  normalised like the log; a hidden address stays hidden when hit again.
- **Rows into rules:** one address — `POST /storefront-redirects` with
  `from_path` = `path`; many addresses to one page — one import, a line per
  address `path;target;301;exact` (`dry_run` first). The plan limit applies.

## 13. How this was verified

- `go test ./internal/core/domain/` — matcher, masks, loop gate, the shared
  Go/TS vectors (`testdata/storefront_redirect_vectors.json`), the move
  planner (`TestPlanCategoryMove*`).
- `go test -tags=integration ./internal/api/catalog/ -run TestSFR` on
  PostgreSQL 17: CRUD, role and tenant gates, limits, import, check, the public
  set, the fallback setting, dry-run move (nothing changes), pinning every
  address and the atomic limit refusal, the pin gate for editors and tokens,
  the article slug journal; stage C: the tariff seed and refusals
  (`REDIRECT_LIMIT_REACHED`, masks, the ceiling on a plan without the row),
  the 404 log (merge by address and day, junk and unknown shops dropped,
  `version` untouched, search, sort, the rule mark, hide and unhide, the daily
  cap, the purge).
- Prod test 2026-09-25 (ВЕЗУ-ВЕЗУ): `TestStorefrontRedirectChainHopLimit`,
  `TestTraceStorefrontRedirectSentCode` (domain), vectors row 99 (10 hops ok,
  11 → `chain_too_long`), `TestSFRProd*` (integration: the 10/11-hop gate from
  both ends, `sent_code` in the check, notes kept on re-import).
- `internal/core/domain/testdata/storefront_miss_vectors.json` — the junk
  filter and the referrer host, shared by Go and the storefront.
- `go test ./internal/core/services/ -run TestRedirectScope` — scope,
  contour and role registries; `redirects:read` cannot write.
