# Foundry Map Page + Cesium: production canon

Статус: **действующий production-контракт**

Область: Module Foundry Map Page, Platform Map Gateway, общий TileCache, DC AMD Proxy и DC AMD Connector

Контрольная дата: 2026-07-16

Этот документ — воспроизводимый канон интеграции Cesium в NODE.DC. Он описывает не только текущий Foundry Map Page, но и обязательные границы для следующих продуктов NODE.DC, которым потребуются Cesium Terrain, imagery, 3D Tiles или другие разрешённые provider assets.

Текущий проверенный deploy baseline:

- `dc-amd-proxy-transport-lifecycle-20260716-005`;
- `platform-map-gateway-hot-path-20260716-015`;
- `module-foundry-map-hot-path-20260716-012`.

Идентификаторы baseline нужны для аудита. Их нельзя повторно использовать для новых изменений: каждый следующий deploy получает новый patch id и новый digest.

## 1. Главные инварианты

1. Browser никогда не получает Cesium Ion master token, asset-scoped token, Bing key, Map Gateway admin secret или egress/connector secret.
2. Foundry не хранит Cesium token в `.env`, runtime layout, Application Manifest, browser storage, deploy artifact или исходном коде.
3. Единственный владелец Cesium master token и provider endpoint credentials — private Platform Map Gateway.
4. Все browser-запросы карты идут same-origin через Foundry BFF. Browser не обращается напрямую ни к Map Gateway, ни к AMD Proxy, ни к Cesium/Bing.
5. Один Platform Map Gateway и один NAS-resident mutable TileCache обслуживают все Foundry Applications, страницы, пользователей и browser-инстансы.
6. Warm cache hit читается прямо с NAS и не требует Ion token refresh, AMD Proxy, Windows-машины, VPN или доступности Cesium.
7. Только cold miss или явный refresh проходит по внешнему маршруту `Map Gateway → DC AMD Proxy → DC AMD Connector → VPN → Cesium/Bing`.
8. NAS networking, DNS, default route, Tailscale и VPN не изменяются. Через AMD-машину идёт только allowlisted Cesium/Bing traffic.
9. TileCache не лежит в Git, Docker image layer или application volume. Это отдельный persistent NAS bind directory.
10. Записанный объект не удаляется автоматически и не перезаписывается без явного `nodedc_cache_refresh=1`.
11. Partial response никогда не становится cache entry. Object публикуется atomic rename, а fill считается завершённым только после durable snapshot индекса.
12. Все deploy-изменения доставляются data-only artifact через canonical runner: сначала `plan` точного файла, затем `apply` этого же файла. Live edit и ручной `docker compose up` на NAS не являются deploy-каноном.

## 2. Источники истины в репозитории

Канон должен обновляться вместе с этими файлами:

- Foundry BFF и admin settings: `server/catalog-server.mjs`;
- Foundry session/auth boundary: `server/nodedc-auth.mjs`;
- UI TileCache и health state: `apps/catalog/src/MapFixturePreview.tsx`;
- Cesium adapter и provider startup: `apps/catalog/src/CesiumMapRenderer.tsx`;
- Platform provider/cache boundary: `../../platform/services/map-gateway/src/server.mjs`;
- Map Gateway runtime topology: `../../platform/infra/synology/docker-compose.platform-http.yml`;
- NAS egress service: `../../platform/services/dc-amd-proxy/server.mjs`;
- AMD egress installation, recovery and migration runbook: `../../platform/services/dc-amd-proxy/OPERATIONS.md`;
- Windows connector: `../../platform/services/dc-amd-connector/`;
- одноразовое pairing: `../../platform/tools/dc-amd-pair/`;
- canonical artifact builders: `../../platform/infra/deploy-runner/`.

Если текст этого документа расходится с уже задеплоенным кодом, сначала фиксируется расхождение в Ops, затем синхронно исправляются код, тесты и этот канон. Нельзя молча считать устаревший текст runtime-поведением.

## 3. Архитектура и владельцы

```text
Authenticated browser
  │ same-origin GET/HEAD + session cookie
  ▼
Foundry BFF
  │ trusted subject header, no provider credential
  ▼
Platform Map Gateway
  ├─ warm hit ───────────────► NAS live TileCache ─► browser
  │
  └─ miss / explicit refresh
       │ private egress token
       ▼
     DC AMD Proxy on NAS 172.22.0.222:8790
       │ authenticated CONNECT transport
       ▼
     DC AMD Connector on Windows 172.22.0.183:8791
       │ workstation VPN exit
       ▼
     Cesium Ion / Cesium assets / Bing imagery
       │ streamed response
       ├─► browser immediately
       └─► private temp file ─atomic rename─► NAS TileCache
```

### 3.1 Responsibility table

| Компонент | Владеет | Не владеет |
| --- | --- | --- |
| Map Page | provider-neutral scene, presentation, camera, cache intent | tokens, upstream host policy, cache files, VPN |
| Cesium adapter | преобразование scene в Cesium objects, запрос allowlisted asset endpoints | master token, direct provider networking |
| Foundry BFF | user session, same-origin proxy, admin authorization, HMAC signing | provider credentials, TileCache, VPN route |
| Map Gateway | provider allowlist, credential injection, endpoint metadata, TileCache policy/index, upstream diagnostics | UI layout, Windows VPN configuration |
| NAS live TileCache | общие immutable-by-default provider objects и индекс | Git source, per-user state, offline license |
| DC AMD Proxy | узкий authenticated egress, pooling, retry/redirect safety, transport metrics | general proxying, NAS route, provider token persistence |
| DC AMD Connector | CONNECT byte forwarding через текущий Windows VPN | provider URL parsing above host:443, master token, NAS routing |
| Windows workstation | стабильный LAN endpoint, Docker Desktop, VPN exit | Foundry/Map Gateway runtime state |
| deploy runner | root-owned secrets, runtime directories, atomic artifact apply/rollback | application UI, token entry |

### 3.2 Один writer для общего cache

Текущая topology предполагает один активный `map-gateway` process, который является единственным writer для `index.json`. Все Foundry-инстансы используют его через private Docker network.

Масштабирование Foundry горизонтально безопасно: cache общий. Горизонтальное масштабирование самого Map Gateway без доработки запрещено, потому что tile singleflight и coalescing индекса сейчас process-local. Перед запуском нескольких Gateway replicas нужен распределённый per-key lock и согласованный single-writer/transactional index.

## 4. Два независимых маршрута

### 4.1 Control plane: настройка Cesium Ion

```text
Foundry admin browser
  → PUT /api/platform-settings/cesium-ion
  → Foundry revalidates admin session
  → HMAC-signed PUT /api/map/admin/cesium-ion
  → Map Gateway verifies assets 1, 2, 96188
  → atomic private token write
```

Порядок принципиален:

1. UI принимает новое значение как password field по существующей HTTPS-сессии.
2. Foundry повторно проверяет server-side роль; скрытая кнопка сама по себе не является защитой.
3. Foundry не сохраняет token, а формирует HMAC v2 по method, pathname, timestamp, actor id, SHA-256 body и нормализованному Foundry referer.
4. Map Gateway принимает admin request только с валидной подписью и timestamp в пределах 60 секунд.
5. Candidate token проверяется параллельно по canonical assets:
   - `1` — Cesium World Terrain;
   - `2` — World Imagery/Bing endpoint;
   - `96188` — Cesium OSM Buildings.
6. Только если все три проверки успешны, рабочее значение атомарно заменяется.
7. Ошибочный candidate не уничтожает предыдущий рабочий token.
8. Browser получает только `configured`, `verification`, `updatedAt`, `updatedBy`; значение token не возвращается.

Успешная rotation увеличивает внутреннее поколение token, очищает in-memory и disk endpoint credentials для allowlisted assets и не трогает уже записанные tile objects. In-flight refresh старого поколения не может вернуть старый credential после rotation.

### 4.2 Data plane: рендер карты

При старте Map Page Cesium adapter независимо и параллельно запрашивает imagery, terrain и buildings. Отказ imagery не блокирует terrain; отказ buildings не останавливает остальную сцену.

```text
GET /api/map/runtime-config
GET /api/map-gateway/api/map/ion/assets/2/endpoint
GET /api/map-gateway/api/map/ion/assets/1/endpoint
GET /api/map-gateway/api/map/ion/assets/96188/endpoint
```

Endpoint response содержит публичный URL, attribution, тип и `credentialMode: "gateway"`. Затем Cesium `DefaultProxy` направляет все derived resources через:

```text
/api/map-gateway/api/map/cache?url=<encoded-provider-url>
```

Master token или asset credential в этом URL отсутствует. Map Gateway добавляет credential server-side только после cache lookup и только если действительно нужен upstream request.

### 4.3 Live domain layers

Map data-product binding разрешён только для live-data слотов шаблона:
`points` (`entity-stream`) и `zones` (`zone-stream`). Оба слота используют один
provider-neutral snapshot/patch transport, но разные renderer adapters.

`map.zone` не превращается в Cesium-specific сущность и не получает отдельный
ontology type `HGeoZone`. HGeoZone — имя проекционного adapter layer в Foundry:
Polygon/MultiPolygon facts пакетируются группами ограниченного размера в
`GroundPrimitive`, все кольца границ — в `GroundPolylinePrimitive`. Заливка,
граница и их прозрачности читаются из versioned Map presentation profile.
Per-entity `PolygonGraphics` для массового слоя геозон не является production
путём. Stable runtime entity id сохраняется для selection/focus, а изменение
цвета обновляет per-instance attributes без перестройки геометрии; изменение
толщины границы или cursor создаёт новое bounded batch generation.

## 5. Credential model

### 5.1 Cesium Ion master token

Canonical location внутри mutable Map Gateway volume:

```text
/var/lib/nodedc-map-live-cache/secrets/cesium-ion-token
```

Физически на NAS:

```text
/volume1/docker/nodedc-platform/map-gateway/live-tile-cache/secrets/cesium-ion-token
```

Directory создаётся с mode `0700`, token и metadata — `0600`. Private NAS file имеет приоритет над transitional `CESIUM_ION_TOKEN` bootstrap после restart.

Нельзя:

- помещать token в Foundry `.env`;
- собирать его во frontend bundle;
- вставлять в Application Manifest;
- отправлять в chat, Ops comment, diagnostic archive или deploy artifact;
- читать его обратно через UI;
- включать `live-tile-cache/secrets` в обычный tile export или SMB-операции.

### 5.2 Map Gateway admin secret

Runner-owned path:

```text
/volume1/docker/nodedc-platform/secrets/map-gateway-admin-secret
```

Он монтируется read-only в Foundry и Map Gateway как `/run/nodedc-secrets/map-gateway-admin-secret`. Это ключ межсервисной подписи, а не Cesium token и не продуктовая настройка. Он никогда не вводится пользователем.

### 5.3 Map egress proxy token

Runner-managed path:

```text
/volume1/docker/nodedc-platform/secrets/map-egress-proxy-token
```

Map Gateway читает его из read-only mount и ставит в `x-proxy-token` только при запросе к DC AMD Proxy. Browser и Windows connector его не получают.

### 5.4 AMD connector access token

Windows installer создаёт локальный random value в:

```text
C:\NODEDC\dc-amd-connector\runtime\connector-access
```

Одноразовый pairing script читает его внутри запущенного container и передаёт напрямую на NAS без печати. NAS сохраняет paired value в private runtime `dc-amd-proxy`; deploy artifact его не содержит.

### 5.5 Asset-scoped endpoint credentials

Ответ Ion endpoint API может содержать временный `accessToken` либо Bing key. Map Gateway хранит эти значения только в памяти и private files:

```text
live-tile-cache/ion-endpoints/<assetId>.json
```

Файлы имеют mode `0600` и считаются service-sensitive. Master token в них не записывается.

Перед выдачей endpoint browser-у Gateway удаляет credential query parameters. Перед upstream miss Gateway:

1. снова удаляет user-supplied `token`, `key`, `signature` и аналогичные параметры;
2. сопоставляет resource с endpoint по точному origin и наиболее длинному разрешённому path scope;
3. для file endpoint, например `tileset.json`, разрешает sibling resources только внутри его directory;
4. никогда не превращает root-level file endpoint в credential scope всего host;
5. добавляет соответствующий asset credential или Bing key.

Endpoint cache имеет TTL 300 секунд и refresh-ahead 60 секунд по умолчанию. Refresh одного asset singleflight-ится. Истёкший JWT не выдаётся, не сохраняется и не подставляется.

### 5.6 Referer restrictions

Если Cesium token ограничен URL/referer policy, production `FOUNDRY_PUBLIC_URL` должен совпадать с разрешённым Foundry origin. Foundry делегирует только собственный configured origin, а не произвольный browser header. Нормализованный referer входит в HMAC admin request и передаётся в provider requests.

Прямая terminal-проверка token без того же referer может вернуть `401`, хотя production flow работает. Каноническая проверка — через Foundry settings/Map Gateway, потому что она воспроизводит реальный транспорт и проверяет все три обязательных asset.

## 6. Физическое устройство TileCache

### 6.1 NAS paths

Mutable live cache:

```text
/volume1/docker/nodedc-platform/map-gateway/live-tile-cache
```

Container mount:

```text
/var/lib/nodedc-map-live-cache
```

Read-only offline snapshot:

```text
/volume1/docker/nodedc-platform/map-gateway/offline-snapshot
```

Container mount:

```text
/var/lib/nodedc-map-offline-snapshot
```

Обе host directory создаёт root-owned deploy runner. `docker compose down`, image rebuild и service restart их не удаляют.

### 6.2 On-disk format

```text
live-tile-cache/
├── index.json
├── objects/
│   └── <first-two-hash-chars>/
│       └── <sha256-cache-key>.bin
├── ion-endpoints/
│   ├── 1.json
│   ├── 2.json
│   └── 96188.json
└── secrets/
    ├── cesium-ion-token
    └── cesium-ion-token.metadata.json
```

`objects` и `index.json` — собственно TileCache. `ion-endpoints` и `secrets` — private service state; они не являются пользовательскими cache objects.

`index.json` version 1 содержит для каждого SHA-256 key:

- relative file path;
- byte count;
- content type;
- provider ETag, если он корректен;
- `savedAt`, `lastAccessAt`, `expiresAt`.

Binary object сохраняется с расширением `.bin` независимо от media type; content type берётся из индекса. Cache key — SHA-256 canonical provider URL.

### 6.3 Canonical URL and deduplication

До вычисления key удаляются:

- `nodedc_client_revision`;
- `nodedc_cache_profile`;
- `nodedc_cache_mode`;
- `nodedc_cache_refresh`;
- credential-like query parameters.

Query parameters сортируются. Bing subdomains `ecn.t0`…`ecn.t3` нормализуются в один logical host, чтобы одинаковый quadkey не создавал четыре копии.

Следствие: token rotation, новый browser build или другой Foundry Application не создают новый tile object для тех же provider bytes.

## 7. Product semantics of cache controls

### 7.1 `Кэшировать live-данные` выключено

Adapter добавляет `nodedc_cache_mode=passthrough`. Запрос всё равно проходит через Foundry и Map Gateway, поэтому credential/security boundary сохраняется, но live persistent cache не читается и не записывается.

Это не direct browser-to-Cesium режим.

### 7.2 Cache включён + `Не перезаписывать уже полученный cache` включено

Это основной steady-state режим:

- hit немедленно отдаётся с NAS;
- miss идёт к official upstream и после полного получения дописывается;
- существующий объект не заменяется даже после его provider TTL;
- отключение AMD/VPN не влияет на уже записанную область.

Галка не замораживает cache целиком. Она запрещает перезапись существующих key, но новые tiles, которые пользователь впервые открыл, продолжают долетать и записываться на NAS.

### 7.3 Cache включён + `Не перезаписывать` выключено

Adapter добавляет `nodedc_cache_refresh=1` в provider resource root. Derived requests текущего Application могут заменить существующие objects. Это явно разрешённый update mode, а не автоматический фоновый refresh всего cache.

Обновляются только ресурсы, которые реально запросил renderer; мировой dataset не обходится целиком.

### 7.4 `Обновить текущий viewport`

Это одноразовый refresh. Renderer пересоздаётся с `nodedc_cache_refresh=1`, помечает root provider resources текущего view, после первого render возвращается к сохранённой steady-state policy.

### 7.5 OSM Buildings в persistent TileCache

Asset `96188` не является отдельным browser-only слоем. Root `tileset.json` создаётся как Cesium `Resource` с тем же Gateway proxy и cache intent, поэтому все его derived resources остаются на общем маршруте:

```text
96188 endpoint
  → root tileset.json
  → external/nested tileset JSON
  → b3dm/i3dm/pnts/cmpt или glTF/GLB dependencies
  → Platform Map Gateway
  → NAS live TileCache
```

Endpoint metadata хранится отдельно в private `ion-endpoints/96188.json`; сами building objects имеют обычные credential-free SHA-256 cache keys и лежат в `objects/`. Проверка live NAS store подтвердила `b3dm` objects, а не только root metadata. Warm building tile не требует AMD/VPN и отдаётся тем же локальным NAS path, что imagery и terrain.

Renderer обязан принять asset `96188` только с `type=3DTILES` и `credentialMode=gateway`. Direct URL, browser token или отдельный cache для Buildings запрещены.

### 7.6 Gateway-level modes

| `MAP_CACHE_MODE` | Hit | Miss | Запись |
| --- | --- | --- | --- |
| `readwrite` | NAS | official upstream | да, после полного response |
| `readonly` | NAS | live pass-through | нет |
| `offline` | NAS/offline policy | `504` | нет и никакого egress |

Application UI не может изменить server-level `MAP_CACHE_MODE`.

### 7.7 Live vs offline profile

`nodedc_cache_profile=live` выбирает mutable store. `offline` выбирает read-only snapshot и никогда не допускает pass-through.

Offline mode дополнительно требует hostname в `MAP_GATEWAY_OFFLINE_PROVIDER_ALLOWLIST`. Пустой список по умолчанию означает осознанный запрет offline redistribution. Наличие технически сохранённых Cesium bytes само по себе не создаёт право на их offline distribution.

## 8. Request algorithm

Для каждого `/api/map/cache` Gateway выполняет следующий порядок:

1. Проверяет trusted subject, method и route.
2. Парсит и валидирует HTTPS target, host allowlist, URL length и отсутствие embedded credentials.
3. Нормализует cache profile/mode/refresh intent и удаляет их из provider URL.
4. Вычисляет credential-free canonical key.
5. Для `passthrough` пропускает persistent store и идёт к upstream через тот же security boundary.
6. Для normal mode сначала ищет object в выбранном store.
7. Если по этому key уже идёт fill/refresh, новый request становится follower и ждёт его commit.
8. Offline/legacy profile никогда не превращает miss в неожиданный upstream fetch.
9. Normal hit без explicit refresh сразу отдаётся с NAS, в том числе stale hit.
10. Только после отсутствия пригодного hit Gateway получает/обновляет endpoint credential и подставляет его в upstream URL.
11. `readonly` отдаёт live response без записи.
12. Range request проксируется как range и пока не записывается partial object.
13. Full miss/refresh запускает streaming fill.
14. При provider failure explicit refresh возвращает предыдущий cached object как `live-stale-upstream-error`, если он существует.
15. При заполненном cache cold miss показывается live как `live-pass-through-cache-full`; существующие objects не удаляются.

Этот порядок гарантирует ключевой fail-safe: warm cache не зависит от control plane и egress.

## 9. Concurrency and performance

### 9.1 Что происходит при 20 одновременных пользователях

Для одного и того же missing key:

1. Первый request становится leader и открывает один upstream stream.
2. Response body делится через stream tee.
3. Client branch начинает передаваться первому browser сразу после provider headers.
4. Cache branch независимо пишется во временный файл.
5. Остальные request того же key становятся followers и не создают новые provider downloads.
6. После atomic commit followers читают опубликованный NAS file.
7. Если один browser закрыл вкладку, server-owned cache fill не отменяется и продолжает обслуживать других.

Для разных keys requests выполняются параллельно. Capacity reservations сериализуют только расчёт общего объёма, а не весь network path.

### 9.2 Publication safety

Cache fill:

- пишет только в private `*.tmp`;
- считает фактические bytes и прерывает object сверх лимита;
- резервирует capacity с учётом replacement;
- делает atomic rename только после полного EOF;
- затем добавляет entry в memory index и ждёт durable index snapshot;
- при abort/error удаляет temp и не публикует index entry.

### 9.3 Index strategy

Warm hit не обновляет `lastAccessAt` и вообще не переписывает `index.json`. Это осознанное следствие no-eviction policy: запись полного O(N) JSON на каждый 20 KB tile делала warm views медленнее provider.

Завершения разных new objects объединяются в короткое 10 ms окно. Один atomic index snapshot включает все накопленные revisions; waiter каждого object завершается только после durable revision.

### 9.4 ETag and browser cache

NAS hit получает безопасный ETag: валидный provider ETag либо deterministic NODE.DC ETag. `If-None-Match` возвращает `304` без чтения body и без записи index.

Foundry BFF разрешает browser cache только для подтверждённых persistent hit/stale states:

```text
Cache-Control: private, max-age=300, stale-while-revalidate=60
Vary: Cookie
```

Provider pass-through, endpoint metadata, health, explicit refresh и errors получают `no-store`. Shared reverse proxy не должен раздавать authenticated map response между пользователями.

### 9.5 AMD connection pool

DC AMD Proxy держит отдельный HTTP/1.1 keep-alive pool на каждый approved origin:

- максимум 8 active sockets per origin;
- максимум 4 idle sockets per origin;
- LIFO reuse;
- keep-alive 30 секунд;
- excess requests ждут в agent queue за bounded socket pool; её фактический пик обязательно контролируется через `maxQueuedRequests`.

Это устраняет новый `NAS → AMD CONNECT → TLS` handshake для каждого tile. При насыщении throughput ограничивается VPN/provider, а не созданием неограниченного числа tunnel. `maxQueuedRequests`, queue/connection/TTFB/total timings показывают, где возникает задержка.

Только idempotent `GET`/`HEAD`, потерявший уже reused keep-alive socket, повторяется один раз. Fresh-socket failure не ретраится автоматически. При cross-origin redirect `Authorization` удаляется; master/API bearer не может уйти с `api.cesium.com` на assets/Bing origin.

### 9.6 Camera cache survey presets

У imagery, terrain и OSM Buildings нет одного общего integer zoom. Текущий runtime profile измерен так:

- World Terrain публикует availability до L19;
- Bing metadata допускает internal imagery L0…L20, но фактический выбор связан с terrain geometry;
- текущая иерархия OSM Buildings содержит 16 geometric-error bands от `77067.33978` до `2.351909` м.

Поэтому UI не обещает ложное соответствие `высота = provider zoom`. Он даёт десять рабочих региональных профилей высоты и покрытия:

| Профиль | AGL | Шаг витка | Потолок скорости | Радиус | No-wait ETA |
| --- | ---: | ---: | ---: | ---: | ---: |
| 1 | 80 м | 64 м | 256 м/с | 25 км | 33,3 ч |
| 2 | 160 м | 128 м | 512 м/с | 25 км | 8,3 ч |
| 3 | 320 м | 256 м | 1,024 км/с | 25 км | 2,1 ч |
| 4 | 640 м | 512 м | 2,048 км/с | 25 км | 31 мин |
| 5 | 1,28 км | 1,024 км | 4,096 км/с | 25 км | 7,8 мин |
| 6 | 2,56 км | 2,048 км | 5 км/с | 25 км | 3,2 мин |
| 7 | 5,12 км | 4,096 км | 5 км/с | 25 км | 1,6 мин |
| 8 | 10,24 км | 8,192 км | 5 км/с | 25 км | 48 с |
| 9 | 20,48 км | 16,384 км | 5 км/с | 25 км | 25 с |
| 10 | 40,96 км | 32,768 км | 5 км/с | 25 км | 13 с |

При горизонтальном FOV около 60° и почти nadir camera шаг `0.8 × height` даёт примерно 31% поперечного перекрытия. Перед стартом renderer считает реальный horizontal FOV из текущего frustum/aspect и при необходимости уменьшает шаг до `0.69 × visible cross-track width`; узкий viewport поэтому не создаёт gaps. Скорость — только верхний предел: при незавершённых tile requests route clock стоит. `No-wait ETA` показывает нижнюю границу без сети и без viewport clamp. Профиль не форсирует конкретный OSM LOD: реальный выбор остаётся за Cesium SSE traversal и зависит от viewport, pixel ratio, tile bounds и availability. При движении к detailed child Cesium обычно проходит ancestor resources, но полнота их durable cache проверяется только post-run audit.

Включение режима survey временно подготавливает `Imagery + Terrain + OSM Buildings`, выключает monochrome, ставит canonical Buildings SSE `16` и append-only TileCache. Только эти survey-owned overrides не сохраняются в page layout и восстанавливаются после выключения режима; остальные правки Inspector не теряются. Start заблокирован, пока все три provider root не готовы и свежий Gateway health не подтвердил `persistent=true`, `mode=readwrite`, `writePolicy=append-only-no-eviction`, `atCapacity=false`. На время движения renderer отключает Cesium culling/foveated deferral для Buildings, фиксирует camera почти в nadir и не продвигает маршрут, пока `globe.tilesLoaded` и `buildings.tilesLoaded` не завершат текущие requests. Terrain prefetch обрезается по точной arc-length выбранного радиуса. Любой provider/tile failure latch'ит provider как failed и останавливает проход; повторная попытка требует bounded renderer recreate. Непрерывное ожидание tiles ограничено 45 секундами активной вкладки, background time не учитывается. Это browser readiness, не доказательство NAS commit. После Stop исходные интерактивные request policies восстанавливаются.

Preset является conservative cache traversal, а не юридической гарантией полноты provider dataset: availability и provider revision могут измениться. Выбранный радиус завершает проход детерминированно, но для доказуемого покрытия конкретного полигона дополнительно нужен coverage ledger и post-run audit cache keys.

## 10. Timeouts, aborts and recovery

### 10.1 Foundry BFF

- headers deadline: 15 секунд по умолчанию;
- body idle deadline: 30 секунд без прогресса;
- body timeout сбрасывается каждым chunk;
- после upstream EOF медленный browser drain не ограничивается абсолютным таймером;
- browser abort отменяет BFF upstream request;
- ошибка после отправки headers закрывает stream, а не пытается отправить второй JSON response.

### 10.2 Map Gateway

- upstream timeout: 30 секунд по умолчанию;
- warm hit не запускает этот timeout;
- refresh failure с прежним object даёт stale fallback;
- client abort leader response не отменяет cache branch;
- headers-sent error уничтожает только response, не Gateway process.

### 10.3 DC AMD Proxy

- connector/TLS connection timeout: 20 секунд;
- provider headers/attempt timeout: 30 секунд;
- response body idle timeout: 30 секунд без bytes;
- abort проходит через queue, CONNECT, TLS, headers, redirect drain, retry и body;
- каждый logical request получает ровно один terminal accounting outcome;
- partial bytes учитываются отдельно от complete response bytes.

### 10.4 Cesium renderer

Provider startup независим. Render error не показывает стандартную Cesium modal с `[object Object]`; UI получает safe code и делает одну bounded попытку восстановить render loop. Постоянный GPU/browser fault не запускает бесконечный retry.

## 11. Authentication and authorization

Production Foundry всегда использует Launcher/Authentik session. Map tile burst не должен валидировать Launcher на каждый object, поэтому:

- validation TTL по умолчанию 20 секунд и зажат в production диапазоне 15–30 секунд;
- запросы одной session делят singleflight validation;
- transient Launcher error использует 2-секундный retry backoff;
- last-known-good identity допускается не более 30 секунд grace и только для `GET`/`HEAD`;
- mutation во время transient auth failure получает `503`;
- только явный `{ok:true, active:false}` удаляет session/cookie;
- timeout, network error и Launcher non-2xx не уничтожают рабочую cookie;
- session хранится process-local, поэтому после Foundry restart старая opaque cookie истекает и нужен новый handoff.

Foundry roles:

- `nodedc:module-foundry:admin` — Platform settings;
- `nodedc:module-foundry:user` — обычная работа без settings;
- `nodedc:module-foundry:blocked` — deny-first;
- `nodedc:module-foundry:access` — transitional user role;
- `nodedc:superadmin` и `user_root` — break-glass admin.

Raw groups не возвращаются browser-у. Map Gateway production route требует trusted `x-nodedc-user-id`, который создаёт только Foundry BFF/private health check. Gateway нельзя публиковать напрямую в Internet.

## 12. DC AMD route

### 12.1 NAS service

`dc-amd-proxy` работает с `network_mode: host`, но слушает только `172.22.0.222:8790`. Host networking нужен, чтобы one-time pairing видел реальный source IP Windows host; service не bind-ится на все NAS interfaces.

Он принимает:

- `GET /healthz` и `GET /status` — safe state/metrics;
- `POST /api/pair` — один раз и только с configured AMD LAN source;
- `GET|HEAD /proxy/cesium/fetch` — только с runner-synchronised map egress token.

Target обязан быть HTTPS port 443 и входить в фиксированный Cesium/Bing allowlist. Direct NAS egress fallback отсутствует (`directEgress: false`).

### 12.2 Windows connector

`dc-amd-connector` bind-ится к стабильному LAN IP `172.22.0.183:8791`, а не к OpenVPN address или public exit IP. Windows Firewall разрешает inbound только с NAS `172.22.0.222`.

Connector:

- принимает только authenticated HTTP `CONNECT`;
- разрешает только port 443 и тот же host allowlist;
- не расшифровывает end-to-end TLS и не видит Ion bearer;
- не является системным Windows proxy;
- не меняет routing/DNS/VPN других приложений;
- ограничен 1 CPU, 256 MB, 64 processes и 4096 file descriptors.

Docker Compose использует `restart: unless-stopped`. Docker Desktop на WSL2 восстанавливается через Startup launcher после входа configured Windows user. До sign-in это не unattended Windows service; в это окно warm TileCache продолжает работать, а cold misses ожидаемо недоступны.

### 12.3 Pairing and moving to another machine

Первичная установка выполняется из elevated PowerShell внутри проверенного connector package:

```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\install.ps1
```

После того как NAS proxy показывает `state=awaiting_pair`, на Windows запускается package из `platform/tools/dc-amd-pair`:

```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\pair-dc-amd-proxy.ps1
```

Ожидаемый результат: `AMD connector paired with NAS. No secret value was displayed.`

Перенос:

1. Выбрать новый стабильный LAN IP и проверить VPN на новой машине.
2. Установить тот же versioned connector package с явными `-BindAddress` и `-NasAddress`.
3. Проверить local container health и permitted CONNECT.
4. Подготовить новый NAS proxy artifact/config с новым connector host и pair source.
5. Реализовать и проверить runner-owned pair-rotation transition: текущий NAS pair write-once и не принимает fresh credential поверх существующего state.
6. Через этот transition вывести старую pairing identity, выполнить canonical `plan`, `apply`, one-time pairing и end-to-end validation.
7. Остановить старый connector только после успешного cold-request acceptance на новой машине.

Текущий runner ещё не предоставляет pair rotation как готовую операцию. До её появления миграция с fresh credential не является полностью канонической: нельзя вручную удалять NAS `runtime/connector-access`, копировать старый secret, ослаблять `/api/pair` или одновременно держать две pairing identity. Сначала создаётся отдельная Ops/change задача на audited rotation, затем выполняется перенос по `platform/services/dc-amd-proxy/OPERATIONS.md`.

## 13. Health and observability

### 13.1 Safe checks on NAS

```bash
curl -fsS http://172.22.0.222:8790/status

curl -fsS \
  -H 'x-nodedc-user-id: healthcheck' \
  http://127.0.0.1:18103/healthz

curl -fsS http://172.22.0.222:9920/healthz
```

Эти команды не печатают secrets. Для endpoint acceptance нужно выводить только safe fields, а не private files:

```bash
curl -fsS \
  -H 'x-nodedc-user-id: healthcheck' \
  http://127.0.0.1:18103/api/map/ion/assets/1/endpoint \
  | /usr/local/bin/docker exec -i nodedc-platform-map-gateway-1 \
      node -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>{const v=JSON.parse(s);console.log(JSON.stringify({ok:v.ok,assetId:v.assetId,type:v.type,credentialMode:v.credentialMode,cache:v.cache}))})"
```

Если конкретное имя container изменилось, использовать `docker compose ps`, а не угадывать новое имя.

### 13.2 Map Gateway health fields

`/healthz` возвращает:

- live/offline cache entry count and bytes;
- `mode`, `writePolicy`, `maxBytes`, `atCapacity`, `persistent`;
- hit/stale/miss/refresh/passthrough/fallback counters;
- upstream and egress request/failure counters;
- slow upstream count;
- index snapshot count;
- safe `lastFailure` и timestamp;
- `ionConfigured` и asset allowlist.

Он не возвращает token, provider URL с credential или endpoint credential.

### 13.3 AMD status fields

`/status` обязан показывать:

- `state=paired`;
- `forwarding=amd_connector_only`;
- `directEgress=false`;
- active/idle/queued sockets и pool limits per origin;
- logical, terminal, in-flight, complete, failed and aborted requests;
- opened/failed tunnels, socket reuse, bounded retries;
- complete/partial bytes;
- queue, connection, TTFB, retry, attempt and total timings;
- safe last error code.

Инвариант accounting:

```text
terminalRequests + inFlightRequests == requests
```

После завершившейся нагрузки `inFlightRequests` должен вернуться в `0`.

### 13.4 UI health state

Когда Inspector или Layers открыт, Foundry:

- проверяет runtime config и Gateway health каждые 15 секунд;
- держит только один concurrent health request;
- отменяет check через 10 секунд;
- различает runtime HTTP, health HTTP, invalid response, network и timeout;
- сохраняет last-known-good stats как `stale`, если новая проверка не прошла;
- не даёт запоздалому renderer health overwrite более свежий explicit poll.

Красное сообщение `gateway_not_configured` относится к отсутствию runtime profile, а не автоматически к AMD/VPN. Код ошибки должен сохраняться до UI, иначе оператор ищет проблему не в том слое.

## 14. Failure matrix

| Симптом / safe code | Слой | Что продолжает работать | Проверка / действие |
| --- | --- | --- | --- |
| `module_foundry_auth_required` | Foundry session | public health | пройти Launcher handoff; не обходить BFF |
| `module_foundry_auth_unavailable` | Launcher validation | bounded read-only grace, если ещё активен | Launcher health/internal token; mutation не повторять вслепую |
| `map_gateway_not_configured` | Foundry runtime config | Foundry shell | проверить `NODEDC_MAP_GATEWAY_INTERNAL_URL` и private Docker network |
| `map_gateway_headers_timeout` | BFF→Gateway | shell и уже browser-cached objects | Gateway health, CPU/I/O, request queue |
| `map_gateway_body_idle_timeout` | BFF stream | прочие resources | Gateway/AMD body progress, VPN stalls |
| `map_gateway_auth_required` | Gateway trusted subject | admin signed route/health с корректным header | BFF header boundary, не включать anonymous в production |
| `cesium_ion_not_configured` | Gateway credential store | existing object hits | admin settings; не добавлять token в Foundry env |
| `cesium_ion_token_verification_failed` | provider/control plane | предыдущий token и cache | assets 1/2/96188 permissions, Foundry referer, AMD route |
| `cesium_asset_not_allowed` | asset policy | canonical assets | изменить root-owned allowlist только с reviewed use case |
| `cesium_ion_endpoint_unavailable` | Ion API | warm object hits, usable cached endpoint | AMD state/timings, VPN, provider HTTP status, token/referer policy |
| `map_egress_amd_connector_not_paired` | NAS proxy pairing | warm hits | `/status`, затем controlled pairing |
| `map_egress_amd_connector_timeout` | NAS→Windows | warm hits/stale refresh fallback | Windows host online, LAN/firewall, connector container |
| `map_egress_amd_upstream_tls_timeout` | Windows/VPN→provider | warm hits/stale refresh fallback | VPN exit, DNS/provider reachability on Windows |
| `map_upstream_timeout` | Gateway upstream | warm hits/stale refresh fallback | distinguish queue/CONNECT/TLS/TTFB via AMD metrics |
| `map_cache_capacity_reached` | NAS capacity policy | old hits and live pass-through | provision capacity/export; do not delete arbitrary objects |
| `map_offline_snapshot_miss` | offline snapshot | other snapshot objects | expected miss; seed licensed dataset, never fall through live |
| `map_provider_offline_not_permitted` | provider policy | live mode if allowed | legal/provider review and explicit allowlist |
| `map_cache_object_too_large` | object guard | other objects/live policy | inspect asset type; change limit only after measured review |
| `live-stale-upstream-error` | explicit refresh failed | previous object | expected resilience; investigate upstream asynchronously |
| renderer `cesium_render_error` | browser/GPU/Cesium | shell and provider diagnostics | browser console/GPU; one recovery already attempted |

### 14.1 Reading latency correctly

- Высокий `queueMs`, нормальный connection/TTFB: pool saturated; проверить request fan-out и VPN throughput.
- Высокий `connectionMs`: дорогой NAS→AMD CONNECT или TLS setup; socket reuse недостаточен либо Windows/VPN нестабилен.
- Низкий connection, высокий `ttfbMs`: VPN/provider latency.
- Нормальный AMD timing, высокий Foundry body idle: проблема stream progress между Gateway и BFF либо NAS I/O.
- Много `cacheHits`, но медленная карта: проверить BFF/browser ETag, NAS read latency, renderer/GPU; AMD здесь не должен участвовать.
- `egressRequests` растёт на повторном одинаковом append-only viewport: cache key/refresh policy нарушена либо requests действительно относятся к новым tiles/LOD.

## 15. Canonical deploy and rollback

### 15.1 Pre-deploy validation

Foundry:

```bash
cd NODEDC_DESIGN_GUIDELINE
node --test server/*.test.mjs
npm run build
```

Map Gateway:

```bash
cd platform/services/map-gateway
npm run test:credential-boundary
npm run test:admin-token-boundary
npm run test:live-cache-fallback
npm run test:streaming-cache-fill
```

DC AMD Proxy:

```bash
cd platform/services/dc-amd-proxy
npm run test:connection-pool
docker compose config >/dev/null
```

Production images должны дополнительно собираться с нуля до публикации artifact. Ни один test fixture token не переносится в artifact.

### 15.2 Build data-only artifacts

Из repository root с новыми уникальными patch ids:

```bash
node platform/infra/deploy-runner/build-dc-amd-proxy-artifact.mjs \
  dc-amd-proxy-<change>-YYYYMMDD-NNN

node platform/infra/deploy-runner/build-map-gateway-artifact.mjs \
  platform-map-gateway-<change>-YYYYMMDD-NNN

node platform/infra/deploy-runner/build-module-foundry-artifact.mjs \
  module-foundry-<change>-YYYYMMDD-NNN
```

Artifact обязан содержать только `manifest.env`, `files.txt`, `payload/**`; запрещены secrets, live `.env`, runtime data, cache objects, symlinks, hooks и AppleDouble `._*`.

Создать SHA-256 sidecar, проверить tar listing и скопировать `.tgz` + `.sha256` через SMB в:

```text
/volume1/docker/nodedc-deploy/inbox
```

### 15.3 Plan before apply

На NAS:

```bash
sudo /usr/local/sbin/nodedc-deploy verify-install

sudo /usr/local/sbin/nodedc-deploy plan \
  /volume1/docker/nodedc-deploy/inbox/<exact-dc-amd-proxy-artifact>.tgz

sudo /usr/local/sbin/nodedc-deploy plan \
  /volume1/docker/nodedc-deploy/inbox/<exact-platform-artifact>.tgz

sudo /usr/local/sbin/nodedc-deploy plan \
  /volume1/docker/nodedc-deploy/inbox/<exact-module-foundry-artifact>.tgz
```

До `apply` оператор проверяет digest, component, type=`app-overlay`, payload/compose roots, services, runtime secret mounts, exact files и `state=new`. `sha-already-applied`, неожиданный file или component — стоп, а не повод обходить runner.

### 15.4 Apply order

```text
1. dc-amd-proxy
2. platform / map-gateway
3. module-foundry
```

Команды используют те же exact paths, что прошли `plan`:

```bash
sudo /usr/local/sbin/nodedc-deploy apply \
  /volume1/docker/nodedc-deploy/inbox/<exact-dc-amd-proxy-artifact>.tgz

sudo /usr/local/sbin/nodedc-deploy apply \
  /volume1/docker/nodedc-deploy/inbox/<exact-platform-artifact>.tgz

sudo /usr/local/sbin/nodedc-deploy apply \
  /volume1/docker/nodedc-deploy/inbox/<exact-module-foundry-artifact>.tgz
```

Нельзя использовать `apply latest`, wildcard или вручную распаковывать payload в live roots. Runner создаёт backup и выполняет component health gate. Ошибка одного слоя должна остановить цепочку до следующего apply.

### 15.5 Post-deploy acceptance

1. Все три containers `running` и `healthy`.
2. AMD `/status`: `paired`, `amd_connector_only`, `directEgress=false`.
3. Gateway `/healthz`: `cache.persistent=true`, ожидаемые paths/limits, `ionConfigured=true`.
4. Canonical endpoints 1, 2, 96188 возвращают соответствующие types и `credentialMode=gateway` без credentials.
5. Первый cold viewport начинает рисоваться до завершения disk commit.
6. Повторный viewport увеличивает cache hits, но не egress requests для тех же objects.
7. Выключение Windows VPN не ломает warm viewport; новый uncached viewport даёт диагностируемый transport error.
8. Explicit refresh при недоступном provider отдаёт старый object, если он был.
9. Foundry UI показывает last-known-good cache stats как stale, а не стирает их generic error.
10. Никакой token/key не встречается в browser Network response, logs, artifact listing или Ops comment.

## 16. Reuse in future NODE.DC products

Новый продукт с Cesium не копирует token handling или TileCache внутрь себя. Он подключается к существующей платформенной capability.

Обязательный шаблон:

1. Provider-neutral domain/page contract живёт в продукте.
2. Renderer adapter получает только same-origin runtime config и sanitised asset endpoint.
3. Product BFF подтверждает свою user/session policy и проксирует только разрешённые Map Gateway routes.
4. BFF передаёт trusted subject из server-side identity, а не browser header.
5. Все provider resources используют Gateway proxy; direct provider URL без proxy запрещён.
6. Новый asset id добавляется в reviewed allowlist с ожидаемым type и acceptance test.
7. Cache intent кодируется теми же `profile/mode/refresh` semantics.
8. Cache физически остаётся общим Platform cache; продукт не создаёт per-instance copy.
9. Admin token rotation остаётся одной Platform setting, а не повторяется в каждом продукте.
10. Health UI различает runtime, Gateway, cache и egress layers и сохраняет safe error code.

Если продукту нужен другой provider или offline dataset, сначала расширяется Platform Map Gateway policy. Нельзя просто добавить arbitrary host в frontend. Требуются license review, host/path allowlist, credential scope, cache policy, diagnostics и tests.

## 17. Explicit decisions and forbidden anti-patterns

### Решения

- Shared NAS cache выбран вместо cache на browser/Foundry instance.
- Append-only/no-eviction выбран для предсказуемости; capacity exhaustion не удаляет уже собранную карту.
- Cache-first выполняется до credential injection, чтобы offline resilience была реальной.
- First miss streaming выбран вместо «сначала полностью записать, потом показать».
- One Map Gateway writer выбран до появления distributed locking.
- AMD route ограничен provider hosts и не затрагивает NAS default networking.
- Endpoint credentials кэшируются private и refresh-ятся заранее, master token остаётся отдельным.
- Browser cache разрешён только для подтверждённых NAS hits и только private.

### Запрещено

- token в browser bundle, source, `.env` Foundry, Envoyer-like UI или Application artifact;
- direct Cesium calls из browser;
- proxy всего NAS/Windows traffic через VPN ради Cesium;
- open/general-purpose HTTP proxy на AMD или NAS;
- использование changing VPN IP вместо stable AMD LAN IP;
- cache в Docker writable layer или anonymous volume;
- отдельный TileCache на каждого пользователя/Application/Foundry replica;
- credential query в cache key;
- автоматический refresh каждого hit без product intent;
- полный `index.json` rewrite на каждый warm hit;
- новый CONNECT/TLS tunnel на каждый tile;
- безусловный retry non-idempotent request или fresh-socket failure;
- перенос `Authorization` через cross-origin redirect;
- absolute body deadline, который убивает большой, но прогрессирующий response;
- публикация partial file/index entry;
- автоматический LRU delete без отдельной принятой retention policy;
- silent direct NAS egress, если AMD/VPN недоступен;
- трактовка сохранённых Cesium bytes как разрешения на offline distribution;
- скрытие provider credits на внешнем/коммерческом surface. Текущее скрытие credit container допустимо только как зафиксированный sandbox debt;
- ручной `sudo`/live edit вместо exact runner `plan` + `apply`.

## 18. Known boundaries and next extensions

1. Range requests сейчас проходят live и не записываются как chunk cache. Range-aware cache добавляется только после измерения реальных 3D Tiles/Gaussian assets.
2. Runtime cache сам не prefetch-ит мир. Для offline regions нужен отдельный licensed seed/prefetch job и versioned export/import artifact.
3. Offline snapshot read-only; miss никогда не становится live request.
4. `index.json` подходит текущему single-writer scale. Multiple Gateway writers требуют нового storage contract.
5. Windows Docker Desktop recovery начинается после user sign-in. Если потребуется unattended SLA, connector должен стать отдельно спроектированным Windows service/host, а не скрытым изменением текущей схемы.
6. DC AMD Proxy можно расширять на новые потоки только отдельным policy change. Нельзя добавлять arbitrary URL forwarding к существующему Cesium route.
7. Provider attribution metadata сохраняется, но visible credit surface должен быть возвращён до внешнего release.

## 19. Definition of done for any Cesium change

Изменение считается завершённым только если:

- credential boundary доказан automated test;
- cache hit не делает provider/egress request;
- same-key concurrency делает один upstream fill;
- abort/partial response не публикует object;
- refresh failure сохраняет старый object;
- endpoint rotation не возвращает credential старого поколения;
- BFF корректно передаёт conditional headers и abort;
- session validation не умножается на tile count;
- AMD metrics сохраняют exact terminal accounting;
- logs не содержат URL query, token, cookie, session/user id или secret;
- production images собираются с нуля;
- data-only artifacts проходят inspection и checksum;
- canonical runner показывает ожидаемый `plan` до `apply`;
- post-deploy acceptance проверяет cold, warm, VPN-off и refresh-failure scenarios;
- Ops card и этот документ обновлены вместе с кодом.

Итоговая модель проста: **Platform Map Gateway владеет credential и общим NAS TileCache; Foundry владеет authenticated same-origin BFF и UI intent; AMD-машина предоставляет только узкий VPN egress. Warm data всегда возвращается локально, а live route используется только там, где cache действительно не хватает или оператор явно разрешил refresh.**
