# ADR: L2-owned external connectors and provider-neutral Data Plane Статус: **accepted**. Дата: 2026-07-14. Владелец решения: NODE.DC Platform. ## Контекст NODE.DC — платформа разработки. Подключение внешнего API не должно создавать ещё один provider-specific сервис, в котором навсегда зашиты логика клиента, нормализация и правила отображения. Иначе каждый новый account или новый тип объекта превращается в изменение Platform runtime. Нужна одна повторяемая форма: Platform даёт безопасные границы, контракт хранения и выдачи данных; изолированный workflow NDC Agent L2 является адаптером конкретного API и остаётся редактируемым через предоставленный Codex/Engine MCP. Это решение заменяет provider-adapter часть `ADR_EXTERNAL_PROVIDER_DATA_PLANE.md` и все provider-specific runtime предписания `ADR_GELIOS_DATA_PLANE.md`. Они сохраняются как исторические черновики, но не являются каноном для новой реализации. ## Решение ### 1. Адаптер внешнего API принадлежит L2 Один изолированный L2 workflow представляет одно connection instance и является единственным местом для: - обращения к API поставщика через ссылку на credential из Engine; - выбора read-capability, cursor/pagination, rate limit, retry и расписания; - получения raw envelope, разбиения большого ответа на native batch-операции; - семантического mapping к версии ontology и формирования canonical facts; - idempotency key, watermark и lifecycle источника; - deploy/run/execution/trace через Engine MCP. L2 не хранит значение provider secret или writer token: он использует только Engine-bound credential references. Plaintext writer token выдаётся trusted provisioner ровно один раз при create/rotate binding и затем хранится только в Engine credential store, привязанном к этому L2; External Data Plane хранит лишь его hash. Provisioner читает отдельный runner-owned secret file, который read-only монтируется только в EDP и позднее в dedicated Engine provisioner, работающий под выделенным UID/GID `11006`; это не shared `.env`, не `NODEDC_INTERNAL_ACCESS_TOKEN` и не provider credential. До deployment этого provisioner `EXTERNAL_DATA_PLANE_PROVISIONING_ENABLED=false`, поэтому issuance routes fail closed. Token не попадает в L2 graph/profile, contract artifact, execution log/trace, raw envelope, Foundry или UI. Command/write-capabilities не получают transport route в read workflow. Для новой учётной записи создаётся новый instance/profile этого workflow с другой credential reference и connection configuration. Это не требует изменения Platform source. Если поставщик раскрывает новый объект или поле, сначала расширяется ontology/capability contract, затем изменяется именно соответствующий L2 workflow. ### 2. Platform Data Plane нейтрален к provider и домену Platform предоставляет один versioned **External Data Plane**. Он принимает batch по внутреннему аутентифицированному контракту, хранит raw envelope с retention/provenance, canonical facts, current/history projections и отдаёт scoped data products/realtime updates. В нём запрещены: - ветвления по названию provider, customer, account, vehicle или renderer; - provider field mapping, unit allowlist, business filtering и visual rules; - provider token, прямой browser-to-provider доступ и command transport. Его canonical write-форма, которую Data Plane валидирует и сохраняет после авторизации, содержит только нейтральные элементы: ```text source: providerId + tenantId + connectionId contract: contractVersion + ontologyRevision + dataProductId batch: runId + sequence + idempotencyKey + receivedAt raw: optional restricted ref + contentType + hash + retention policy facts: stable sourceId + semanticType + observedAt + attributes + geometry? ``` Для нового L2 writer-а это не wire-форма. Он вызывает `POST /internal/data-plane/v1/data-products/:dataProductId/publish` с bearer writer token и формой `nodedc.data-product.publish/v1`, в которой вообще нет `source`, contract metadata, transport или persistence policy. Любой caller scope и scope headers запрещены. External Data Plane разрешает token в собственный immutable writer binding `(tenantId, connectionId, providerId, allowedDataProductIds, expiresAt)`, проверяет active/non-expired binding, совпадение `providerId` и разрешение `dataProductId`, затем сам materializes canonical `source` с tenant/connection и только после этого валидирует и сохраняет batch. Присланный caller scope отклоняется, а не merge/override-ится. Изменение tenant, connection, provider, allowed data products или срока требует нового binding; для существующего binding допустимы только token rotation и revoke. Выпуск, rotation и revoke binding принимаются только от dedicated provisioning principal с runner-owned secret file; он не монтируется в shared `.env` или L2. Shared legacy bearer не может создавать writer capability. Идентификатор источника стабилен в пределах `(providerId, connectionId, sourceId)`. Отсутствие объекта в очередном ответе помечается lifecycle-state или временем последнего наблюдения; запись и её история не удаляются. ### 3. Граница данных и интерфейса ```text Provider API (safe read) -> Engine provider-credential reference -> L2 connector instance (adapter + mapping + batches) -> Engine writer-credential reference -> External Data Plane Data Product publish (scope materialized server-side) -> External Data Plane (generic persistence + projections) -> scoped data product / realtime stream -> Foundry binding / user interface ``` Ontology Core описывает semantic types, связи, capability catalog и mapping versions, но не хранит runtime payloads или секреты. Foundry получает только approved data product и решает presentation: pins, visibility, layers and filters. Оно не получает provider endpoint или credential. ### 4. Полнота без entity allowlist Collection profile выбирает разрешённые **read-capabilities**, а не список конкретных объектов. Если выбранный read endpoint возвращает все доступные источнику сущности, L2 передаёт все валидные элементы. Новые сущности добавляются автоматически; скрытие/отображение — задача data product/UI, а не сбора. Технические лимиты существуют только как размер batch, backpressure, quota и защита от повреждённого ответа, но не как бизнес-фильтр по ID. ### 5. Масштабирование и native NDC Agent nodes L2 не запускает отдельный execution на каждую сущность. Он использует native nodes NDC Agent: HTTP Request, Split Out, Split In Batches/Loop Over Items, Aggregate, Postgres (когда нужен private workflow state), Set/If/Merge, Schedule Trigger and Respond to Webhook. Code node допустим только как малый преобразователь boundary-shape, когда эквивалентной native node нет; он не становится скрытым сервисом или provider database. Shared telemetry/history не записывается L2 напрямую в физические таблицы Platform Postgres: это создало бы coupling к schema. Direct Postgres допустим для private state конкретного workflow или отдельной project-owned базы. Общий контур использует только External Data Plane contract и bulk batches. ### 5.1. Каталог custom nodes NODE.DC Встроенные узлы runtime сохраняют свои штатные названия (`HTTP Request`, `Schedule Trigger`, `Loop Over Items` и т. п.). Любой узел, код которого принадлежит NODE.DC, обязан одновременно выполнять три условия: - видимое имя начинается с `NDC `; - runtime type принадлежит package namespace `n8n-nodes-ndc.*`; - package-level test отклоняет публикацию узла без этого префикса и namespace. Первый канонический набор состоит из: - `NDC Data Product Publish` — runtime write boundary L2 → Data Plane; - `NDC Data Product Read` — scoped current snapshot read по reader grant; - `NDC Foundry Binding` — deploy/control-plane связь Data Product с `Application → Page → typed slot`, но не транспорт каждого realtime tick. `NDC Foundry Output` не используется как техническое имя: оно ошибочно подразумевает прямой transport L2 → renderer. Provider-specific adapter, mapping и collection profile остаются логикой конкретного L2 workflow на штатных nodes; они не оформляются как custom NODE.DC nodes и не добавляют provider branch в Data Plane или Foundry. `NDC Foundry Binding` отправляет только `nodedc.foundry.binding-upsert/v1`. Её постоянный L2 credential — отдельный revocable `ndc_fndbg_*` workload grant с allowlist точных `Application → Page → Binding → Slot → Data Product` targets. Короткоживущая `fnd1.*` capability интерактивного Foundry MCP, browser session, EDP writer/reader grant и `NODEDC_INTERNAL_ACCESS_TOKEN` для этой ноды запрещены. Runtime node не содержит URL, endpoint, provider ID, tenant ID, connection ID, token, contract version или history cadence. Узел выбирает только разрешённый Data Product; всё остальное materializes из opaque writer/reader binding и immutable Data Product definition. Package `n8n-nodes-ndc` устанавливается как штатный private community package в `/home/node/.n8n/nodes/node_modules`, чтобы n8n `PackageDirectoryLoader` сохранил namespace `n8n-nodes-ndc.*`. `~/.n8n/custom` и `N8N_CUSTOM_EXTENSIONS` запрещены для этого пакета: они создают namespace `CUSTOM.*`. Ручное копирование или `npm install` в живом container, patch Engine core и подмена built-in node запрещены. Production использует проверенный offline tarball, immutable release, atomic current/previous switch, read-only mount и restart всех n8n execution processes; acceptance завершается только когда Engine schema и MCP catalog возвращают точные package-qualified types. ### 5.2. Realtime delivery contract Новый writer использует: ```text POST /internal/data-plane/v1/data-products/:dataProductId/publish Authorization: Bearer ``` Body имеет schema `nodedc.data-product.publish/v1` и содержит только batch identity и canonical facts. Data Plane одной транзакцией: 1. проверяет idempotency; 2. обновляет current projection только более новым или изменившимся фактом; 3. применяет declarative history policy (`none`, `all`, `sampled`); 4. добавляет changed facts в durable patch outbox; 5. фиксирует монотонный cursor. Reader grant получает snapshot `nodedc.data-product.snapshot/v1`, затем подключается к SSE stream с `after=` или `Last-Event-ID`. Каждый outbox event имеет schema `nodedc.data-product.patch/v1`. Если cursor уже удалён retention policy, stream отвечает `409 resync_required`, и consumer повторяет snapshot. Browser не получает Data Plane token или scope: Foundry BFF разрешает persisted binding и читает server-only reader grant. `snapshot+patch` в v1 является **bounded product contract**. Current projection одного scoped Data Product не может содержать больше 5000 entity keys `(sourceId, semanticType)`. Snapshot возвращает целую согласованную projection и один barrier cursor из одной `REPEATABLE READ` transaction. Query-параметр `limit` — только защитный потолок, а не размер страницы: если полная projection не помещается, Data Plane отвечает `413 data_product_snapshot_limit_exceeded` и consumer не начинает patch stream с неполной базой. Наивная pagination current snapshot по `sourceId`, offset или независимо полученным page cursors запрещена: изменения между страницами могут быть пропущены или продублированы относительно patch cursor. Product с ожидаемой cardinality выше 5000 обязан **до включения realtime** выбрать один из двух отдельно версионируемых контрактов: - stable partitioning в несколько Data Products, где partition key является частью definition, а каждый partition имеет собственный полный snapshot и независимый patch cursor; - новый query delivery contract с явно определёнными snapshot barrier, continuation cursor, query scope и правилами перехода к patch stream. Такой query contract не входит в `nodedc.data-product.snapshot/v1` и не может быть имитирован полем `nextPageCursor`. До его отдельного утверждения runtime и `NDC Data Product Read` работают только с bounded products до 5000 сущностей. ### 6. Порядок первой реализации 1. Версионировать нейтральный intake/read contract и поднять External Data Plane без provider-specific logic. 2. Пересобрать существующий L2 proof в native-node pipeline: safe read → split/batch → semantic mapping → generic append → safe summary. Никаких provider ID filters и provider gateway endpoint. 3. Выполнить manual run, проверить execution/trace, idempotency and current projection; только затем включить Schedule Trigger. 4. Provision immutable writer binding, one-time place its token into an opaque Engine credential, then switch the L2 to `/data-products/:dataProductId/publish` and remove caller scope/headers and the shared legacy credential. 5. Подключить Foundry к data product current positions. История, геозоны и новые сущности добавляются отдельными L2 collection profiles and ontology revisions. ## Последствия - `services/-gateway` не является шаблоном для новых интеграций. Существующий experimental code не расширяется и не деплоится как часть этого решения. - Data Plane может иметь собственную БД/Timescale/PostGIS implementation, но физическая схема остаётся внутренней деталью neutral service, а не API для L2 или Foundry. - Inline raw запрещён в L2 → Data Plane v1: до отдельного raw-vault допускается только restricted ref/hash либо отсутствие raw envelope. Retention reference вычисляется по серверному acceptance time, а не по timestamp из batch; Data Plane выполняет bounded retention sweeps после готовности сервиса и по таймеру. - Обычный Codex Desktop получает ровно те L2 grants, которые выданы владельцем connection/workflow, и может безопасно развивать adapter только в этой границе. - `POST /internal/data-plane/v1/intake` остаётся временным migration-only route: он выключен по умолчанию (`EXTERNAL_DATA_PLANE_LEGACY_INTAKE_ENABLED=false`), требует `NODEDC_INTERNAL_ACCESS_TOKEN`, canonical scoped batch и совпадающие scope headers. Новый или migrated L2 не получает этот shared token и не использует fallback в legacy route. После миграции всех writers он удаляется.