feat(data-plane): add provider contracts and ontology delivery
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
# External Provider Contract
|
||||
|
||||
Этот package — единственное общее место для формы внешних интеграций NODE.DC.
|
||||
Он не содержит provider secrets, customer records, runtime payloads или
|
||||
исполняемый connector code. `src/index.mjs` даёт dependency-free проверку
|
||||
минимальных v1 contracts; она запускается через `npm run check`.
|
||||
|
||||
Каждый L2 connector instance должен поставлять совместимые versioned
|
||||
артефакты:
|
||||
|
||||
```text
|
||||
provider-manifest
|
||||
connection-profile
|
||||
capability-catalog
|
||||
field-catalog
|
||||
collection-profile
|
||||
retention-profile
|
||||
semantic-mapping-contract
|
||||
read-model-contract
|
||||
command-catalog (metadata only until a red command gateway is approved)
|
||||
```
|
||||
|
||||
`provider-manifest` — декларативный template-артефакт: provider ID, ontology
|
||||
revision, L2 template version, capability catalog и data-product IDs. Он не
|
||||
является tenant connection, не содержит endpoint/URL, credential reference,
|
||||
secret, ручной scope или executable provider code. Concrete non-secret
|
||||
connection profile принадлежит и версионируется вместе с конкретным L2
|
||||
workflow; Engine только привязывает к нему opaque credential reference и grant.
|
||||
|
||||
## Проверяемые v1 contracts
|
||||
|
||||
- `Connection` — provider instance, tenant scope и *ссылка* на credential в
|
||||
Engine. Любые token/secret/password-like поля запрещены.
|
||||
- `Collection Profile` — явная policy сбора. `manual` не может скрыто содержать
|
||||
polling interval; `realtime` требует interval не чаще одного раза в секунду.
|
||||
- `Data Product` — нормализованный versioned output с semantic types, полями и
|
||||
внутренней аудиторией.
|
||||
- `Intake Batch` — canonical **scoped** record, который External Data Plane
|
||||
валидирует и сохраняет: source, contract revision, idempotency, restricted
|
||||
raw envelope и canonical facts. Его `source` содержит `providerId`,
|
||||
`tenantId` и `connectionId`. Writer-bound L2 request намеренно не является
|
||||
готовым `Intake Batch`: Data Plane сначала materializes scope и лишь затем
|
||||
применяет этот contract. В canonical record нет provider field mapping,
|
||||
entity allowlist, token или renderer data. Inline `raw.payload` в v1
|
||||
запрещён: если нужна provenance-ссылка, connector передаёт restricted
|
||||
`raw.ref` вместе с hash. Отдельный raw-vault может быть добавлен только
|
||||
отдельным ADR и не становится частью L2 → Data Plane wire boundary.
|
||||
- `NDC Foundry Binding` — адресует data product только в конкретную цепочку
|
||||
`Foundry Application → Page → approved slot`; `templateId` можно сохранить
|
||||
как дополнительную типизацию, но он не заменяет `applicationId` и `pageId`.
|
||||
В binding запрещены provider transport, endpoint и credential reference.
|
||||
- `Provider Manifest` — статическое описание L2 connector template, capability
|
||||
catalog и ontology/data-product contracts. Оно не может содержать tenant,
|
||||
connection, credential, secret или provider transport.
|
||||
|
||||
## Data Product delivery contracts
|
||||
|
||||
Provider-neutral runtime использует отдельные wire schemas:
|
||||
|
||||
- `nodedc.data-product.publish/v1` — unscoped publish request от
|
||||
`NDC Data Product Publish`; содержит только batch identity и canonical facts;
|
||||
- `nodedc.data-product.snapshot/v1` — согласованный current snapshot с cursor;
|
||||
- `nodedc.data-product.patch/v1` — committed upsert operations из durable
|
||||
outbox с `previousCursor`/`cursor`.
|
||||
|
||||
Publish request не может задавать provider, tenant, connection, endpoint,
|
||||
receivedAt, ontology revision, version или persistence policy. Data Plane
|
||||
materializes эти значения из opaque writer binding и зарегистрированного Data
|
||||
Product definition. Snapshot/stream читаются только по отдельному opaque reader
|
||||
binding; shared internal bearer и caller-provided scope headers являются legacy
|
||||
и не используются новыми nodes/Foundry runtime.
|
||||
|
||||
`snapshot+patch` v1 — bounded contract: один scoped Data Product содержит не
|
||||
более 5000 current entity keys `(sourceId, semanticType)`. Snapshot обязан быть
|
||||
полным и привязанным к одному repeatable-read cursor. Параметр `limit` задаёт
|
||||
защитный ceiling, не page size: превышение возвращает
|
||||
`413 data_product_snapshot_limit_exceeded`, а reader не имеет права начинать
|
||||
stream с усечённой базой. `nextPageCursor` зарезервирован для будущего отдельно
|
||||
версионируемого query contract и текущим bounded runtime не выдаётся.
|
||||
|
||||
Для большей cardinality definition заранее раскладывается по стабильным
|
||||
partition Data Products с независимыми snapshot/patch cursors либо использует
|
||||
будущий query contract с единым snapshot barrier и continuation semantics.
|
||||
Offset/source-ID pagination поверх меняющегося current snapshot запрещена:
|
||||
между страницами она способна потерять или задублировать изменения относительно
|
||||
patch cursor.
|
||||
|
||||
Для каждого canonical fact действует одинаковый hard ceiling: сериализованный
|
||||
`attributes` не больше 64 KiB как на publish/intake входе, так и в
|
||||
snapshot/patch выходе. GeoJSON `Point` принимает longitude только в
|
||||
`[-180, 180]`, latitude в `[-90, 90]`; batch sequence ограничен диапазоном
|
||||
PostgreSQL `integer` `0..2147483647`. Эти ограничения нельзя ослабить опциями
|
||||
конкретного caller-а.
|
||||
|
||||
Manifest, connection/collection profiles, data-product definition и Foundry
|
||||
binding используют fail-closed allowed-key schemas. Неизвестные поля, а также
|
||||
secret-like имена или значения (включая `ndc_edpwb_`/`ndc_edprb_`) отклоняются
|
||||
на общей границе.
|
||||
|
||||
Все private custom nodes NODE.DC поставляются package
|
||||
`platform/packages/n8n-nodes-ndc`. Их display name обязан начинаться с `NDC `,
|
||||
а runtime type — с `n8n-nodes-ndc.`; package называется строго
|
||||
`n8n-nodes-ndc`. Provider-specific adapters остаются L2 workflow logic и не
|
||||
становятся custom nodes или ветками Data Plane. Эти инварианты проверяются
|
||||
package test.
|
||||
|
||||
Control-plane команда `NDC Foundry Binding` имеет отдельную replay-safe schema
|
||||
`nodedc.foundry.binding-upsert/v1` (`validateFoundryBindingUpsert`). Это не
|
||||
declarative provider artifact и не runtime transport: команда содержит только
|
||||
application/page/binding/data-product projection и idempotency key, а право на
|
||||
операцию извлекается Foundry из отдельного opaque workload grant.
|
||||
|
||||
## Engine opaque credential sink
|
||||
|
||||
`src/engine-credential-sink.mjs` задаёт dependency-free server-to-server v1
|
||||
границу для доставки трёх workload capabilities в Engine Credentials:
|
||||
|
||||
- `external-data-plane.writer` → точная нода
|
||||
`n8n-nodes-ndc.ndcDataProductPublish` / `ndcDataProductWriterApi`;
|
||||
- `external-data-plane.reader` → точная нода
|
||||
`n8n-nodes-ndc.ndcDataProductRead` / `ndcDataProductReaderApi`;
|
||||
- `foundry.binding` → точная нода
|
||||
`n8n-nodes-ndc.ndcFoundryBinding` / `ndcFoundryBindingApi`.
|
||||
|
||||
Provision request фиксирует `workflowId`, `workflowRevision`, `nodeId`, runtime
|
||||
node type, credential type, grant ID, expiry и issuer policy hash. Aggregate
|
||||
`transaction.policyHash` детерминированно считается по всему secret-free
|
||||
descriptor; подмена любой цели или policy ломает валидацию. Единственное поле,
|
||||
которое переносит plaintext capability, — `bindings[].material.value`; request
|
||||
нельзя писать в логи, traces, очередь или audit.
|
||||
|
||||
Provision и rollback envelopes действуют не более 15 минут: sink отклоняет
|
||||
истёкшие запросы и допускает максимум 60 секунд положительного clock skew.
|
||||
Receipt повторно проверяет freshness относительно собственного `processedAt`,
|
||||
чтобы старый запрос нельзя было применить или подтвердить через replay.
|
||||
|
||||
Каждый binding содержит `capabilityDigest = sha256(material.value)`: Engine
|
||||
самостоятельно хеширует полученный plaintext и сравнивает digest. Aggregate
|
||||
`policyHash` включает этот digest и issuer identity, а provision transaction
|
||||
несёт обязательную Ed25519 attestation. Engine принимает её только по
|
||||
allowlisted `serviceId:keyId`; заменить capability и пересчитать обычный hash
|
||||
без приватного issuer key невозможно.
|
||||
|
||||
Sink обязан выполнять `rollback-all`: сначала проверить весь request и точное
|
||||
состояние graph, затем создать credentials в staging, атомарно привязать весь
|
||||
набор и только после commit вернуть opaque `credentialRef`. При любой ошибке
|
||||
новые credentials удаляются, а прежние bindings остаются без изменений.
|
||||
`rollback-failed` означает карантин и ручное восстановление, но никогда не
|
||||
возвращает частичные credential refs.
|
||||
|
||||
Receipt, rollback request/receipt и audit имеют отдельные strict schemas. Они
|
||||
не способны вернуть capability material; audit хранит только hash opaque
|
||||
credential reference. Explicit rollback адресует committed transaction через
|
||||
`transactionId`, `policyHash` и hash committed receipt, поэтому не может
|
||||
случайно откатить другой набор. Реализация sink принадлежит Engine и не даёт
|
||||
Platform/Codex доступа к Engine core, runtime files или plaintext credential
|
||||
storage.
|
||||
|
||||
## Engine private-extension management
|
||||
|
||||
`src/engine-private-extension.mjs` задаёт строгую Platform-side границу для
|
||||
Engine-owned активации проверенного `n8n-nodes-ndc` release. Контракт не
|
||||
устанавливает package и не меняет Engine: он фиксирует async
|
||||
`plan -> apply receipt -> operation/status` protocol, где `apply` обязан
|
||||
быстро вернуть `queued` и `operationId`, а долгий recreate/acceptance
|
||||
отслеживается отдельно.
|
||||
|
||||
Активация и rollback требуют отдельной глобальной capability
|
||||
`engine.private-extension.manage`. Обычные L1/L2 grants её не дают. Чтение
|
||||
состояния допускает `engine.private-extension.read` или manage-capability.
|
||||
Запрос выбирает только allowlisted package, digest-bound `releaseId` и
|
||||
`packageSha256`; caller не передаёт host path, package bytes, Compose service,
|
||||
shell command или credential material. Plan живёт не более 15 минут, является
|
||||
single-use и применяется только с тем же idempotency key и plan hash.
|
||||
|
||||
Runtime transition для n8n 2.3.2 зафиксирован как community-package loader по
|
||||
`/home/node/.n8n/nodes/node_modules/n8n-nodes-ndc`, а не как
|
||||
`N8N_CUSTOM_EXTENSIONS` или `CUSTOM.*` loader:
|
||||
|
||||
- `N8N_COMMUNITY_PACKAGES_ENABLED=true`,
|
||||
`N8N_COMMUNITY_PACKAGES_PREVENT_LOADING=false`,
|
||||
`N8N_REINSTALL_MISSING_PACKAGES=false`;
|
||||
- Deploy/Run quiesced and execution queue drained before the version switch;
|
||||
- read-only mount and atomic current/recovery state;
|
||||
- force-recreate main, every worker and every webhook instance as one version
|
||||
barrier; hot reload запрещён;
|
||||
- acceptance требует единый generation и точный набор трёх package-qualified
|
||||
node schemas и трёх credential schemas.
|
||||
|
||||
Любая ошибка после switch запускает automatic rollback и повторный
|
||||
force-recreate/acceptance. Ошибка самого rollback переводит runtime в
|
||||
`quarantined`. Immutable release и существующие Engine Credentials
|
||||
сохраняются. Для первой активации предыдущим проверенным состоянием является
|
||||
`n8n-nodes-ndc.inactive/v1`: rollback в этот baseline удаляет package из
|
||||
loader surface, но не удаляет credentials.
|
||||
|
||||
Public Ops gateway уже умеет прозрачно передавать эти операции через
|
||||
`/engine/mcp`, если Engine реализует соответствующие MCP tools. Так как
|
||||
gateway имеет 30-second upstream timeout, side effect остаётся асинхронным;
|
||||
отдельный public REST proxy для management boundary не требуется.
|
||||
|
||||
Пример `examples/gelios-positions-current.v1.mjs` — provider-specific fixture
|
||||
без customer, tenant identity или credential material.
|
||||
|
||||
## Ownership
|
||||
|
||||
- `platform/packages/external-provider-contract` — общий контракт и schemas.
|
||||
- NDC Agent L2 — provider API adapter: fetch, pagination, batching,
|
||||
semantic mapping, collection profile и ссылка на credential в Engine.
|
||||
- Platform External Data Plane — provider-neutral intake, raw retention,
|
||||
canonical facts, current/history projections и scoped read products. Он не
|
||||
знает provider fields, customer/business filters или renderer rules.
|
||||
- `platform/services/ontology-core/catalog/domain-packages/<provider>` —
|
||||
семантика, отношения и guardrails, но не runtime data.
|
||||
- Foundry/interface bindings — consumers scoped data product; они не получают
|
||||
provider transport, endpoint или credential reference.
|
||||
|
||||
`services/<provider>-gateway` — устаревший experimental path и не является
|
||||
шаблоном новых integration services. Канон описан в
|
||||
`docs/ADR_L2_OWNED_EXTERNAL_CONNECTORS.md`.
|
||||
|
||||
## Mandatory connection boundary
|
||||
|
||||
`connection` принадлежит одному tenant/client context и содержит только
|
||||
ссылку на секрет, утверждённый capability scope, collection profile, field
|
||||
policy и retention policy. Во всех runtime records Data Plane сохраняет минимум
|
||||
`tenant_id`, `connection_id`, `provider_id`, `observed_at`, `received_at` и
|
||||
provenance/version там, где это применимо.
|
||||
|
||||
Writer token — отдельный EDP runtime credential, а не поле `Connection`,
|
||||
profile, provider manifest или L2 graph.
|
||||
|
||||
## Scoped writer binding
|
||||
|
||||
Writer binding — EDP-owned runtime security state, а не versioned artifact
|
||||
provider manifest или connection profile. Он фиксирует `tenantId`,
|
||||
`connectionId`, `providerId`, `allowedDataProductIds`, active/revoked state и
|
||||
`expiresAt`. L2 не может редактировать binding или задавать его scope;
|
||||
изменение любого scope-поля либо TTL создаёт новый binding, а прежний binding
|
||||
можно только rotate/revoke.
|
||||
|
||||
Новый L2 вызывает `POST /internal/data-plane/v1/intake/writer-bound` с
|
||||
`Authorization: Bearer <writer-token>` и unscoped envelope. В `source`
|
||||
разрешён только `providerId`; `tenantId`, `connectionId` и
|
||||
`x-nodedc-*-id` headers запрещены. EDP проверяет token, binding, provider и
|
||||
`contract.dataProductId`, затем materializes immutable canonical scope и
|
||||
сохраняет обычный `Intake Batch`. Caller-provided scope отклоняется, а не
|
||||
доверяется и не объединяется с binding.
|
||||
|
||||
Plaintext writer token возвращается только trusted provisioner при создании
|
||||
или rotation. Provisioner помещает его непосредственно в opaque Engine
|
||||
credential, доступный назначенному L2. EDP хранит только hash token и binding
|
||||
metadata; token запрещён в connection/profile/manifest, L2 graph, logs/traces,
|
||||
raw payload, Foundry и UI.
|
||||
|
||||
Provisioning routes принимают только отдельный secret file, созданный
|
||||
root-owned deploy runner:
|
||||
`/volume1/docker/nodedc-platform/secrets/external-data-plane-provisioner/token`.
|
||||
Он read-only монтируется только в EDP и позднее — в dedicated Engine
|
||||
provisioner, работающий под выделенным UID/GID `11006`; это не `.env` value,
|
||||
не `NODEDC_INTERNAL_ACCESS_TOKEN` и не provider credential. Пока generic Engine
|
||||
provisioner не развёрнут, `EXTERNAL_DATA_PLANE_PROVISIONING_ENABLED=false`, а
|
||||
create/rotate/revoke routes отвечают `503` и не делают fallback к shared
|
||||
internal bearer.
|
||||
|
||||
## Legacy intake migration
|
||||
|
||||
`POST /internal/data-plane/v1/intake` — временный compatibility route для
|
||||
existing writers. Он принимает только canonical scoped `Intake Batch` под
|
||||
`NODEDC_INTERNAL_ACCESS_TOKEN` и требует совпадения body scope с
|
||||
`x-nodedc-tenant-id` и `x-nodedc-connection-id`. Writer token этот route не
|
||||
заменяет.
|
||||
|
||||
Для миграции: создать writer binding, один раз записать выданный token в Engine
|
||||
credential, переключить L2 на `/intake/writer-bound`, удалить tenant/connection
|
||||
из body и headers, проверить успешный intake, затем удалить у L2 legacy
|
||||
credential. Новый или migrated writer не должен fallback-иться на legacy route
|
||||
после ошибки writer-bound intake. Когда migrated все writers, legacy route и
|
||||
его shared-token access удаляются.
|
||||
|
||||
## Intake boundary
|
||||
|
||||
L2 отправляет в общий Data Plane `Intake Batch`, а не SQL-запрос в общие
|
||||
таблицы. Platform проверяет boundary и сохраняет raw/history/current
|
||||
projections; semantic mapping остаётся в L2. Список конкретных source IDs не
|
||||
является частью connection scope: scope выбирает read-capability API, а
|
||||
visibility решает data-product consumer.
|
||||
|
||||
Inline `raw.payload` запрещён: L2 не может записать в Data Plane полный ответ
|
||||
provider-а или произвольную строку. Пока отдельный raw-vault не утверждён,
|
||||
batch либо не содержит `raw`, либо содержит только restricted `raw.ref` и hash.
|
||||
Secret-like keys и распознаваемые bearer/JWT/writer-token values в любом участке
|
||||
canonical batch, включая `facts[].attributes`, отклоняются. Retention raw
|
||||
reference вычисляется от server acceptance time, а не от `batch.receivedAt`;
|
||||
service делает sweep expired envelopes при старте и по расписанию.
|
||||
|
||||
## Safety classification
|
||||
|
||||
Capabilities классифицируются как `read`, `metadata`, `write`, `destructive`
|
||||
или `unknown`. Только `read` и согласованные `metadata` могут попасть в
|
||||
collector. `write` и `destructive` остаются каталогизированными, но не имеют
|
||||
transport route в read adapter.
|
||||
Reference in New Issue
Block a user