NODEDC_PLATFORM/docs/ADR_L2_OWNED_EXTERNAL_CONNE...

275 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 <opaque writer binding>
```
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=<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. Порядок первой реализации
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/<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 он удаляется.