18 KiB
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 валидирует и сохраняет после авторизации, содержит только нейтральные элементы:
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. Граница данных и интерфейса
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 использует:
POST /internal/data-plane/v1/data-products/:dataProductId/publish
Authorization: Bearer <opaque writer binding>
Body имеет schema nodedc.data-product.publish/v1 и содержит только batch
identity и canonical facts. Data Plane одной транзакцией:
- проверяет idempotency;
- обновляет current projection только более новым или изменившимся фактом;
- применяет declarative history policy (
none,all,sampled); - добавляет changed facts в durable patch outbox;
- фиксирует монотонный cursor.
Reader grant получает snapshot nodedc.data-product.snapshot/v1, затем
подключается к SSE stream с after=<snapshot.cursor> или 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. Порядок первой реализации
- Версионировать нейтральный intake/read contract и поднять External Data Plane без provider-specific logic.
- Пересобрать существующий L2 proof в native-node pipeline: safe read → split/batch → semantic mapping → generic append → safe summary. Никаких provider ID filters и provider gateway endpoint.
- Выполнить manual run, проверить execution/trace, idempotency and current projection; только затем включить Schedule Trigger.
- Provision immutable writer binding, one-time place its token into an opaque
Engine credential, then switch the L2 to
/data-products/:dataProductId/publishand remove caller scope/headers and the shared legacy credential. - Подключить Foundry к data product current positions. История, геозоны и новые сущности добавляются отдельными L2 collection profiles and ontology revisions.
Последствия
services/<provider>-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 он удаляется.