feat(ontology): add core service catalog

This commit is contained in:
Codex 2026-06-20 12:54:03 +03:00
parent 83cca4224b
commit 2d5fef3948
59 changed files with 7878 additions and 0 deletions

View File

@ -34,6 +34,7 @@ Git repo:
- `services/notification-core/README.md`
- `services/ai-workspace-assistant/README.md`
- `services/ai-workspace-hub/README.md`
- `services/ontology-core/README.md`
- `tasks/CODEX_PLATFORM_AUTH_TASK.md`
## Базовое правило
@ -43,3 +44,5 @@ Plane не переносится внутрь Launcher. Launcher не стан
Notification Core живёт в `services/notification-core` как отдельный платформенный сервис с собственным Postgres. Он не использует Authentik DB: Authentik отвечает за identity/session, Notification Core отвечает за events/deliveries/read-state.
AI Workspace Assistant живёт в `services/ai-workspace-assistant` как общий платформенный слой для пользовательских Codex executors, selected executor, shared conversations, surfaces и tool packs. AI Workspace Hub остаётся отдельным тонким транспортом для remote Codex workers и не хранит смысловое состояние ассистента.
Ontology Core живёт в `services/ontology-core` как docs-first семантический слой для canonical entities, relations, aliases, guardrails, evidence, первого resolver MVP между OPS и ENGINE и policy MVP для NDC Core Assistant access. Он не владеет доменными БД HUB/OPS/ENGINE и не заменяет Launcher/HUB roles или OPS Gateway enforcement.

View File

@ -0,0 +1,165 @@
# NODE.DC Ontology Core
Docs-first ontology service/module for NODE.DC.
Current status: `v0.4-pre` implementation slice.
This module owns:
- canonical entity catalog;
- relation catalog;
- alias registry;
- guardrails;
- evidence index;
- first context resolver rules for OPS -> ENGINE and ENGINE -> OPS.
- assistant access policy for NDC Core Assistant permissions.
- assistant action registry and risk policy for safe assistant-mediated operations.
It does not own:
- HUB users/clients/app access source data;
- OPS cards/projects source data;
- ENGINE workflows/runtime source data;
- OPS Gateway grants/tokens/scopes enforcement;
- runtime data, logs, dumps, storage, env, or secrets.
## Layout
```text
catalog/entities.json
catalog/relations.json
catalog/aliases.json
catalog/guardrails.json
catalog/evidence.json
catalog/resolver-rules.json
catalog/context-bindings.json
catalog/assistant-access-policy.json
catalog/assistant-actions.json
catalog/assistant-risk-policy.json
examples/*.json
docs/baseline/*.md
docs/CONTEXT_BINDINGS.md
docs/ASSISTANT_ACCESS_POLICY.md
docs/ASSISTANT_ACTION_REGISTRY.md
docs/ASSISTANT_CALLER_CONTRACT.md
docs/ASSISTANT_EXECUTION_GATEWAY.md
src/catalog.mjs
src/registry.mjs
src/validate.mjs
src/resolver.mjs
src/assistant-policy.mjs
src/assistant-action-resolver.mjs
src/assistant-action-plan.mjs
src/assistant-action-caller.mjs
src/assistant-action-executor.mjs
src/adapters/hub-launcher-admin.mjs
```
## Validate
```text
npm run validate
```
## Resolver Smoke
```text
npm run smoke:resolver
```
## Assistant Policy Smoke
```text
npm run smoke:assistant-policy
```
Resolve one assistant access input:
```text
npm run assistant:policy -- --input-json examples/assistant-access-admin-client.example.json
```
## Assistant Action Smoke
```text
npm run smoke:assistant-actions
npm run smoke:assistant-action-plan
npm run smoke:assistant-caller
npm run smoke:assistant-executor
```
Resolve one assistant action request:
```text
npm run assistant:action -- --input-json examples/assistant-action-block-user.example.json
npm run assistant:plan -- --input-json examples/assistant-action-block-user.example.json
npm run assistant:caller -- --input-json examples/assistant-caller-preview-block-user.example.json
npm run assistant:execute -- --input-json examples/assistant-action-block-user.example.json
```
The action resolver returns a policy decision such as `forbidden`, `denied`, `needs_confirmation`, `future_adapter`, or `allowed`.
The action planner returns a dry-run app-owned adapter request plan. It does not execute mutations.
The caller contract returns compact UI previews and confirmation tokens for write actions.
The executor validates the plan and defaults to `dry_run`; live write execution requires a matching confirmation token, internal gateway auth, and an implemented adapter.
## Resolve One Request
```text
npm run resolve -- --input-json examples/ops-card-to-engine-request.json
```
## Registry CLI
List known contexts:
```text
npm run registry -- list-contexts
```
Dry-run adding an Engine context:
```text
npm run registry -- add-context --input-json examples/engine-context.example.json --dry-run
```
After a real Engine `workflow_id` / `node_id` is known, create a real context JSON and run the same command without `--dry-run`.
Dry-run adding an OPS <-> ENGINE binding:
```text
npm run registry -- add-binding --input-json examples/ops-engine-binding.example.json --dry-run
```
Dry-run importing an Engine workflow manifest and optional OPS binding:
```text
npm run registry -- import-engine-context --input-json examples/engine-workflow-manifest.example.json --dry-run
```
The resolver returns:
- canonical input entity;
- matched source-system contexts;
- selected resolver rule;
- existing context bindings;
- missing binding types required for the selected route.
## First Implementation Target
The first useful resolver flows are:
- OPS -> ENGINE: resolve an OPS project/card request into Engine workflow/L1/L2 context.
- ENGINE -> OPS: resolve an Engine workflow/node request into target OPS project/card context.
Ontology Core advises context. OPS Gateway remains the permission/enforcement layer.
## Context Bindings
`catalog/context-bindings.json` is the bridge from stable ontology IDs to real source-system identifiers.
Current active seed contexts:
- OPS project `NDC PLATFORM`
- OPS card `Антология / Ontology Core`
Cross-surface bindings are intentionally empty until a real OPS card/project is linked to a real ENGINE workflow/node. The resolver already reports which binding type is missing, instead of pretending the link exists.

View File

@ -0,0 +1,36 @@
{
"version": "0.4-pre",
"updatedAt": "2026-06-19",
"aliases": [
{ "alias": "Plane Issue", "canonicalId": "ops.card" },
{ "alias": "issue", "canonicalId": "ops.card" },
{ "alias": "task", "canonicalId": "ops.card" },
{ "alias": "ticket", "canonicalId": "ops.card" },
{ "alias": "work item", "canonicalId": "ops.card" },
{ "alias": "карточка", "canonicalId": "ops.card" },
{ "alias": "задача", "canonicalId": "ops.card" },
{ "alias": "Authentik user", "canonicalId": "ndcauth.identity" },
{ "alias": "OIDC subject", "canonicalId": "ndcauth.identity" },
{ "alias": "JWT subject", "canonicalId": "ndcauth.identity" },
{ "alias": "Launcher service", "canonicalId": "hub.application" },
{ "alias": "app", "canonicalId": "hub.application" },
{ "alias": "application tile", "canonicalId": "hub.application_card" },
{ "alias": "app card", "canonicalId": "hub.application_card" },
{ "alias": "n8n workflow id", "canonicalId": "engine.workflow_l2_runtime_id" },
{ "alias": "runtime workflow id", "canonicalId": "engine.workflow_l2_runtime_id" },
{ "alias": "n8n execution", "canonicalId": "engine.l2_execution" },
{ "alias": "Engine-side OPS", "canonicalId": "engine.tech_debt.ops_layer" },
{ "alias": "Agent Monitor OPS", "canonicalId": "engine.tech_debt.ops_layer" },
{ "alias": "Tender Agent Monitor UI", "canonicalId": "engine.tech_debt.tender_domain_ui" },
{ "alias": "NOTG controller layer", "canonicalId": "future.interface_layer" },
{ "alias": "interface layer", "canonicalId": "future.interface_layer" },
{ "alias": "NDC Core Assistant", "canonicalId": "assistant.core_access" },
{ "alias": "Core Assistant", "canonicalId": "assistant.core_access" },
{ "alias": "assistant access", "canonicalId": "assistant.core_access" },
{ "alias": "assistant role", "canonicalId": "assistant.access_role" },
{ "alias": "assistant admin", "canonicalId": "assistant.access_role" },
{ "alias": "assistant action", "canonicalId": "assistant.action" },
{ "alias": "action card", "canonicalId": "assistant.action" },
{ "alias": "action registry", "canonicalId": "assistant.action_registry" }
]
}

View File

@ -0,0 +1,241 @@
{
"version": "0.1.0",
"updatedAt": "2026-06-19",
"status": "draft-source-evidenced",
"summary": "NDC Core Assistant access overlay. It controls assistant visibility and assistant-mediated capabilities, but never bypasses Launcher/HUB admin guards or OPS Gateway enforcement.",
"roleVocabulary": [
{
"id": "assistant_blocked",
"name": "Blocked",
"summary": "No assistant icon, no assistant surface, no assistant capabilities."
},
{
"id": "assistant_member",
"name": "Member",
"summary": "Default personal assistant access for active users inside their allowed contexts."
},
{
"id": "assistant_admin",
"name": "Admin",
"summary": "Assistant-mediated administration, limited by existing Launcher root/client admin scope."
}
],
"roleAliases": [
{ "alias": "blocked", "roleId": "assistant_blocked" },
{ "alias": "off", "roleId": "assistant_blocked" },
{ "alias": "disabled", "roleId": "assistant_blocked" },
{ "alias": "member", "roleId": "assistant_member" },
{ "alias": "participant", "roleId": "assistant_member" },
{ "alias": "admin", "roleId": "assistant_admin" },
{ "alias": "administrator", "roleId": "assistant_admin" }
],
"defaultResolution": {
"activeUserDefaultRole": "assistant_member",
"blockedUserEffectiveRole": "assistant_blocked",
"disabledMembershipRemovesContextAdminScope": true,
"superAdminDefaultRole": "assistant_admin",
"assistantRoleDoesNotGrantLauncherAdmin": true
},
"sourceAuthorities": [
{
"id": "hub.launcher_global_role",
"entityIds": ["hub.user", "hub.role"],
"source": "/Users/dcconstructions/Downloads/mnt/data/nodedc_launcher/src/entities/user/types.ts",
"summary": "LauncherGlobalRole includes root_admin, support_admin, client_owner, client_admin, member."
},
{
"id": "hub.client_membership_role",
"entityIds": ["hub.membership", "hub.role"],
"source": "/Users/dcconstructions/Downloads/mnt/data/nodedc_launcher/src/entities/user/types.ts",
"summary": "ClientMembershipRole includes client_owner, client_admin, member; membership status includes active/disabled."
},
{
"id": "hub.resolve_admin_scope",
"entityIds": ["assistant.admin_scope", "hub.membership", "hub.role"],
"source": "/Users/dcconstructions/Downloads/mnt/data/nodedc_launcher/server/dev-server.mjs#resolveAdminScope",
"summary": "Root scope comes from nodedc:superadmin/nodedc:launcher:admin groups. Client scope comes from active client_owner/client_admin membership."
},
{
"id": "hub.admin_guards",
"entityIds": ["assistant.admin_scope", "hub.user", "hub.client_context"],
"source": "/Users/dcconstructions/Downloads/mnt/data/nodedc_launcher/server/dev-server.mjs#assertAdminCanManageClient/assertAdminCanManageUser/assertAdminCanManageAccessForUser",
"summary": "Launcher backend guards constrain which client, user, memberships, grants, and requests an admin can manage."
},
{
"id": "hub.admin_ui_access_matrix",
"entityIds": ["hub.application_access", "ops.workspace_member", "ops.project_member"],
"source": "/Users/dcconstructions/Downloads/mnt/data/nodedc_launcher/src/widgets/admin-overlay/AdminOverlay.tsx",
"summary": "Admin UI already exposes MAIN status, MAIN role, service access, Operational Core workspace/project roles, invites, and access requests."
}
],
"capabilities": [
{ "id": "assistant.visible", "entityId": "assistant.capability", "level": "surface", "summary": "Assistant icon/surface can be shown." },
{ "id": "assistant.use", "entityId": "assistant.capability", "level": "member", "summary": "User can talk to personal assistant in allowed contexts." },
{ "id": "assistant.resolve_context", "entityId": "assistant.capability", "level": "member", "summary": "Assistant can use ontology to resolve HUB/OPS Product/ENGINE context." },
{ "id": "assistant.read_own_context", "entityId": "assistant.capability", "level": "member", "summary": "Assistant can read current user's allowed context summaries through permitted tools." },
{ "id": "assistant.create_own_ops_card", "entityId": "assistant.capability", "level": "member", "summary": "Assistant can help create/update OPS Product cards in a user-authorized project." },
{ "id": "assistant.request_own_engine_workflow_action", "entityId": "assistant.capability", "level": "member", "summary": "Assistant can prepare/request actions for Engine workflow context available to the user." },
{ "id": "assistant.view_admin_summary", "entityId": "assistant.capability", "level": "admin", "summary": "Assistant can summarize admin-visible users, memberships, invites, requests, and access matrix state." },
{ "id": "assistant.manage_users", "entityId": "assistant.capability", "level": "admin", "summary": "Assistant can request user profile/status updates through Launcher admin guards." },
{ "id": "assistant.manage_client_memberships", "entityId": "assistant.capability", "level": "admin", "summary": "Assistant can request client membership role/status changes through Launcher admin guards." },
{ "id": "assistant.view_pending_invites", "entityId": "assistant.capability", "level": "admin", "summary": "Assistant can list pending invites visible in admin scope." },
{ "id": "assistant.manage_invites", "entityId": "assistant.capability", "level": "admin", "summary": "Assistant can create/update/revoke invites inside admin scope." },
{ "id": "assistant.view_access_requests", "entityId": "assistant.capability", "level": "admin", "summary": "Assistant can list pending access/workflow/tasker requests visible in admin scope." },
{ "id": "assistant.manage_ops_workspace_membership", "entityId": "assistant.capability", "level": "admin", "summary": "Assistant can request Operational Core workspace membership changes through Launcher admin guards." },
{ "id": "assistant.manage_ops_project_membership", "entityId": "assistant.capability", "level": "admin", "summary": "Assistant can request Operational Core project membership changes through Launcher admin guards." },
{ "id": "assistant.manage_engine_workflow_requests", "entityId": "assistant.capability", "level": "root", "summary": "Assistant can approve/reject Engine workflow access requests only when Launcher route remains root-only." },
{ "id": "assistant.manage_service_catalog", "entityId": "assistant.capability", "level": "root", "summary": "Assistant can request service catalog/settings mutations only for root scope." },
{ "id": "assistant.manage_assistant_access", "entityId": "assistant.capability", "level": "admin", "summary": "Assistant can request changes to assistant role assignments inside admin scope after this feature is implemented in HUB." }
],
"roleMatrix": [
{
"roleId": "assistant_blocked",
"visible": false,
"canUse": false,
"allowedCapabilities": [],
"deniedCapabilities": ["*"],
"summary": "Hard off."
},
{
"roleId": "assistant_member",
"visible": true,
"canUse": true,
"allowedCapabilities": [
"assistant.visible",
"assistant.use",
"assistant.resolve_context",
"assistant.read_own_context",
"assistant.create_own_ops_card",
"assistant.request_own_engine_workflow_action"
],
"deniedCapabilities": [
"assistant.view_admin_summary",
"assistant.manage_users",
"assistant.manage_client_memberships",
"assistant.view_pending_invites",
"assistant.manage_invites",
"assistant.view_access_requests",
"assistant.manage_ops_workspace_membership",
"assistant.manage_ops_project_membership",
"assistant.manage_engine_workflow_requests",
"assistant.manage_service_catalog",
"assistant.manage_assistant_access"
],
"summary": "Current default personal assistant behavior."
},
{
"roleId": "assistant_admin",
"visible": true,
"canUse": true,
"allowedCapabilities": [
"assistant.visible",
"assistant.use",
"assistant.resolve_context",
"assistant.read_own_context",
"assistant.create_own_ops_card",
"assistant.request_own_engine_workflow_action",
"assistant.view_admin_summary",
"assistant.manage_users",
"assistant.manage_client_memberships",
"assistant.view_pending_invites",
"assistant.manage_invites",
"assistant.view_access_requests",
"assistant.manage_ops_workspace_membership",
"assistant.manage_ops_project_membership",
"assistant.manage_assistant_access"
],
"conditionalCapabilities": [
{
"capabilityId": "assistant.manage_engine_workflow_requests",
"requires": "launcher_admin_scope.root"
},
{
"capabilityId": "assistant.manage_service_catalog",
"requires": "launcher_admin_scope.root"
}
],
"deniedWhenNoAdminScope": [
"assistant.view_admin_summary",
"assistant.manage_users",
"assistant.manage_client_memberships",
"assistant.view_pending_invites",
"assistant.manage_invites",
"assistant.view_access_requests",
"assistant.manage_ops_workspace_membership",
"assistant.manage_ops_project_membership",
"assistant.manage_engine_workflow_requests",
"assistant.manage_service_catalog",
"assistant.manage_assistant_access"
],
"summary": "Assistant-admin commands are enabled only when existing Launcher admin scope is present."
}
],
"operations": [
{
"id": "assistant.op.change_user_status",
"capabilityId": "assistant.manage_users",
"sourceEndpoints": ["PATCH /api/admin/users/:userId/profile"],
"requiredGuards": ["requireLauncherAdmin", "assertAdminCanManageUser"],
"summary": "Block/unblock/invite-state management for a user visible in admin scope."
},
{
"id": "assistant.op.change_client_membership",
"capabilityId": "assistant.manage_client_memberships",
"sourceEndpoints": ["PATCH /api/admin/memberships/:membershipId"],
"requiredGuards": ["requireLauncherAdmin", "assertAdminCanManageMembership"],
"summary": "Change client role/status for a visible membership."
},
{
"id": "assistant.op.list_pending_invites",
"capabilityId": "assistant.view_pending_invites",
"sourceEndpoints": ["GET /api/admin/control-plane"],
"requiredGuards": ["requireLauncherAdmin", "scopeControlPlaneData"],
"summary": "List pending invites visible after Launcher data scoping."
},
{
"id": "assistant.op.manage_ops_workspace_membership",
"capabilityId": "assistant.manage_ops_workspace_membership",
"sourceEndpoints": ["POST /api/admin/task-manager/workspace-memberships/ensure"],
"requiredGuards": ["requireLauncherAdmin", "assertAdminCanManageClient", "assertAdminCanManageAccessForUser"],
"hardDenies": ["assistant.deny.task_manager_admin_role_locked"],
"summary": "Ensure/revoke Operational Core workspace access without granting Task Manager admin role."
},
{
"id": "assistant.op.manage_ops_project_membership",
"capabilityId": "assistant.manage_ops_project_membership",
"sourceEndpoints": ["POST /api/admin/task-manager/project-memberships/ensure"],
"requiredGuards": ["requireLauncherAdmin", "assertAdminCanManageClient", "assertAdminCanManageAccessForUser"],
"hardDenies": ["assistant.deny.task_manager_admin_role_locked"],
"summary": "Ensure/revoke Operational Core project access without granting Task Manager admin role."
},
{
"id": "assistant.op.manage_engine_workflow_request",
"capabilityId": "assistant.manage_engine_workflow_requests",
"sourceEndpoints": ["PATCH /api/admin/engine-workflow-access-requests/:requestId/approve", "PATCH /api/admin/engine-workflow-access-requests/:requestId/reject"],
"requiredGuards": ["requireRootLauncherAdmin"],
"summary": "Root-only request management stays root-only."
}
],
"hardDenies": [
{
"id": "assistant.deny.task_manager_admin_role_locked",
"entityIds": ["ops.workspace_member", "ops.project_member"],
"summary": "Launcher source currently forbids requestedRole === admin for Task Manager workspace/project membership mutations."
},
{
"id": "assistant.deny.protected_user_root_locked",
"entityIds": ["hub.user"],
"summary": "Protected user_root cannot be managed by ordinary admins; source guards protect it."
},
{
"id": "assistant.deny.no_scope_escalation",
"entityIds": ["assistant.access_role", "assistant.admin_scope", "agent.grant"],
"summary": "Assistant role cannot create Launcher admin scope or Gateway grants by itself."
},
{
"id": "assistant.deny.root_only_stays_root_only",
"entityIds": ["hub.client_context", "hub.application", "engine.workflow_access_request"],
"summary": "Root-only backend endpoints remain root-only even when assistant role is assistant_admin."
}
]
}

View File

@ -0,0 +1,317 @@
{
"version": "0.1.0",
"updatedAt": "2026-06-19",
"status": "draft-owner-confirmed",
"summary": "Declarative assistant action registry. Actions route requests to app-owned adapters after ontology context resolution and policy checks.",
"apps": [
{
"id": "hub",
"name": "HUB / Launcher",
"authorityEntityIds": ["hub.user", "hub.membership", "hub.invite", "hub.application_access"],
"adapterId": "adapter.hub.launcher_admin_api",
"summary": "Owns platform entry access, clients, memberships, invites, service access matrix, and assistant access role assignments."
},
{
"id": "ops",
"name": "OPS Product",
"authorityEntityIds": ["ops.workspace", "ops.project", "ops.card", "ops.workspace_member", "ops.project_member"],
"adapterId": "adapter.ops.product_api",
"summary": "Owns Operational Core workspace/project/card model and internal OPS roles."
},
{
"id": "engine",
"name": "ENGINE",
"authorityEntityIds": ["engine.workflow_l1", "engine.workflow_acl", "engine.workflow_share"],
"adapterId": "adapter.engine.workflow_api",
"summary": "Owns workflow ACL, workflow sharing, and Engine runtime/workflow context."
},
{
"id": "gateway",
"name": "OPS Gateway",
"authorityEntityIds": ["agent.identity", "agent.grant", "agent.scope", "agent.mcp_tool"],
"adapterId": "adapter.gateway.agent_api",
"summary": "Owns MCP/tool grants, idempotency, audit, and adapter execution envelope."
}
],
"adapterStatuses": [
"implemented",
"planned",
"future",
"forbidden"
],
"actions": [
{
"id": "assistant.action.resolve",
"app": "gateway",
"domain": "assistant",
"intentAliases": ["resolve action", "route command", "understand command"],
"entityIds": ["assistant.action_request", "assistant.action", "assistant.action_registry"],
"capabilityIds": ["assistant.resolve_context"],
"riskLevel": "read",
"confirmationMode": "none",
"selfActionPolicy": "allowed",
"adapterId": "adapter.gateway.agent_api",
"adapterStatus": "planned",
"summary": "Resolve natural-language user request into a known registered action before policy/execution."
},
{
"id": "hub.user.read_admin_summary",
"app": "hub",
"domain": "users",
"intentAliases": ["show users", "show user access", "summarize users", "покажи пользователей", "покажи доступы пользователя"],
"entityIds": ["hub.user", "hub.membership", "hub.application_access"],
"capabilityIds": ["assistant.view_admin_summary"],
"riskLevel": "read",
"confirmationMode": "none",
"selfActionPolicy": "allowed",
"requiredScopes": ["launcher_admin_scope.any"],
"adapterId": "adapter.hub.launcher_admin_api",
"adapterStatus": "implemented",
"summary": "Read scoped admin summary for HUB users, memberships, app access, and assistant access role."
},
{
"id": "hub.invite.list_pending",
"app": "hub",
"domain": "invites",
"intentAliases": ["pending invites", "show pending invites", "покажи инвайты", "покажи ожидающие инвайты"],
"entityIds": ["hub.invite", "hub.client_context", "hub.public_pool_context"],
"capabilityIds": ["assistant.view_access_requests"],
"riskLevel": "read",
"confirmationMode": "none",
"selfActionPolicy": "allowed",
"requiredScopes": ["launcher_admin_scope.any"],
"adapterId": "adapter.hub.launcher_admin_api",
"adapterStatus": "implemented",
"summary": "List pending invites visible inside current Launcher admin scope."
},
{
"id": "hub.access_request.list_pending",
"app": "hub",
"domain": "access_requests",
"intentAliases": ["pending access requests", "new access requests", "show access requests", "show launcher requests", "покажи заявки", "покажи новые заявки", "есть ли новые заявки", "заявки доступа", "заявки в лаунчере"],
"entityIds": ["hub.access_request", "hub.client_context", "hub.public_pool_context"],
"capabilityIds": ["assistant.view_access_requests"],
"riskLevel": "read",
"confirmationMode": "none",
"selfActionPolicy": "allowed",
"requiredScopes": ["launcher_admin_scope.any"],
"adapterId": "adapter.hub.launcher_admin_api",
"adapterStatus": "implemented",
"summary": "List pending public access requests visible inside current Launcher admin scope."
},
{
"id": "hub.user.block",
"app": "hub",
"domain": "users",
"intentAliases": ["block user", "disable user", "заблокируй пользователя", "отключи аккаунт"],
"entityIds": ["hub.user"],
"capabilityIds": ["assistant.manage_users"],
"riskLevel": "privileged",
"confirmationMode": "explicit",
"selfActionPolicy": "no_self_lockout",
"requiredScopes": ["launcher_admin_scope.can_manage_user"],
"adapterId": "adapter.hub.launcher_admin_api",
"adapterStatus": "implemented",
"sourceEndpoints": ["PATCH /api/admin/users/:userId/profile"],
"sourcePatch": { "globalStatus": "blocked" },
"hardDenyIds": ["assistant.action.deny.self_lockout"],
"summary": "Block HUB user account through existing Launcher admin guards."
},
{
"id": "hub.user.unblock",
"app": "hub",
"domain": "users",
"intentAliases": ["unblock user", "activate user", "разблокируй пользователя", "включи аккаунт"],
"entityIds": ["hub.user"],
"capabilityIds": ["assistant.manage_users"],
"riskLevel": "privileged",
"confirmationMode": "explicit",
"selfActionPolicy": "allowed",
"requiredScopes": ["launcher_admin_scope.can_manage_user"],
"adapterId": "adapter.hub.launcher_admin_api",
"adapterStatus": "implemented",
"sourceEndpoints": ["PATCH /api/admin/users/:userId/profile"],
"sourcePatch": { "globalStatus": "active" },
"summary": "Unblock HUB user account through existing Launcher admin guards."
},
{
"id": "hub.user.delete",
"app": "hub",
"domain": "users",
"intentAliases": ["delete user", "remove user", "удали пользователя", "сотри пользователя"],
"entityIds": ["hub.user"],
"capabilityIds": ["assistant.manage_users"],
"riskLevel": "destructive",
"confirmationMode": "forbidden",
"selfActionPolicy": "blocked",
"adapterId": "adapter.forbidden",
"adapterStatus": "forbidden",
"hardDenyIds": ["assistant.action.deny.delete_user", "assistant.action.deny.hard_delete_records"],
"safeAlternativeActionIds": ["hub.user.block", "hub.membership.disable"],
"refusal": "Я не удаляю пользователей. Могу заблокировать аккаунт или отключить доступ в конкретном контуре.",
"summary": "Explicit forbidden action card for user deletion requests."
},
{
"id": "hub.membership.change_role",
"app": "hub",
"domain": "memberships",
"intentAliases": ["change hub role", "change client role", "измени роль в хабе", "измени роль в контуре"],
"entityIds": ["hub.membership", "hub.role"],
"capabilityIds": ["assistant.manage_client_memberships"],
"riskLevel": "privileged",
"confirmationMode": "explicit",
"selfActionPolicy": "no_self_lockout",
"requiredScopes": ["launcher_admin_scope.can_manage_membership"],
"adapterId": "adapter.hub.launcher_admin_api",
"adapterStatus": "implemented",
"sourceEndpoints": ["PATCH /api/admin/memberships/:membershipId"],
"hardDenyIds": ["assistant.action.deny.self_lockout"],
"summary": "Change HUB/Launcher client membership role through existing Launcher admin guards."
},
{
"id": "hub.membership.disable",
"app": "hub",
"domain": "memberships",
"intentAliases": ["disable context access", "disable membership", "отключи доступ в контуре"],
"entityIds": ["hub.membership"],
"capabilityIds": ["assistant.manage_client_memberships"],
"riskLevel": "privileged",
"confirmationMode": "explicit",
"selfActionPolicy": "no_self_lockout",
"requiredScopes": ["launcher_admin_scope.can_manage_membership"],
"adapterId": "adapter.hub.launcher_admin_api",
"adapterStatus": "implemented",
"sourceEndpoints": ["PATCH /api/admin/memberships/:membershipId"],
"sourcePatch": { "status": "disabled" },
"hardDenyIds": ["assistant.action.deny.self_lockout"],
"summary": "Disable a user's membership in a HUB client/public context."
},
{
"id": "hub.assistant_access.change_role",
"app": "hub",
"domain": "assistant_access",
"intentAliases": ["change assistant role", "block assistant", "enable assistant admin", "измени роль ассистента", "отключи ассистента"],
"entityIds": ["assistant.core_access", "assistant.access_role", "hub.membership"],
"capabilityIds": ["assistant.manage_assistant_access"],
"riskLevel": "privileged",
"confirmationMode": "explicit",
"selfActionPolicy": "no_self_lockout",
"requiredScopes": ["launcher_admin_scope.can_manage_membership"],
"adapterId": "adapter.hub.launcher_admin_api",
"adapterStatus": "implemented",
"sourceEndpoints": ["PATCH /api/admin/memberships/:membershipId"],
"allowedValues": ["blocked", "member", "admin"],
"hardDenyIds": ["assistant.action.deny.self_lockout"],
"summary": "Change NDC Core Assistant access role stored on HUB membership."
},
{
"id": "ops.card.list_recent",
"app": "ops",
"domain": "cards",
"intentAliases": ["latest ops task", "last ops task", "recent ops cards", "show ops tasks", "show ops cards", "последняя задача в опсе", "последнюю задачу в опсе", "последние задачи в опсе", "задачи в опсе", "карточки в опсе", "покажи задачи ops", "покажи карточки ops"],
"entityIds": ["ops.card", "ops.project", "ops.workspace"],
"capabilityIds": ["assistant.read_own_context"],
"riskLevel": "read",
"confirmationMode": "none",
"selfActionPolicy": "allowed",
"requiredScopes": ["ops.project.can_read_card"],
"adapterId": "adapter.ops.product_api",
"adapterStatus": "implemented",
"sourceEndpoints": ["GET /api/v1/tools/issues"],
"summary": "Read recent OPS Product cards in the current user-authorized project through the local Ops Gateway."
},
{
"id": "ops.card.create",
"app": "ops",
"domain": "cards",
"intentAliases": ["create ops card", "create ops task", "create card in ops", "create task in ops", "create card", "create task", "создай карточку в опсе", "создай задачу в опсе", "заведи карточку в опсе", "заведи задачу в опсе", "создай карточку", "создай задачу"],
"entityIds": ["ops.card", "ops.project", "ops.workspace"],
"capabilityIds": ["assistant.create_own_ops_card"],
"riskLevel": "write",
"confirmationMode": "explicit",
"selfActionPolicy": "allowed",
"requiredScopes": ["ops.project.can_create_card"],
"adapterId": "adapter.ops.product_api",
"adapterStatus": "implemented",
"sourceEndpoints": ["POST /api/v1/tools/issues"],
"summary": "Create OPS Product card through OPS-owned Gateway adapter."
},
{
"id": "ops.card.add_comment",
"app": "ops",
"domain": "cards",
"intentAliases": ["add ops comment", "comment ops card", "comment ops task", "write in ops card", "write in ops task", "добавь комментарий в опсе", "добавь комментарий в задачу", "напиши в задаче опса", "напиши в карточке опса", "напиши в этой задаче", "запиши в карточку опса", "оставь комментарий в задаче"],
"entityIds": ["ops.card", "ops.card_comment", "ops.project", "ops.workspace"],
"capabilityIds": ["assistant.create_own_ops_card"],
"riskLevel": "write",
"confirmationMode": "explicit",
"selfActionPolicy": "allowed",
"requiredScopes": ["ops.card.can_comment"],
"adapterId": "adapter.ops.product_api",
"adapterStatus": "implemented",
"sourceEndpoints": ["POST /api/v1/tools/issues/:issueId/comments"],
"summary": "Append comment to OPS Product card through OPS-owned Gateway adapter."
},
{
"id": "ops.card.update_status",
"app": "ops",
"domain": "cards",
"intentAliases": ["update card status", "close card", "archive card safely", "измени статус карточки", "закрой карточку"],
"entityIds": ["ops.card", "ops.project", "ops.workspace"],
"capabilityIds": ["assistant.create_own_ops_card"],
"riskLevel": "write",
"confirmationMode": "explicit",
"selfActionPolicy": "allowed",
"requiredScopes": ["ops.card.can_update_status"],
"adapterId": "adapter.ops.product_api",
"adapterStatus": "planned",
"summary": "Update OPS Product card status through OPS-owned adapter. This is the safe alternative to destructive card deletion."
},
{
"id": "ops.project.grant_member",
"app": "ops",
"domain": "project_members",
"intentAliases": ["grant ops project", "add ops project member", "добавь в проект опса"],
"entityIds": ["ops.project_member", "ops.project", "ops.workspace_member"],
"capabilityIds": ["assistant.manage_ops_project_membership"],
"riskLevel": "privileged",
"confirmationMode": "explicit",
"selfActionPolicy": "app_owned",
"requiredScopes": ["ops.project.can_manage_members"],
"adapterId": "adapter.ops.product_api",
"adapterStatus": "future",
"hardDenyIds": ["assistant.action.deny.cross_app_role_ownership"],
"summary": "Grant OPS Product project access through OPS-owned role adapter, not HUB direct mutation."
},
{
"id": "engine.workflow.share",
"app": "engine",
"domain": "workflow_acl",
"intentAliases": ["share workflow", "grant engine workflow", "пошарь workflow", "дай доступ к workflow"],
"entityIds": ["engine.workflow_share", "engine.workflow_acl", "engine.workflow_l1"],
"capabilityIds": ["assistant.request_own_engine_workflow_action"],
"riskLevel": "privileged",
"confirmationMode": "explicit",
"selfActionPolicy": "app_owned",
"requiredScopes": ["engine.workflow.can_manage_acl"],
"adapterId": "adapter.engine.workflow_api",
"adapterStatus": "future",
"hardDenyIds": ["assistant.action.deny.cross_app_role_ownership"],
"summary": "Share ENGINE workflow through Engine-owned ACL adapter."
},
{
"id": "gateway.grant.select_tool_scope",
"app": "gateway",
"domain": "agent_grants",
"intentAliases": ["select gateway grant", "select mcp tool", "выбери mcp tool"],
"entityIds": ["agent.grant", "agent.scope", "agent.mcp_tool", "agent.idempotency_key"],
"capabilityIds": ["assistant.resolve_context"],
"riskLevel": "read",
"confirmationMode": "none",
"selfActionPolicy": "allowed",
"adapterId": "adapter.gateway.agent_api",
"adapterStatus": "planned",
"summary": "Select candidate Gateway grant/tool/scope before execution. Does not create grants."
}
]
}

View File

@ -0,0 +1,135 @@
{
"version": "0.1.0",
"updatedAt": "2026-06-19",
"status": "draft-owner-confirmed",
"summary": "Risk policy for assistant action routing. This policy is intentionally conservative: destructive actions are forbidden, privileged writes are scoped, and app-owned roles stay app-owned.",
"riskLevels": [
{
"id": "read",
"requiresConfirmation": false,
"summary": "Read or summarize data already visible to the actor."
},
{
"id": "write",
"requiresConfirmation": true,
"summary": "Create or update non-destructive state."
},
{
"id": "privileged",
"requiresConfirmation": true,
"summary": "Change users, roles, memberships, access, grants, workflow ACLs, or other privileged state."
},
{
"id": "destructive",
"requiresConfirmation": true,
"assistantAllowed": false,
"summary": "Delete, hard-remove, archive critical objects, or destroy production state. Forbidden for assistant execution."
},
{
"id": "root_only",
"requiresConfirmation": true,
"summary": "Root-only administrative action. Assistant may route only when source app also confirms root scope."
},
{
"id": "future",
"requiresConfirmation": false,
"assistantAllowed": false,
"summary": "Conceptual action not wired to an adapter yet."
}
],
"confirmationModes": [
{
"id": "none",
"summary": "No extra confirmation beyond ordinary chat request."
},
{
"id": "explicit",
"summary": "Assistant must show exact target, action, app, scope, and expected effect before execution."
},
{
"id": "typed",
"summary": "Reserved for very high-risk future flows. Requires typing a short confirmation phrase."
},
{
"id": "forbidden",
"summary": "Assistant refuses and should offer the nearest safe alternative."
}
],
"selfActionPolicies": [
{
"id": "allowed",
"summary": "Self-targeting is allowed."
},
{
"id": "blocked",
"summary": "Self-targeting is never allowed."
},
{
"id": "no_self_lockout",
"summary": "Self-targeting is allowed only when it cannot remove the actor's own last access/admin path."
},
{
"id": "app_owned",
"summary": "Self-targeting is delegated to the app adapter because the app owns the role model."
}
],
"hardDenies": [
{
"id": "assistant.action.deny.delete_user",
"summary": "Assistant must never delete a HUB/Launcher user. Offer block user or disable membership instead.",
"safeAlternatives": ["hub.user.block", "hub.membership.disable"]
},
{
"id": "assistant.action.deny.hard_delete_records",
"summary": "Assistant must never hard-delete records, database rows, files, runtime data, storage, backups, or production state.",
"safeAlternatives": ["hub.user.block", "hub.membership.disable", "ops.card.update_status"]
},
{
"id": "assistant.action.deny.self_lockout",
"summary": "Assistant must never let an actor remove their own last admin/access path.",
"safeAlternatives": ["hub.invite.list_pending", "hub.user.read_admin_summary"]
},
{
"id": "assistant.action.deny.cross_app_role_ownership",
"summary": "HUB action cannot mutate internal ENGINE workflow ACL or OPS Product project roles unless routed to the app-owned adapter.",
"safeAlternatives": ["engine.workflow.share", "ops.project.grant_member"]
},
{
"id": "assistant.action.deny.prompt_only_execution",
"summary": "Assistant must not execute actions from prompt text alone. It must resolve a registered action card and pass policy first.",
"safeAlternatives": ["assistant.action.resolve"]
}
],
"globalRules": [
{
"id": "assistant.rule.registered_action_required",
"severity": "error",
"summary": "Every executable request must resolve to an action in assistant-actions.json."
},
{
"id": "assistant.rule.destructive_forbidden",
"severity": "error",
"summary": "Actions with riskLevel=destructive or confirmationMode=forbidden are refused by default."
},
{
"id": "assistant.rule.privileged_requires_admin_role",
"severity": "error",
"summary": "Actions with riskLevel=privileged require assistant_admin plus app-owned admin scope."
},
{
"id": "assistant.rule.app_owned_roles_stay_app_owned",
"severity": "error",
"summary": "HUB may route but must not directly mutate OPS Product or ENGINE internal role models."
},
{
"id": "assistant.rule.write_requires_idempotency",
"severity": "error",
"summary": "Write actions executed through Gateway adapters require an idempotency key."
},
{
"id": "assistant.rule.audit_every_write",
"severity": "error",
"summary": "Write and privileged actions must emit app audit or gateway audit events."
}
]
}

View File

@ -0,0 +1,65 @@
{
"version": "0.1.0",
"updatedAt": "2026-06-19",
"contextStatuses": ["active", "planned", "deprecated"],
"bindingStatuses": ["active", "planned", "blocked"],
"contexts": [
{
"id": "context.ops.nodedc.ndc_platform",
"surface": "ops",
"entityId": "ops.project",
"sourceSystem": "tasker",
"label": "NDC PLATFORM",
"status": "active",
"refs": {
"workspace_slug": "nodedc",
"project_id": "86629a11-eaff-4ad2-9f89-e5245a344fcc"
}
},
{
"id": "context.ops.nodedc.ndc_platform.ontology_core_card",
"surface": "ops",
"entityId": "ops.card",
"sourceSystem": "tasker",
"label": "Антология / Ontology Core",
"status": "active",
"parentContextId": "context.ops.nodedc.ndc_platform",
"refs": {
"workspace_slug": "nodedc",
"project_id": "86629a11-eaff-4ad2-9f89-e5245a344fcc",
"issue_id": "1312519a-0eda-4ea0-9e34-2113c3724ecf",
"sequence_id": "43"
}
}
],
"bindingTypes": [
{
"id": "binding_type.ops_card_to_engine_workflow",
"relationId": "ontology.resolves_ops_card_to_engine_context",
"fromEntityIds": ["ops.card", "ops.project"],
"toEntityIds": ["engine.workflow_l1", "engine.node_l1", "engine.workflow_l2", "engine.workflow_l2_runtime_id"],
"requiredFromRefs": ["workspace_slug", "project_id"],
"requiredToRefs": ["workflow_id"],
"summary": "Binds OPS project/card context to Engine L1/L2 workflow context."
},
{
"id": "binding_type.engine_workflow_to_ops_project",
"relationId": "ontology.resolves_engine_context_to_ops_project",
"fromEntityIds": ["engine.workflow_l1", "engine.workflow_l2", "engine.node_l1"],
"toEntityIds": ["ops.project", "ops.card"],
"requiredFromRefs": ["workflow_id"],
"requiredToRefs": ["workspace_slug", "project_id"],
"summary": "Binds Engine workflow/node context to OPS project/card context."
},
{
"id": "binding_type.assistant_context_to_gateway_grant",
"relationId": "ontology.selects_gateway_grant_context",
"fromEntityIds": ["assistant.bridge", "assistant.executor"],
"toEntityIds": ["agent.grant", "agent.scope", "agent.mcp_tool"],
"requiredFromRefs": ["surface"],
"requiredToRefs": ["grant_id"],
"summary": "Binds resolved assistant context to Gateway grant/tool preflight."
}
],
"bindings": []
}

View File

@ -0,0 +1,128 @@
{
"version": "0.4-pre",
"updatedAt": "2026-06-19",
"statusVocabulary": [
"source-confirmed",
"source-evidenced",
"owner-confirmed",
"product-required",
"future-concept",
"tech-debt-noncanonical",
"pending",
"source-adjacent"
],
"entities": [
{ "id": "hub.open_contour", "name": "HUB Open Contour", "surface": "hub", "status": ["source-evidenced", "owner-confirmed"], "authority": "HUB", "summary": "Public/open branch for simple users." },
{ "id": "hub.public_pool_context", "name": "HUB Public Pool Context", "surface": "hub", "status": ["source-confirmed"], "authority": "HUB", "summary": "Source-backed public context." },
{ "id": "hub.public_pool_client", "name": "HUB Public Pool Client", "surface": "hub", "status": ["source-confirmed"], "authority": "HUB", "summary": "Special source-backed Client, not ordinary company/client." },
{ "id": "hub.client_context", "name": "HUB Client Context", "surface": "hub", "status": ["source-confirmed"], "authority": "HUB", "summary": "Managed closed contour for companies/clients." },
{ "id": "hub.company_contour", "name": "HUB Company Contour", "surface": "hub", "status": ["source-evidenced"], "authority": "HUB", "summary": "Product wording over managed client context." },
{ "id": "hub.user", "name": "HUB User", "surface": "hub", "status": ["source-confirmed"], "authority": "HUB/NDCAuth", "summary": "Canonical launcher user." },
{ "id": "hub.public_user", "name": "HUB Public User Classification", "surface": "hub", "status": ["source-evidenced"], "authority": "HUB", "summary": "Classification of hub.user by public-pool context." },
{ "id": "hub.client_user", "name": "HUB Client User Classification", "surface": "hub", "status": ["source-evidenced"], "authority": "HUB", "summary": "Classification of hub.user by client/company membership." },
{ "id": "hub.membership", "name": "HUB Membership", "surface": "hub", "status": ["source-confirmed"], "authority": "HUB", "summary": "User-to-context relation." },
{ "id": "hub.public_membership", "name": "HUB Public Membership", "surface": "hub", "status": ["source-confirmed"], "authority": "HUB", "summary": "Membership in public pool context." },
{ "id": "hub.access_request", "name": "HUB Access Request", "surface": "hub", "status": ["source-confirmed"], "authority": "HUB", "summary": "Base access request entity." },
{ "id": "hub.public_access_request", "name": "HUB Public Access Request", "surface": "hub", "status": ["source-evidenced"], "authority": "HUB", "summary": "Public/open-contour access request subtype." },
{ "id": "hub.client_access_request", "name": "HUB Client Access Request", "surface": "hub", "status": ["source-evidenced"], "authority": "HUB", "summary": "Company/client-context access request subtype." },
{ "id": "hub.invite", "name": "HUB Invite", "surface": "hub", "status": ["source-confirmed"], "authority": "HUB", "summary": "Invitation into a context." },
{ "id": "hub.group", "name": "HUB Group", "surface": "hub", "status": ["source-confirmed"], "authority": "HUB", "summary": "Client-local group." },
{ "id": "hub.role", "name": "HUB Role", "surface": "hub", "status": ["source-confirmed"], "authority": "HUB", "summary": "Role vocabulary for user and membership decisions." },
{ "id": "hub.permission", "name": "HUB Permission", "surface": "hub", "status": ["source-confirmed"], "authority": "HUB", "summary": "Derived permission capability." },
{ "id": "hub.application", "name": "HUB Application", "surface": "hub", "status": ["source-confirmed"], "authority": "HUB", "summary": "Product application/service registered in launcher." },
{ "id": "hub.application_access", "name": "HUB Application Access", "surface": "hub", "status": ["source-confirmed"], "authority": "HUB", "summary": "Effective app access result." },
{ "id": "hub.app_grant", "name": "HUB App Grant", "surface": "hub", "status": ["source-confirmed"], "authority": "HUB", "summary": "Access authority input." },
{ "id": "hub.app_exception", "name": "HUB App Exception", "surface": "hub", "status": ["source-confirmed"], "authority": "HUB", "summary": "User-specific allow/deny override." },
{ "id": "hub.application_card", "name": "HUB Application Card", "surface": "hub", "status": ["source-confirmed"], "authority": "HUB UI", "summary": "Presentation over application/access state, not authority." },
{ "id": "ndcauth.identity", "name": "NDCAuth Identity", "surface": "ndcauth", "status": ["source-confirmed"], "authority": "NDCAuth/HUB", "summary": "Canonical auth identity." },
{ "id": "ndcauth.session", "name": "NDCAuth Session", "surface": "ndcauth", "status": ["source-confirmed"], "authority": "NDCAuth/HUB", "summary": "Runtime authenticated/unauthenticated session." },
{ "id": "ops.workspace", "name": "OPS Workspace", "surface": "ops", "status": ["source-confirmed"], "authority": "OPS Product", "summary": "Workspace/root collaboration context." },
{ "id": "ops.workspace_member", "name": "OPS Workspace Member", "surface": "ops", "status": ["source-confirmed"], "authority": "OPS Product", "summary": "User membership in workspace." },
{ "id": "ops.project", "name": "OPS Project", "surface": "ops", "status": ["source-confirmed"], "authority": "OPS Product", "summary": "Project inside workspace." },
{ "id": "ops.project_member", "name": "OPS Project Member", "surface": "ops", "status": ["source-confirmed"], "authority": "OPS Product", "summary": "User membership in project." },
{ "id": "ops.card", "name": "OPS Card", "surface": "ops", "status": ["source-confirmed", "owner-confirmed"], "authority": "OPS Product", "summary": "Canonical work object." },
{ "id": "ops.card_status", "name": "OPS Card Status", "surface": "ops", "status": ["source-confirmed"], "authority": "OPS Product", "summary": "State/status of a card." },
{ "id": "ops.card_priority", "name": "OPS Card Priority", "surface": "ops", "status": ["source-confirmed"], "authority": "OPS Product", "summary": "Card priority attribute." },
{ "id": "ops.card_assignment", "name": "OPS Card Assignment", "surface": "ops", "status": ["source-confirmed"], "authority": "OPS Product", "summary": "Relation between card and assignee." },
{ "id": "ops.assignee", "name": "OPS Assignee", "surface": "ops", "status": ["source-confirmed"], "authority": "OPS Product", "summary": "Assigned user/member." },
{ "id": "ops.responsible", "name": "OPS Responsible", "surface": "ops", "status": ["product-required", "source-adjacent"], "authority": "OPS Product", "summary": "Accountability role, not automatically assignee." },
{ "id": "ops.card_type", "name": "OPS Card Type", "surface": "ops", "status": ["source-evidenced", "pending"], "authority": "OPS Product", "summary": "Type/subtype of card." },
{ "id": "ops.card_relation", "name": "OPS Card Relation", "surface": "ops", "status": ["source-confirmed"], "authority": "OPS Product", "summary": "Dependency/link between cards." },
{ "id": "ops.external_ref", "name": "OPS External Reference", "surface": "ops", "status": ["source-confirmed"], "authority": "OPS Product", "summary": "Link to external/source object." },
{ "id": "ops.card_label", "name": "OPS Card Label", "surface": "ops", "status": ["source-confirmed"], "authority": "OPS Product", "summary": "Label/tag classification." },
{ "id": "ops.card_comment", "name": "OPS Card Comment", "surface": "ops", "status": ["source-confirmed"], "authority": "OPS Product", "summary": "Discussion/comment on a card." },
{ "id": "ops.card_activity", "name": "OPS Card Activity", "surface": "ops", "status": ["source-evidenced"], "authority": "OPS Product", "summary": "Change/activity history." },
{ "id": "ops.card_artifact_link", "name": "OPS Card Artifact Link", "surface": "ops", "status": ["source-evidenced"], "authority": "OPS Product", "summary": "Relation/link to output artifact." },
{ "id": "ops.file_asset", "name": "OPS File Asset", "surface": "ops", "status": ["source-evidenced"], "authority": "OPS Product", "summary": "File attached/linked through OPS." },
{ "id": "ops.stored_blob", "name": "OPS Stored Blob", "surface": "ops", "status": ["source-evidenced"], "authority": "OPS Product", "summary": "Storage-level blob behind file assets." },
{ "id": "agent.identity", "name": "Agent Identity", "surface": "agent", "status": ["source-confirmed"], "authority": "OPS Gateway", "summary": "Gateway execution identity, not user/persona." },
{ "id": "agent.token", "name": "Agent Token", "surface": "agent", "status": ["source-confirmed"], "authority": "OPS Gateway", "summary": "Opaque credential." },
{ "id": "agent.session", "name": "Agent Session", "surface": "agent", "status": ["source-confirmed"], "authority": "OPS Gateway", "summary": "Runtime/session envelope for agent operations." },
{ "id": "agent.grant", "name": "Agent Grant", "surface": "agent", "status": ["source-confirmed"], "authority": "OPS Gateway", "summary": "Permission grant for agent identity/session." },
{ "id": "agent.token_grant", "name": "Token Grant", "surface": "agent", "status": ["source-confirmed"], "authority": "OPS Gateway", "summary": "Token-to-grant binding." },
{ "id": "agent.scope", "name": "Agent Scope", "surface": "agent", "status": ["source-confirmed"], "authority": "OPS Gateway", "summary": "Tool/capability permission." },
{ "id": "agent.denied_capability", "name": "Denied Capability", "surface": "agent", "status": ["source-confirmed"], "authority": "OPS Gateway", "summary": "Explicitly unavailable operation." },
{ "id": "agent.mcp_tool", "name": "MCP Tool", "surface": "agent", "status": ["source-confirmed"], "authority": "OPS Gateway", "summary": "Exposed callable tool surface." },
{ "id": "agent.idempotency_key", "name": "Idempotency Key", "surface": "agent", "status": ["source-confirmed"], "authority": "OPS Gateway", "summary": "Required write-safety key." },
{ "id": "agent.audit_event", "name": "Agent Audit Event", "surface": "agent", "status": ["source-confirmed"], "authority": "OPS Gateway", "summary": "Audit trail for agent actions." },
{ "id": "agent.tasker_adapter", "name": "Tasker Adapter", "surface": "agent", "status": ["source-confirmed"], "authority": "OPS Gateway", "summary": "Adapter to Tasker/OPS product API." },
{ "id": "agent.setup_packet", "name": "Agent Setup Packet", "surface": "agent", "status": ["source-evidenced"], "authority": "OPS Gateway", "summary": "Setup payload for client app/tool access." },
{ "id": "agent.ai_workspace_entitlement", "name": "AI Workspace Entitlement", "surface": "agent", "status": ["source-evidenced", "pending"], "authority": "HUB/OPS Gateway", "summary": "Entitlement/access context for AI workspace." },
{ "id": "agent.pairing_code", "name": "Pairing Code", "surface": "agent", "status": ["source-evidenced", "pending"], "authority": "OPS Gateway", "summary": "Pairing/setup flow candidate." },
{ "id": "engine.workflow_l1", "name": "ENGINE L1 Workflow", "surface": "engine", "status": ["source-confirmed"], "authority": "ENGINE", "summary": "NodeDC canvas/workflow graph." },
{ "id": "engine.node_l1", "name": "ENGINE L1 Node", "surface": "engine", "status": ["source-confirmed"], "authority": "ENGINE", "summary": "React Flow node in L1 graph." },
{ "id": "engine.edge_l1", "name": "ENGINE L1 Edge", "surface": "engine", "status": ["source-confirmed"], "authority": "ENGINE", "summary": "React Flow edge in L1 graph." },
{ "id": "engine.node_type_l1", "name": "ENGINE L1 Node Type", "surface": "engine", "status": ["source-confirmed"], "authority": "ENGINE", "summary": "Auto-registered node type/module." },
{ "id": "engine.workflow_acl", "name": "ENGINE Workflow ACL", "surface": "engine", "status": ["source-confirmed"], "authority": "ENGINE", "summary": "Workflow-level ACL." },
{ "id": "engine.workflow_owner", "name": "ENGINE Workflow Owner", "surface": "engine", "status": ["source-confirmed"], "authority": "ENGINE", "summary": "Owner field in ACL." },
{ "id": "engine.workflow_share", "name": "ENGINE Workflow Share", "surface": "engine", "status": ["source-confirmed"], "authority": "ENGINE", "summary": "Share/invite/user access routes." },
{ "id": "engine.workflow_access_request", "name": "ENGINE Workflow Access Request", "surface": "engine", "status": ["source-confirmed"], "authority": "ENGINE", "summary": "Access request route/model for workflow access." },
{ "id": "engine.workflow_l2", "name": "ENGINE L2 Workflow", "surface": "engine", "status": ["source-evidenced"], "authority": "ENGINE/n8n bridge", "summary": "n8n subworkflow attached to L1 node." },
{ "id": "engine.node_l2", "name": "ENGINE L2 Node", "surface": "engine", "status": ["source-evidenced"], "authority": "ENGINE/n8n bridge", "summary": "Node inside compiled/deployed n8n workflow." },
{ "id": "engine.l2_runtime_core", "name": "ENGINE L2 Runtime Core", "surface": "engine", "status": ["source-evidenced"], "authority": "ENGINE/n8n bridge", "summary": "Target n8n runtime/core instance." },
{ "id": "engine.workflow_l2_runtime_id", "name": "ENGINE L2 Runtime Workflow ID", "surface": "engine", "status": ["source-confirmed"], "authority": "ENGINE/n8n bridge", "summary": "n8n runtime workflow id, separate from NodeDC workflow id." },
{ "id": "engine.l2_execution", "name": "ENGINE L2 Execution", "surface": "engine", "status": ["source-confirmed"], "authority": "ENGINE/n8n bridge", "summary": "n8n execution id mapped to run/execution records." },
{ "id": "engine.l2_run", "name": "ENGINE L2 Run", "surface": "engine", "status": ["source-confirmed"], "authority": "ENGINE-side OpsLayer", "summary": "Runtime run id; tech-debt-adjacent." },
{ "id": "engine.l2_session", "name": "ENGINE L2 Session", "surface": "engine", "status": ["source-confirmed"], "authority": "ENGINE-side OpsLayer", "summary": "Runtime session anchor." },
{ "id": "engine.l2_runtime_event", "name": "ENGINE L2 Runtime Event", "surface": "engine", "status": ["source-confirmed"], "authority": "ENGINE/n8n bridge", "summary": "Runtime event: workflow/node start/finish/success/error." },
{ "id": "engine.source_stamp", "name": "ENGINE Source Stamp", "surface": "engine", "status": ["pending"], "authority": "ENGINE", "summary": "Future source/version traceability candidate." },
{ "id": "assistant.assistant", "name": "Assistant", "surface": "assistant", "status": ["product-required", "pending"], "authority": "AI Workspace", "summary": "User-facing assistant capability/persona." },
{ "id": "assistant.provider", "name": "Assistant Provider", "surface": "assistant", "status": ["product-required", "pending"], "authority": "AI Workspace", "summary": "Model/provider integration boundary." },
{ "id": "assistant.executor", "name": "Assistant Executor", "surface": "assistant", "status": ["product-required", "pending"], "authority": "AI Workspace/Codex/MCP", "summary": "Execution adapter." },
{ "id": "assistant.thread", "name": "Assistant Thread", "surface": "assistant", "status": ["product-required", "pending"], "authority": "AI Workspace", "summary": "Conversation context." },
{ "id": "assistant.message", "name": "Assistant Message", "surface": "assistant", "status": ["product-required", "pending"], "authority": "AI Workspace", "summary": "Message unit in thread." },
{ "id": "assistant.bridge", "name": "Assistant Bridge", "surface": "assistant", "status": ["product-required", "pending"], "authority": "AI Workspace/OPS Gateway", "summary": "Bridge between assistant, ontology, gateway, Codex, and Engine." },
{ "id": "assistant.core_access", "name": "NDC Core Assistant Access", "surface": "assistant", "status": ["product-required", "owner-confirmed"], "authority": "HUB/Launcher", "summary": "Per-user assistant availability and permission overlay." },
{ "id": "assistant.access_role", "name": "Assistant Access Role", "surface": "assistant", "status": ["product-required", "owner-confirmed"], "authority": "HUB/Launcher", "summary": "Assistant role vocabulary: blocked, member, admin." },
{ "id": "assistant.role_assignment", "name": "Assistant Role Assignment", "surface": "assistant", "status": ["product-required", "pending"], "authority": "HUB/Launcher", "summary": "Scoped assignment of an assistant access role to a HUB user." },
{ "id": "assistant.capability", "name": "Assistant Capability", "surface": "assistant", "status": ["product-required", "owner-confirmed"], "authority": "Ontology Core/HUB/OPS Gateway", "summary": "Action-level capability exposed to an assistant." },
{ "id": "assistant.permission_policy", "name": "Assistant Permission Policy", "surface": "assistant", "status": ["product-required", "owner-confirmed"], "authority": "Ontology Core", "summary": "Policy that maps assistant role plus HUB admin scope to allowed capabilities." },
{ "id": "assistant.admin_scope", "name": "Assistant Admin Scope", "surface": "assistant", "status": ["product-required", "source-evidenced"], "authority": "HUB/Launcher", "summary": "Launcher-derived root/client admin boundary available to assistant-admin commands." },
{ "id": "assistant.action_request", "name": "Assistant Action Request", "surface": "assistant", "status": ["product-required", "pending"], "authority": "AI Workspace/OPS Gateway", "summary": "A requested assistant operation before policy and gateway enforcement." },
{ "id": "assistant.action", "name": "Assistant Action", "surface": "assistant", "status": ["product-required", "owner-confirmed"], "authority": "Ontology Core", "summary": "Declarative action card used by assistant command routing." },
{ "id": "assistant.action_registry", "name": "Assistant Action Registry", "surface": "assistant", "status": ["product-required", "owner-confirmed"], "authority": "Ontology Core", "summary": "Catalog of assistant actions across HUB, OPS Product, ENGINE, and Gateway." },
{ "id": "assistant.risk_policy", "name": "Assistant Risk Policy", "surface": "assistant", "status": ["product-required", "owner-confirmed"], "authority": "Ontology Core", "summary": "Risk classification and hard-deny policy for assistant actions." },
{ "id": "assistant.app_adapter", "name": "Assistant App Adapter", "surface": "assistant", "status": ["product-required", "pending"], "authority": "Platform Apps", "summary": "Application-owned execution adapter for an approved assistant action." },
{ "id": "assistant.confirmation_policy", "name": "Assistant Confirmation Policy", "surface": "assistant", "status": ["product-required", "owner-confirmed"], "authority": "Ontology Core", "summary": "Rules for when assistant actions require explicit confirmation." },
{ "id": "future.interface_layer", "name": "Future Interface Layer", "surface": "future", "status": ["owner-confirmed", "future-concept"], "authority": "Platform", "summary": "Dedicated automation UI/workview layer." },
{ "id": "future.interface_view", "name": "Interface View", "surface": "future", "status": ["future-concept"], "authority": "Interface Layer", "summary": "Generated/configured view over ontology-backed domain data." },
{ "id": "future.interface_widget", "name": "Interface Widget", "surface": "future", "status": ["future-concept"], "authority": "Interface Layer", "summary": "Map, table, analytics block, form, graph, control, etc." },
{ "id": "future.interface_binding", "name": "Interface Binding", "surface": "future", "status": ["future-concept"], "authority": "Interface Layer/ENGINE", "summary": "Binding from workflow/node output to interface layer." },
{ "id": "future.domain_package", "name": "Domain Ontology Package", "surface": "future", "status": ["future-concept"], "authority": "Ontology Core", "summary": "Domain extension package." },
{ "id": "engine.tech_debt.ops_layer", "name": "ENGINE OpsLayer Tech Debt", "surface": "engine", "status": ["tech-debt-noncanonical"], "authority": "ENGINE", "summary": "Current ENGINE-side operational/runtime layer; not OPS Product." },
{ "id": "engine.tech_debt.ops_agent_instance", "name": "ENGINE Ops Agent Instance", "surface": "engine", "status": ["tech-debt-noncanonical"], "authority": "ENGINE", "summary": "Local runtime agent instance; not OPS Gateway agent.identity." },
{ "id": "engine.tech_debt.agent_monitor_node", "name": "Agent Monitor Node", "surface": "engine", "status": ["tech-debt-noncanonical"], "authority": "ENGINE", "summary": "Current monitor/control UI experiment." },
{ "id": "engine.tech_debt.monitor_profile", "name": "Monitor Profile", "surface": "engine", "status": ["tech-debt-noncanonical"], "authority": "ENGINE", "summary": "Current profile/config around Agent Monitor." },
{ "id": "engine.tech_debt.tender_domain_ui", "name": "Tender Domain UI", "surface": "engine", "status": ["tech-debt-noncanonical"], "authority": "ENGINE", "summary": "Hardcoded tender/procurement UI in ENGINE." },
{ "id": "engine.tech_debt.tender_storage", "name": "Tender Storage", "surface": "engine", "status": ["tech-debt-noncanonical"], "authority": "ENGINE", "summary": "Current domain storage in ENGINE-side implementation." },
{ "id": "engine.tech_debt.tender_ai_review", "name": "Tender AI Review", "surface": "engine", "status": ["tech-debt-noncanonical"], "authority": "ENGINE", "summary": "Current AI review implementation for tender domain." },
{ "id": "engine.tech_debt.ops_tabs_control", "name": "ENGINE OPS Tabs Control", "surface": "engine", "status": ["tech-debt-noncanonical"], "authority": "ENGINE", "summary": "UI/control surface tied to current OpsLayer experiment." }
]
}

View File

@ -0,0 +1,31 @@
{
"version": "0.4-pre",
"updatedAt": "2026-06-19",
"sourceRoots": [
{ "surface": "hub", "path": "/Users/dcconstructions/Downloads/mnt/data/nodedc_launcher", "mode": "read-only source inspection" },
{ "surface": "ops", "path": "/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER", "mode": "read-only source inspection" },
{ "surface": "agent", "path": "/Users/dcconstructions/Downloads/mnt/data/NODEDC_TASKMANAGER_CODEXAPI", "mode": "read-only source inspection" },
{ "surface": "engine", "path": "/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_ENGINE_INFRA/nodedc-source", "mode": "read-only source inspection" },
{ "surface": "ontology", "path": "/Users/dcconstructions/Downloads/mnt/NODEDC_ONTOLOGY", "mode": "documentation workspace" }
],
"ledgers": [
{ "id": "ledger.hub_p0", "path": "docs/baseline/EVIDENCE_LEDGER_HUB_P0.md", "entityIds": ["hub.open_contour", "hub.public_pool_context", "hub.public_pool_client", "hub.user", "hub.application", "ndcauth.identity", "ndcauth.session"] },
{ "id": "ledger.ops_p0", "path": "docs/baseline/EVIDENCE_LEDGER_OPS_P0.md", "entityIds": ["ops.workspace", "ops.project", "ops.card", "ops.card_status", "ops.card_priority", "ops.assignee", "ops.card_relation"] },
{ "id": "ledger.ops_gateway_p1", "path": "docs/baseline/EVIDENCE_LEDGER_OPS_GATEWAY_P1.md", "entityIds": ["agent.identity", "agent.token", "agent.grant", "agent.scope", "agent.mcp_tool", "agent.audit_event", "agent.idempotency_key"] },
{ "id": "ledger.engine_dirty_boundary_p1", "path": "docs/baseline/EVIDENCE_LEDGER_ENGINE_DIRTY_BOUNDARY_P1.md", "entityIds": ["engine.tech_debt.ops_layer", "engine.tech_debt.agent_monitor_node", "engine.tech_debt.tender_domain_ui", "future.interface_layer"] },
{ "id": "ledger.engine_workflow_p1", "path": "docs/baseline/EVIDENCE_LEDGER_ENGINE_WORKFLOW_P1.md", "entityIds": ["engine.workflow_l1", "engine.node_l1", "engine.edge_l1", "engine.workflow_l2", "engine.workflow_l2_runtime_id", "engine.l2_execution", "engine.l2_run", "engine.l2_session", "engine.l2_runtime_event"] }
],
"baselineDocs": [
"docs/baseline/CANONICAL_ENTITY_CATALOG.md",
"docs/baseline/RELATION_CATALOG.md",
"docs/baseline/GUARDRAILS.md",
"docs/baseline/TERMS_GLOSSARY.md",
"docs/baseline/IMPLEMENTATION_READINESS.md",
"docs/baseline/OPEN_QUESTIONS.md"
],
"restrictions": [
"Do not ingest env/secrets/runtime/data/storage/logs/dumps.",
"Do not edit HUB/OPS/Gateway/ENGINE source code from this service task.",
"Do not treat browser ChatGPT RFCs as source truth without evidence ledger update."
]
}

View File

@ -0,0 +1,108 @@
{
"version": "0.4-pre",
"updatedAt": "2026-06-19",
"rules": [
{
"id": "guardrail.ops.must_qualify_ops_term",
"severity": "error",
"summary": "Do not say OPS without specifying OPS Product, OPS Gateway, or ENGINE-side OpsLayer.",
"entityIds": ["ops.card", "agent.identity", "engine.tech_debt.ops_layer"]
},
{
"id": "guardrail.ops.card_is_root_work_object",
"severity": "error",
"summary": "Use ops.card as canonical work object; issue/task/work item are aliases or subtypes.",
"entityIds": ["ops.card", "ops.card_type"]
},
{
"id": "guardrail.hub.open_contour_not_client_context",
"severity": "error",
"summary": "hub.open_contour is not an ordinary hub.client_context.",
"entityIds": ["hub.open_contour", "hub.client_context", "hub.public_pool_client"]
},
{
"id": "guardrail.hub.application_card_not_authority",
"severity": "error",
"summary": "hub.application_card is presentation only; access authority belongs to application_access/grant/exception.",
"entityIds": ["hub.application_card", "hub.application_access", "hub.app_grant", "hub.app_exception"]
},
{
"id": "guardrail.gateway.identity_not_user_or_assistant",
"severity": "error",
"summary": "agent.identity is Gateway execution identity, not human user and not assistant persona.",
"entityIds": ["agent.identity", "hub.user", "assistant.assistant"]
},
{
"id": "guardrail.gateway_enforces_permissions",
"severity": "error",
"summary": "Ontology advises context; OPS Gateway enforces grants, scopes, audit, and idempotency.",
"entityIds": ["agent.grant", "agent.scope", "agent.audit_event", "agent.idempotency_key", "assistant.bridge"]
},
{
"id": "guardrail.engine.l1_l2_runtime_ids_are_distinct",
"severity": "error",
"summary": "engine.workflow_l1 id, engine.workflow_l2, and engine.workflow_l2_runtime_id are distinct concepts.",
"entityIds": ["engine.workflow_l1", "engine.workflow_l2", "engine.workflow_l2_runtime_id"]
},
{
"id": "guardrail.engine.ops_layer_noncanonical",
"severity": "error",
"summary": "ENGINE-side OpsLayer/Agent Monitor/tender UI are current tech debt and must not define OPS Product ontology.",
"entityIds": ["engine.tech_debt.ops_layer", "engine.tech_debt.agent_monitor_node", "engine.tech_debt.tender_domain_ui", "ops.card"]
},
{
"id": "guardrail.interface_layer_not_engine_core_ui",
"severity": "warning",
"summary": "Future automation UI belongs in a dedicated Interface Layer, not hardcoded inside ENGINE core.",
"entityIds": ["future.interface_layer", "future.interface_view", "future.interface_binding", "engine.workflow_l1"]
},
{
"id": "guardrail.assistant_role_never_bypasses_launcher_scope",
"severity": "error",
"summary": "Assistant admin role unlocks assistant-admin commands only inside existing Launcher root/client admin scope.",
"entityIds": ["assistant.access_role", "assistant.admin_scope", "hub.membership", "hub.role"]
},
{
"id": "guardrail.assistant_blocked_hides_all_surfaces",
"severity": "error",
"summary": "Blocked assistant access means no assistant icon/surface and no assistant capability in HUB, OPS Product, ENGINE, or Gateway flows.",
"entityIds": ["assistant.core_access", "assistant.access_role", "assistant.capability"]
},
{
"id": "guardrail.assistant_member_not_admin",
"severity": "error",
"summary": "Assistant member mode allows personal/basic assistant use but not user, role, invite, or cross-user access administration.",
"entityIds": ["assistant.access_role", "assistant.capability", "hub.user", "hub.invite"]
},
{
"id": "guardrail.assistant_actions_must_be_registered",
"severity": "error",
"summary": "Assistant must resolve user requests to registered action cards before policy checks or execution.",
"entityIds": ["assistant.action_registry", "assistant.action", "assistant.action_request"]
},
{
"id": "guardrail.assistant_never_hard_deletes",
"severity": "error",
"summary": "Assistant actions must not hard-delete users, records, database rows, files, or production state.",
"entityIds": ["assistant.action", "assistant.risk_policy", "hub.user", "ops.card", "engine.workflow_l1"]
},
{
"id": "guardrail.assistant_no_self_lockout",
"severity": "error",
"summary": "Assistant must not allow a user to remove, block, or disable their own last admin/access path.",
"entityIds": ["assistant.action", "assistant.admin_scope", "hub.membership", "hub.user"]
}
],
"blockedConflations": [
["ops.card", "engine.tech_debt.ops_layer"],
["agent.identity", "hub.user"],
["agent.identity", "assistant.assistant"],
["assistant.access_role", "hub.role"],
["assistant.admin_scope", "agent.grant"],
["assistant.action", "assistant.capability"],
["hub.open_contour", "hub.client_context"],
["hub.application_card", "hub.application_access"],
["engine.workflow_l1", "engine.workflow_l2_runtime_id"],
["future.interface_layer", "engine.tech_debt.tender_domain_ui"]
]
}

View File

@ -0,0 +1,81 @@
{
"version": "0.4-pre",
"updatedAt": "2026-06-19",
"relations": [
{ "id": "hub.open_contour.uses_public_pool", "from": ["hub.open_contour"], "to": ["hub.public_pool_context"], "status": "source-evidenced", "summary": "Open contour is implemented through public pool context." },
{ "id": "hub.public_pool_context.backed_by_client", "from": ["hub.public_pool_context"], "to": ["hub.public_pool_client"], "status": "source-confirmed", "summary": "Public pool context uses special source-backed Client." },
{ "id": "hub.user.has_membership", "from": ["hub.user"], "to": ["hub.membership"], "status": "source-confirmed", "summary": "User belongs to context through membership." },
{ "id": "hub.membership.in_context", "from": ["hub.membership"], "to": ["hub.client_context", "hub.public_pool_context"], "status": "source-confirmed", "summary": "Membership points to closed client context or public pool." },
{ "id": "hub.access_request.targets_context", "from": ["hub.access_request"], "to": ["hub.client_context", "hub.public_pool_context"], "status": "source-confirmed", "summary": "Request targets a context." },
{ "id": "hub.public_access_request.promotes_to_client_context", "from": ["hub.public_access_request"], "to": ["hub.client_context"], "status": "source-confirmed", "summary": "Public request can become client membership after approval." },
{ "id": "hub.invite.targets_context", "from": ["hub.invite"], "to": ["hub.client_context", "hub.public_pool_context"], "status": "source-confirmed", "summary": "Invite targets a context." },
{ "id": "hub.application.has_access_result", "from": ["hub.application"], "to": ["hub.application_access"], "status": "source-confirmed", "summary": "Application exposes effective access state." },
{ "id": "hub.app_grant.grants_access_to", "from": ["hub.app_grant"], "to": ["hub.application"], "status": "source-confirmed", "summary": "Grant contributes to effective access." },
{ "id": "hub.app_exception.overrides_access_to", "from": ["hub.app_exception"], "to": ["hub.application"], "status": "source-confirmed", "summary": "User-specific allow/deny exception." },
{ "id": "hub.application_card.presents", "from": ["hub.application_card"], "to": ["hub.application"], "status": "source-confirmed", "summary": "UI card presents app plus access state." },
{ "id": "ndcauth.identity.maps_to_hub_user", "from": ["ndcauth.identity"], "to": ["hub.user"], "status": "source-confirmed", "summary": "Auth subject resolves to launcher user." },
{ "id": "ops.workspace.contains_project", "from": ["ops.workspace"], "to": ["ops.project"], "status": "source-confirmed", "summary": "Project belongs to workspace." },
{ "id": "ops.workspace.has_member", "from": ["ops.workspace"], "to": ["ops.workspace_member"], "status": "source-confirmed", "summary": "Workspace membership." },
{ "id": "ops.project.has_member", "from": ["ops.project"], "to": ["ops.project_member"], "status": "source-confirmed", "summary": "Project membership." },
{ "id": "ops.project.contains_card", "from": ["ops.project"], "to": ["ops.card"], "status": "source-confirmed", "summary": "Card belongs to project." },
{ "id": "ops.card.has_status", "from": ["ops.card"], "to": ["ops.card_status"], "status": "source-confirmed", "summary": "Card state." },
{ "id": "ops.card.has_priority", "from": ["ops.card"], "to": ["ops.card_priority"], "status": "source-confirmed", "summary": "Card priority attribute." },
{ "id": "ops.card.assigned_to", "from": ["ops.card"], "to": ["ops.assignee"], "status": "source-confirmed", "summary": "Card assignment." },
{ "id": "ops.card.accountable_to", "from": ["ops.card"], "to": ["ops.responsible"], "status": "product-required", "summary": "Product-level responsibility relation." },
{ "id": "ops.card.typed_as", "from": ["ops.card"], "to": ["ops.card_type"], "status": "source-evidenced", "summary": "Card subtype/purpose." },
{ "id": "ops.card.related_to", "from": ["ops.card"], "to": ["ops.card_relation"], "status": "source-confirmed", "summary": "Relation/dependency between cards." },
{ "id": "ops.card.has_label", "from": ["ops.card"], "to": ["ops.card_label"], "status": "source-confirmed", "summary": "Label/tag classification." },
{ "id": "ops.card.has_comment", "from": ["ops.card"], "to": ["ops.card_comment"], "status": "source-confirmed", "summary": "Discussion/comment relation." },
{ "id": "ops.card.has_activity", "from": ["ops.card"], "to": ["ops.card_activity"], "status": "source-evidenced", "summary": "Timeline/change relation." },
{ "id": "ops.card.links_artifact", "from": ["ops.card"], "to": ["ops.card_artifact_link"], "status": "source-evidenced", "summary": "Links output artifacts or docs." },
{ "id": "ops.card.has_external_ref", "from": ["ops.card"], "to": ["ops.external_ref"], "status": "source-confirmed", "summary": "Links external/source object." },
{ "id": "agent.token.represents_identity", "from": ["agent.token"], "to": ["agent.identity"], "status": "source-confirmed", "summary": "Token authenticates agent identity, not user identity." },
{ "id": "agent.identity.has_grant", "from": ["agent.identity"], "to": ["agent.grant"], "status": "source-confirmed", "summary": "Identity receives grants." },
{ "id": "agent.token.has_token_grant", "from": ["agent.token"], "to": ["agent.token_grant"], "status": "source-confirmed", "summary": "Token-level grant binding." },
{ "id": "agent.grant.allows_scope", "from": ["agent.grant"], "to": ["agent.scope"], "status": "source-confirmed", "summary": "Grant enables tool/capability scope." },
{ "id": "agent.scope.exposes_tool", "from": ["agent.scope"], "to": ["agent.mcp_tool"], "status": "source-confirmed", "summary": "Scope controls MCP tool exposure." },
{ "id": "agent.mcp_tool.writes_via_adapter", "from": ["agent.mcp_tool"], "to": ["agent.tasker_adapter"], "status": "source-confirmed", "summary": "Gateway writes to OPS through adapter." },
{ "id": "agent.mcp_tool.requires_idempotency", "from": ["agent.mcp_tool"], "to": ["agent.idempotency_key"], "status": "source-confirmed", "summary": "Write operations require idempotency." },
{ "id": "agent.mcp_tool.emits_audit", "from": ["agent.mcp_tool"], "to": ["agent.audit_event"], "status": "source-confirmed", "summary": "Operations produce audit records." },
{ "id": "agent.setup_packet.provisions_token", "from": ["agent.setup_packet"], "to": ["agent.token"], "status": "source-evidenced", "summary": "Setup flow produces token/connect config." },
{ "id": "engine.workflow_l1.contains_node", "from": ["engine.workflow_l1"], "to": ["engine.node_l1"], "status": "source-confirmed", "summary": "L1 workflow graph contains nodes." },
{ "id": "engine.workflow_l1.contains_edge", "from": ["engine.workflow_l1"], "to": ["engine.edge_l1"], "status": "source-confirmed", "summary": "L1 workflow graph contains edges." },
{ "id": "engine.edge_l1.connects_nodes", "from": ["engine.edge_l1"], "to": ["engine.node_l1"], "status": "source-confirmed", "summary": "Edge connects source/target node handles." },
{ "id": "engine.node_l1.has_type", "from": ["engine.node_l1"], "to": ["engine.node_type_l1"], "status": "source-confirmed", "summary": "Node type comes from registry/module." },
{ "id": "engine.workflow_l1.has_acl", "from": ["engine.workflow_l1"], "to": ["engine.workflow_acl"], "status": "source-confirmed", "summary": "Workflow uses access control." },
{ "id": "engine.workflow_acl.has_owner", "from": ["engine.workflow_acl"], "to": ["engine.workflow_owner"], "status": "source-confirmed", "summary": "ACL owns owner field." },
{ "id": "engine.workflow_l1.has_share", "from": ["engine.workflow_l1"], "to": ["engine.workflow_share"], "status": "source-confirmed", "summary": "Workflow can be shared/invited." },
{ "id": "engine.workflow_l1.has_access_request", "from": ["engine.workflow_l1"], "to": ["engine.workflow_access_request"], "status": "source-confirmed", "summary": "Access requests target workflow." },
{ "id": "engine.node_l1.embeds_l2_workflow", "from": ["engine.node_l1"], "to": ["engine.workflow_l2"], "status": "source-evidenced", "summary": "n8n subworkflow is attached to L1 node." },
{ "id": "engine.workflow_l2.deployed_as_runtime_workflow", "from": ["engine.workflow_l2"], "to": ["engine.workflow_l2_runtime_id"], "status": "source-confirmed", "summary": "Compiled n8n workflow deploy returns runtime workflow id." },
{ "id": "engine.workflow_l2.runs_on_runtime_core", "from": ["engine.workflow_l2"], "to": ["engine.l2_runtime_core"], "status": "source-evidenced", "summary": "L2 workflow targets n8n instance/core." },
{ "id": "engine.l2_execution.belongs_to_runtime_workflow", "from": ["engine.l2_execution"], "to": ["engine.workflow_l2_runtime_id"], "status": "source-confirmed", "summary": "Execution is emitted for n8n workflow id." },
{ "id": "engine.l2_runtime_event.describes_execution", "from": ["engine.l2_runtime_event"], "to": ["engine.l2_execution"], "status": "source-confirmed", "summary": "Runtime events carry execution id." },
{ "id": "engine.l2_runtime_event.binds_run", "from": ["engine.l2_runtime_event"], "to": ["engine.l2_run"], "status": "source-confirmed", "summary": "Event binds to run id. Current storage is ENGINE-side OpsLayer." },
{ "id": "engine.l2_run.has_session", "from": ["engine.l2_run"], "to": ["engine.l2_session"], "status": "source-confirmed", "summary": "Session anchors related runtime events/runs." },
{ "id": "assistant.core_access.assigned_by_role_assignment", "from": ["assistant.core_access"], "to": ["assistant.role_assignment"], "status": "product-required", "summary": "Assistant access is materialized as a scoped role assignment." },
{ "id": "assistant.role_assignment.targets_user", "from": ["assistant.role_assignment"], "to": ["hub.user"], "status": "product-required", "summary": "Assistant role assignment targets a HUB user." },
{ "id": "assistant.role_assignment.has_role", "from": ["assistant.role_assignment"], "to": ["assistant.access_role"], "status": "product-required", "summary": "Assignment grants one assistant access role." },
{ "id": "assistant.role_assignment.scoped_to_context", "from": ["assistant.role_assignment"], "to": ["hub.client_context", "hub.public_pool_context"], "status": "product-required", "summary": "Assistant role assignment may be scoped to a client or public-pool context." },
{ "id": "assistant.access_role.allows_capability", "from": ["assistant.access_role"], "to": ["assistant.capability"], "status": "product-required", "summary": "Role contributes assistant capabilities before scope guards are applied." },
{ "id": "assistant.permission_policy.derives_admin_scope", "from": ["assistant.permission_policy"], "to": ["assistant.admin_scope"], "status": "source-evidenced", "summary": "Policy derives assistant-admin authority from Launcher admin scope." },
{ "id": "assistant.admin_scope.derived_from_hub_membership", "from": ["assistant.admin_scope"], "to": ["hub.membership", "hub.role"], "status": "source-evidenced", "summary": "Client admin scope comes from active client_owner/client_admin membership." },
{ "id": "assistant.action_request.checked_by_permission_policy", "from": ["assistant.action_request"], "to": ["assistant.permission_policy"], "status": "product-required", "summary": "Requested assistant action must pass assistant policy first." },
{ "id": "assistant.action_request.executes_through_bridge", "from": ["assistant.action_request"], "to": ["assistant.bridge", "agent.mcp_tool"], "status": "product-required", "summary": "Approved assistant action still executes through bridge/gateway tooling." },
{ "id": "assistant.action_registry.contains_action", "from": ["assistant.action_registry"], "to": ["assistant.action"], "status": "product-required", "summary": "Registry owns declarative assistant action cards." },
{ "id": "assistant.action.classified_by_risk_policy", "from": ["assistant.action"], "to": ["assistant.risk_policy"], "status": "product-required", "summary": "Every assistant action has risk classification and policy gates." },
{ "id": "assistant.action.requires_confirmation_policy", "from": ["assistant.action"], "to": ["assistant.confirmation_policy"], "status": "product-required", "summary": "Action card declares confirmation requirements before execution." },
{ "id": "assistant.action.executed_by_adapter", "from": ["assistant.action"], "to": ["assistant.app_adapter"], "status": "product-required", "summary": "Application-owned adapter executes an approved action." },
{ "id": "assistant.action_request.resolves_to_action", "from": ["assistant.action_request"], "to": ["assistant.action"], "status": "product-required", "summary": "Natural-language request must resolve to a known action card before any execution." },
{ "id": "ontology.resolves_ops_card_to_engine_context", "from": ["ops.card"], "to": ["engine.workflow_l1", "engine.workflow_l2"], "status": "product-required", "summary": "Assistant in OPS can route a request to Engine workflow context." },
{ "id": "ontology.resolves_engine_context_to_ops_project", "from": ["engine.workflow_l1", "engine.workflow_l2"], "to": ["ops.project"], "status": "product-required", "summary": "Assistant in Engine can create/update cards in the correct OPS project." },
{ "id": "ontology.resolves_hub_app_to_project_context", "from": ["hub.application"], "to": ["ops.project", "engine.workflow_l1"], "status": "product-required", "summary": "Application selection can suggest project/workflow context." },
{ "id": "ontology.selects_gateway_grant_context", "from": ["assistant.bridge"], "to": ["agent.grant"], "status": "product-required", "summary": "Ontology can help choose proper MCP/Gateway grant before tool calls." },
{ "id": "ontology.routes_interface_view_to_domain_package", "from": ["future.interface_view"], "to": ["future.domain_package"], "status": "future-concept", "summary": "Future UI layer renders domain-specific views from ontology-backed data." }
]
}

View File

@ -0,0 +1,51 @@
{
"version": "0.1.0",
"updatedAt": "2026-06-19",
"rules": [
{
"id": "resolver.ops_to_engine.workflow_context",
"relationId": "ontology.resolves_ops_card_to_engine_context",
"fromSurface": "ops",
"inputEntityIds": ["ops.card", "ops.project"],
"intentHints": ["engine", "workflow", "l1", "l2", "n8n", "inspect", "switch", "runtime", "докодим", "воркфлоу"],
"outputSurface": "engine",
"outputEntityIds": ["engine.workflow_l1", "engine.node_l1", "engine.workflow_l2", "engine.workflow_l2_runtime_id"],
"requiredBindings": [
"ops.card -> ops.external_ref -> engine.workflow_l1|engine.workflow_l2",
"ops.project -> hub.application|engine.workflow_l1 context"
],
"status": "product-required",
"summary": "Route an OPS card/project request into Engine workflow context."
},
{
"id": "resolver.engine_to_ops.card_context",
"relationId": "ontology.resolves_engine_context_to_ops_project",
"fromSurface": "engine",
"inputEntityIds": ["engine.workflow_l1", "engine.node_l1", "engine.workflow_l2", "engine.l2_runtime_event"],
"intentHints": ["ops", "card", "task", "issue", "project", "report", "создай", "карточ", "задач"],
"outputSurface": "ops",
"outputEntityIds": ["ops.workspace", "ops.project", "ops.card", "ops.card_type"],
"requiredBindings": [
"engine.workflow_l1 -> ops.project",
"engine.workflow_l1|engine.node_l1 -> ops.card external_ref"
],
"status": "product-required",
"summary": "Route an Engine workflow/node request into OPS project/card context."
},
{
"id": "resolver.gateway_preflight.context_grant",
"relationId": "ontology.selects_gateway_grant_context",
"fromSurface": "assistant",
"inputEntityIds": ["assistant.bridge", "assistant.executor"],
"intentHints": ["mcp", "gateway", "grant", "tool", "token"],
"outputSurface": "agent",
"outputEntityIds": ["agent.grant", "agent.scope", "agent.mcp_tool", "agent.idempotency_key"],
"requiredBindings": [
"assistant.bridge -> target surface context",
"target context -> agent.grant"
],
"status": "product-required",
"summary": "Suggest Gateway grant/tool context before MCP calls; Gateway remains enforcement authority."
}
]
}

View File

@ -0,0 +1,104 @@
# NDC Core Assistant Access Policy
Status: draft source-evidenced slice.
This document defines assistant access as a thin policy overlay over the existing HUB/Launcher admin model. It does not replace Launcher roles, OPS Product permissions, Engine ACLs, or OPS Gateway grants.
## Roles
`assistant_blocked`
- no assistant icon;
- no assistant surface in HUB, OPS Product, ENGINE, or Gateway-mediated flows;
- no assistant capabilities.
`assistant_member`
- default for active users;
- matches the current personal assistant expectation;
- can use assistant in the user's allowed contexts;
- cannot manage users, invites, memberships, access requests, or other users' access.
`assistant_admin`
- enables assistant-mediated admin commands;
- works only when existing Launcher admin scope is present;
- never creates Launcher admin scope by itself;
- client admins stay inside their client scope;
- root-only backend operations stay root-only.
## Source-backed Launcher rules
Current HUB/Launcher source model has these authority inputs:
- `LauncherGlobalRole`: `root_admin`, `support_admin`, `client_owner`, `client_admin`, `member`;
- `LauncherUserStatus`: `invited`, `active`, `blocked`;
- `ClientMembershipRole`: `client_owner`, `client_admin`, `member`;
- `ClientMembershipStatus`: `active`, `disabled`.
Backend admin scope is source-backed by:
- root groups: `nodedc:superadmin`, `nodedc:launcher:admin`;
- active client admin membership: `client_owner` or `client_admin`;
- guard functions: `requireLauncherAdmin`, `requireRootLauncherAdmin`, `assertAdminCanManageClient`, `assertAdminCanManageUser`, `assertAdminCanManageAccessForUser`.
The important rule: `assistant_admin` can request only actions that the same user could already perform manually through Launcher admin endpoints.
## Practical first use case
The first useful implementation target is assistant-mediated access management:
- "block user X in Operational Core";
- "change user X to member/admin in this client";
- "show pending invites for today";
- "grant user X member access to this Operational Core workspace";
- "grant user X member/viewer access to this Operational Core project".
These are practical because Launcher already exposes equivalent manual flows in the admin overlay:
- MAIN status;
- MAIN role;
- service access matrix;
- Operational Core workspace/project memberships;
- invites;
- access requests.
## Hard denies
The assistant policy must keep these hard-deny rules:
- blocked assistant access hides all assistant UI and capabilities;
- blocked Launcher user means no effective assistant access;
- disabled membership removes context admin authority;
- `assistant_member` cannot administer users/access;
- `assistant_admin` does not bypass Launcher admin scope;
- protected `user_root` remains protected by source guards;
- Task Manager/Operational Core `admin` role is currently locked in Launcher mutation routes;
- root-only endpoints stay root-only.
## Planned HUB UI shape
The proposed Launcher admin UI addition is a column/control named `NDC Core Assistant`.
Initial values:
- `Blocked`;
- `Member`;
- `Admin`.
Recommended default:
- active ordinary users: `Member`;
- blocked users: effective `Blocked`;
- superadmin/root: effective `Admin`;
- explicit assignment should be stored later as assistant role assignment, not as a replacement for Launcher role.
## CLI checks
```text
npm run smoke:assistant-policy
npm run assistant:policy -- --input-json examples/assistant-access-member.example.json
npm run assistant:policy -- --input-json examples/assistant-access-admin-client.example.json
npm run assistant:policy -- --input-json examples/assistant-access-blocked.example.json
npm run assistant:policy -- --input-json examples/assistant-access-superadmin.example.json
```

View File

@ -0,0 +1,184 @@
# Assistant Action Registry
Status: draft implementation contract.
Date: 2026-06-19
This document defines the scalable execution model for NDC Core Assistant actions.
The assistant must not grow through prompt-only rules. Every executable or refusible command should resolve to a registered action card, pass assistant access policy, pass risk policy, and then route to an app-owned adapter.
## Execution Pipeline
```mermaid
flowchart LR
A["User request"] --> B["Action resolver"]
B --> C["Ontology context"]
C --> D["Assistant access policy"]
D --> E["Risk policy"]
E --> F["App-owned adapter"]
F --> G["Confirmation"]
G --> H["Audit / idempotent execution"]
```
The resolver can return:
- `action_not_registered`: no action card exists;
- `forbidden`: the action is destructive or explicitly forbidden;
- `denied`: the actor lacks capability, admin scope, or self-action safety;
- `needs_confirmation`: policy allows the action, but explicit confirmation is required;
- `future_adapter`: the correct app boundary is known, but the adapter is not implemented yet;
- `allowed`: policy allows the action. `execution.executionReady` is true only when the adapter is implemented.
## Action Card
Action cards live in:
```text
catalog/assistant-actions.json
```
Each action card must define:
- `id`: stable action id, for example `hub.user.block`;
- `app`: authority boundary: `hub`, `ops`, `engine`, or `gateway`;
- `domain`: local functional area;
- `intentAliases`: phrases the resolver can match;
- `entityIds`: ontology entities touched by the action;
- `capabilityIds`: assistant capabilities required from `assistant-access-policy.json`;
- `riskLevel`: `read`, `write`, `privileged`, `destructive`, `root_only`, or `future`;
- `confirmationMode`: `none`, `explicit`, `typed`, or `forbidden`;
- `selfActionPolicy`: `allowed`, `blocked`, `no_self_lockout`, or `app_owned`;
- `requiredScopes`: source-app scope checks required for privileged actions;
- `adapterId` and `adapterStatus`: the app-owned execution boundary;
- `hardDenyIds` and `safeAlternativeActionIds` when relevant.
The validator rejects dangling entity, capability, risk, adapter-status, hard-deny, and safe-alternative references.
## App Boundaries
HUB / Launcher owns:
- entry access;
- users;
- client memberships;
- invites;
- service access matrix;
- NDC Core Assistant role assignment.
OPS Product owns:
- workspaces;
- projects;
- cards;
- workspace/project membership;
- OPS-internal role rules.
ENGINE owns:
- workflow context;
- workflow ACL;
- workflow sharing;
- runtime/workflow source of truth.
OPS Gateway owns:
- tool grants;
- MCP/tool scope selection;
- idempotency;
- audit envelope;
- adapter execution routing.
HUB may route to OPS or ENGINE, but must not directly mutate OPS Product roles or ENGINE workflow ACLs. Those mutations must go through app-owned adapters.
## Hard Denies
The assistant must never:
- delete HUB users;
- hard-delete database records, files, storage, backups, runtime data, or production state;
- let an actor remove their own last admin/access path;
- mutate app-owned roles through the wrong app boundary;
- execute a write command from prompt text alone without a registered action card.
Preferred safe alternatives:
- block a user instead of deleting the user;
- disable a membership instead of deleting access records;
- update/close an OPS card instead of deleting it;
- route ENGINE workflow ACL changes to an Engine-owned adapter.
## First Practical Block
The first implementation block should be HUB access administration:
- read admin-visible user/access summaries;
- list pending invites;
- block/unblock users;
- disable memberships;
- change `NDC Core Assistant` role between `blocked`, `member`, and `admin`.
This is the lowest-risk useful block because the Launcher already has manual admin flows and backend guards. The assistant adapter should call the same guarded routes and add idempotency/audit around them.
## Adding A New Functional Block
To add another block without prompt sprawl:
1. Add or reuse canonical entities in `catalog/entities.json`.
2. Add required capabilities to `catalog/assistant-access-policy.json`.
3. Add action cards to `catalog/assistant-actions.json`.
4. Classify risk and confirmation using `catalog/assistant-risk-policy.json`.
5. Add examples under `examples/assistant-action-*.json`.
6. Run validation and smoke tests.
7. Only then implement the app-owned adapter.
Commands:
```text
npm run validate
npm run smoke:assistant-policy
npm run smoke:assistant-actions
npm run smoke:assistant-action-plan
npm run smoke:assistant-caller
npm run smoke:assistant-executor
npm run assistant:action -- --input-json examples/assistant-action-block-user.example.json
npm run assistant:plan -- --input-json examples/assistant-action-block-user.example.json
npm run assistant:caller -- --input-json examples/assistant-caller-preview-block-user.example.json
npm run assistant:execute -- --input-json examples/assistant-action-block-user.example.json
```
## Current Limitation
Many non-HUB action cards are currently `adapterStatus=planned` or `adapterStatus=future`.
That is intentional. The ontology core can already answer whether an action is conceptually registered and policy-allowed, but actual mutation must wait for the app-owned adapter implementation.
The current HUB adapter maps the first implemented allowlist block:
```text
src/adapters/hub-launcher-admin.mjs
```
It maps allowed HUB actions to guarded Launcher backend routes:
- `GET /api/admin/control-plane`;
- `PATCH /api/admin/users/:userId/profile`;
- `PATCH /api/admin/memberships/:membershipId`.
It intentionally does not map existing Launcher `DELETE` routes. Deletion requests resolve to `forbidden` before a request plan is produced.
The UI/caller contract lives in:
```text
src/assistant-action-caller.mjs
docs/ASSISTANT_CALLER_CONTRACT.md
```
Callers should use it for preview/execute flows instead of calling the low-level executor directly.
The execution gateway lives in:
```text
src/assistant-action-executor.mjs
```
It defaults to dry-run, validates the request plan, requires confirmation tokens for write execution, and refuses network execution until the specific adapter is marked `implemented`.

View File

@ -0,0 +1,157 @@
# Assistant Caller Contract
Status: first UI/caller contract.
Date: 2026-06-19
This contract sits above the low-level execution gateway. UI and assistant surfaces should call this layer instead of calling the executor directly.
## Phases
`preview`
- resolves the requested action;
- runs policy and route safety checks;
- does not make network requests;
- returns a compact preview for UI;
- returns a `confirmation.token` for write actions.
`execute`
- requires the same action input;
- requires `confirmationToken` for write actions;
- requires Launcher internal token for HUB gateway execution;
- returns a normalized execution result.
## Preview Request
```json
{
"phase": "preview",
"input": {
"actionId": "hub.user.block",
"assistantRole": "admin",
"membershipRole": "client_admin",
"actorUserId": "user_root",
"targetUserId": "user_silver_psih",
"confirmed": true,
"idempotencyKey": "ui:block:user_silver_psih:2026-06-19"
}
}
```
CLI:
```text
npm run assistant:caller -- --input-json examples/assistant-caller-preview-block-user.example.json
```
## Preview Response
Write preview returns:
```json
{
"ok": true,
"phase": "preview",
"decision": "preview_ready",
"preview": {
"app": "hub",
"actionId": "hub.user.block",
"riskLevel": "privileged",
"expectedEffect": "Block HUB user account through existing Launcher admin guards.",
"request": {
"method": "PATCH",
"path": "/api/admin/users/user_silver_psih/profile",
"body": {
"globalStatus": "blocked"
}
}
},
"confirmation": {
"token": "<preview token>",
"request": {
"method": "PATCH",
"path": "/api/admin/users/user_silver_psih/profile",
"idempotencyKey": "ui:block:user_silver_psih:2026-06-19"
}
},
"execution": {
"requiresUserConfirmation": true,
"requiresInternalToken": true
}
}
```
The UI must show the exact app/action/target/effect before execute. It must not silently execute privileged write previews.
## Execute Request
```json
{
"phase": "execute",
"confirmationToken": "<preview token>",
"input": {
"actionId": "hub.user.block",
"assistantRole": "admin",
"membershipRole": "client_admin",
"actorUserId": "user_root",
"targetUserId": "user_silver_psih",
"confirmed": true,
"idempotencyKey": "ui:block:user_silver_psih:2026-06-19"
}
}
```
CLI:
```text
NDC_LAUNCHER_INTERNAL_ACCESS_TOKEN=... npm run assistant:caller -- --base-url http://launcher.local.nodedc --input-json request.json
```
## Execute Response
Successful execution returns:
```json
{
"ok": true,
"phase": "execute",
"decision": "executed",
"result": {
"status": 200,
"method": "PATCH"
}
}
```
Blocked execution returns:
```json
{
"ok": false,
"phase": "execute",
"decision": "execution_blocked",
"reason": "write_confirmation_envelope_missing"
}
```
## Rules For Callers
- Never call `execute` for write actions without first showing `preview`.
- Never generate or modify `confirmationToken` in UI.
- Never reuse a token after changing action input, target, body, idempotency key, or actor.
- Never map delete requests to hidden alternatives. Show the refusal and safe alternatives from preview.
- Keep app ownership visible: HUB actions mutate HUB/Launcher only; OPS and ENGINE actions require their own adapters.
## Tests
```text
npm run smoke:assistant-caller
```
The smoke test covers:
- write preview returns confirmation token;
- execute without token is blocked;
- execute with token and mock internal token reaches the adapter;
- read preview does not require confirmation token.

View File

@ -0,0 +1,171 @@
# Assistant Execution Gateway
Status: first HUB gateway execution path implemented.
Date: 2026-06-19
This document defines the execution boundary after an assistant request resolves to an action card and an app-owned request plan.
The gateway exists to keep assistant execution out of prompt-only logic. It is a code-level safety boundary.
UI and assistant surfaces should call the higher-level caller contract first:
```text
docs/ASSISTANT_CALLER_CONTRACT.md
src/assistant-action-caller.mjs
```
The low-level executor remains the final safety gate.
## Modes
`dry-run`
- default mode;
- builds the action plan;
- validates route/method/idempotency safety;
- for write requests, returns a confirmation envelope and token;
- does not make network requests.
`execute`
- available only when the caller explicitly passes execution mode;
- requires network execution to be enabled;
- requires `baseUrl`;
- refuses planned/future adapters unless an explicit unsafe dev override is set;
- for write requests, requires a matching confirmation envelope token from a previous preview;
- for `adapter.hub.launcher_admin_api`, requires Launcher internal token and assistant actor identity headers;
- still refuses forbidden/destructive actions and non-allowlisted routes.
## Current CLI
Dry-run:
```text
npm run assistant:execute -- --input-json examples/assistant-action-block-user.example.json
```
For write actions, dry-run returns:
```text
confirmationEnvelope.token: <preview token>
```
Attempted network execution:
```text
npm run assistant:execute -- --execute --base-url http://launcher.local.nodedc --input-json examples/assistant-action-block-user.example.json
```
For write actions, execution without the preview token returns:
```text
decision: execution_blocked
reason: write_confirmation_envelope_missing
```
Live write execution requires both:
- the matching `--confirmation-token` from dry-run;
- `NDC_LAUNCHER_INTERNAL_ACCESS_TOKEN` or `NODEDC_INTERNAL_ACCESS_TOKEN`;
- an input actor.
```text
NDC_LAUNCHER_INTERNAL_ACCESS_TOKEN=... npm run assistant:execute -- --execute --confirmation-token <preview token> --base-url http://launcher.local.nodedc --input-json examples/assistant-action-block-user.example.json
```
The executor sends the token as `Authorization: Bearer ...` plus:
```text
X-NODEDC-Assistant-Gateway: ontology-core
X-NODEDC-Assistant-Actor-User-Id: <actorUserId>
```
The Launcher BFF accepts this server-to-server path only for the allowlisted routes below. The actor still has to resolve to an active Launcher user with Launcher admin scope, and the request still passes the source route guards.
## Safety Checks
The executor validates:
- the plan is `ok`;
- method is allowlisted;
- path is under `/api/admin/`;
- route is explicitly allowlisted;
- `DELETE` is always forbidden;
- write requests include `Idempotency-Key`;
- write requests passed prior confirmation checks;
- write execution includes a matching confirmation envelope token;
- destructive/forbidden action decisions cannot execute;
- adapter safety flags are present.
Current HUB allowlist:
```text
GET /api/admin/control-plane
PATCH /api/admin/users/:userId/profile
PATCH /api/admin/memberships/:membershipId
```
## Execution Readiness
The current implementation is still conservative:
- action cards can be policy-allowed;
- request plans can be generated;
- executor can dry-run, return write confirmation envelope, and mock-execute;
- real network execution is available only for implemented HUB allowlist actions and only with gateway auth headers.
## Launcher Route Readiness
Current Launcher BFF status for the first HUB block:
- `GET /api/admin/control-plane` is behind `requireLauncherAdmin`;
- `PATCH /api/admin/users/:userId/profile` is behind `requireLauncherAdmin` and `assertAdminCanManageUser`;
- `PATCH /api/admin/memberships/:membershipId` is behind `requireLauncherAdmin` and `assertAdminCanManageMembership`;
- if no browser session exists, `requireLauncherAdmin` accepts assistant gateway auth only for the three allowlisted routes;
- assistant gateway auth requires the shared internal token and `X-NODEDC-Assistant-Gateway: ontology-core`;
- gateway actor identity is passed through `X-NODEDC-Assistant-Actor-*` headers and resolved to a real Launcher user before admin scope checks;
- both PATCH routes emit control-plane audit through `controlPlaneStore`;
- both PATCH routes now use an `Idempotency-Key` envelope with replay and conflict behavior;
- Launcher contract tests cover these route/guard/idempotency invariants.
The idempotency envelope is process-local and intended to protect immediate retries/replays. It is enough for the current local assistant execution scaffold. A persistent gateway-level idempotency ledger should be added before distributed or multi-process production execution.
The following HUB actions are marked `implemented` because they map only to the current allowlist:
```text
hub.user.read_admin_summary
hub.invite.list_pending
hub.user.block
hub.user.unblock
hub.membership.change_role
hub.membership.disable
hub.assistant_access.change_role
```
OPS, ENGINE, Gateway-level action resolver, and broader admin routes remain `planned` or `future`.
To mark a route production-ready:
1. Verify source app guards directly.
2. Verify auth identity propagation.
3. Verify audit event output.
4. Verify idempotency behavior.
5. Add integration tests against local Launcher.
6. Change only the specific action adapter status to `implemented`.
7. Keep the route in both executor and Launcher BFF allowlists.
## Tests
```text
npm run smoke:assistant-executor
```
The smoke test covers:
- default dry-run;
- write confirmation envelope generation;
- write execution blocked without confirmation envelope token;
- implemented adapter blocking execution without internal token;
- mock execution through allowlisted PATCH route with gateway auth headers;
- delete action blocked before network;
- corrupted DELETE plan rejected by safety validation.

View File

@ -0,0 +1,91 @@
# Context Bindings
Status: first implementation slice
Date: 2026-06-19
`catalog/context-bindings.json` maps stable ontology concepts to real source-system identifiers.
It is intentionally separate from `catalog/entities.json`:
- `entities.json` says what concepts exist: `ops.card`, `engine.workflow_l1`, `engine.workflow_l2`.
- `context-bindings.json` says which concrete OPS card/project or Engine workflow/node is currently known.
## Current Seed Contexts
Active contexts currently seeded:
- OPS project `NDC PLATFORM`
- OPS card `Антология / Ontology Core`
These are real Tasker/OPS identifiers and are safe to use as the first resolver fixture.
## Current Cross-Surface Bindings
No active OPS <-> ENGINE binding is stored yet.
This is deliberate. The resolver should say:
```text
matched OPS context: yes
selected route: OPS -> ENGINE
missing binding: binding_type.ops_card_to_engine_workflow
```
That is better than pretending an Engine workflow is linked when it is not.
## Next Binding To Add
The first real productive binding should look like this, once the target Engine workflow/node is known:
```json
{
"id": "binding.ops.<card_or_project>.engine.<workflow>",
"typeId": "binding_type.ops_card_to_engine_workflow",
"status": "active",
"fromContextId": "context.ops.nodedc.ndc_platform.ontology_core_card",
"toContextId": "context.engine.<workflow_context_id>",
"summary": "Links OPS card/project context to the Engine workflow context."
}
```
Use the registry CLI instead of hand-editing when adding real contexts/bindings:
```text
npm run registry -- add-context --input-json examples/engine-context.example.json --dry-run
npm run registry -- add-binding --input-json examples/ops-engine-binding.example.json --dry-run
npm run registry -- import-engine-context --input-json examples/engine-workflow-manifest.example.json --dry-run
```
Remove `--dry-run` only after replacing example refs with real source-system identifiers.
Preferred workflow for ENGINE is `import-engine-context`: it generates the context id and optional OPS binding from a manifest, then validates the whole registry.
The corresponding Engine context should include at least:
```json
{
"surface": "engine",
"entityId": "engine.workflow_l1",
"sourceSystem": "nodedc-engine",
"refs": {
"workflow_id": "...",
"node_id": "..."
}
}
```
If L2 runtime is known, add:
```json
{
"runtime_workflow_id": "...",
"n8n_instance_id": "..."
}
```
## Rules
- Do not store secrets, tokens, env values, runtime payloads, logs, dumps, or database rows here.
- Store stable identifiers only.
- Prefer explicit context IDs over free-form names.
- Keep planned bindings as `planned`; use `active` only when the target source-system refs are confirmed.

View File

@ -0,0 +1,164 @@
# Canonical Entity Catalog v0.4-pre
Status: pre-release implementation baseline
Date: 2026-06-18
Scope: NodeDC core ontology for HUB, OPS, OPS Gateway, ENGINE, Assistant routing, and future interface layer.
This catalog is the current canonical naming baseline. It is intentionally compact:
implementation can start from it, while open items stay marked instead of being hidden.
## Evidence Status
- `source-confirmed`: direct code/schema evidence exists.
- `source-evidenced`: source supports the concept, but naming or boundary is partly product-level.
- `owner-confirmed`: confirmed by product/architecture discussion.
- `product-required`: needed for target platform behavior, not fully implemented yet.
- `future-concept`: planned service/domain concept, not a current source truth.
- `tech-debt-noncanonical`: current working code that must not define the canonical model.
- `pending`: keep as candidate until a deeper source pass confirms it.
## HUB / Launcher
| Entity ID | Canonical name | Status | Authority | Notes |
| --- | --- | --- | --- | --- |
| `hub.open_contour` | HUB Open Contour | source-evidenced / owner-confirmed | HUB | Public/open branch for simple users. Must be separate from company/client contour. |
| `hub.public_pool_context` | HUB Public Pool Context | source-confirmed | HUB | Source-backed public context. |
| `hub.public_pool_client` | HUB Public Pool Client | source-confirmed | HUB | Special source-backed `Client`, not an ordinary company/client. |
| `hub.client_context` | HUB Client Context | source-confirmed | HUB | Managed closed contour for companies/clients. |
| `hub.company_contour` | HUB Company Contour | source-evidenced alias/subtype | HUB | Product wording over managed `hub.client_context`. |
| `hub.user` | HUB User | source-confirmed | HUB / NDCAuth bridge | Canonical user in launcher domain. |
| `hub.public_user` | HUB Public User Classification | source-evidenced | HUB | Classification of `hub.user` by public-pool membership/origin. |
| `hub.client_user` | HUB Client User Classification | source-evidenced | HUB | Classification of `hub.user` by client/company membership. |
| `hub.membership` | HUB Membership | source-confirmed | HUB | User-to-client/context relation. |
| `hub.public_membership` | HUB Public Membership | source-confirmed | HUB | Membership in public pool context. |
| `hub.access_request` | HUB Access Request | source-confirmed | HUB | Base access request entity. |
| `hub.public_access_request` | HUB Public Access Request | source-evidenced | HUB | Public/open-contour access request subtype. |
| `hub.client_access_request` | HUB Client Access Request | source-evidenced | HUB | Company/client-context access request subtype. |
| `hub.invite` | HUB Invite | source-confirmed | HUB | Invitation into a context. |
| `hub.group` | HUB Group | source-confirmed | HUB | Client-local group. |
| `hub.role` | HUB Role | source-confirmed | HUB | Role vocabulary for user/membership decisions. |
| `hub.permission` | HUB Permission | source-confirmed | HUB | Derived permission capability. |
| `hub.application` | HUB Application | source-confirmed | HUB | Product application/service registered in launcher. |
| `hub.application_access` | HUB Application Access | source-confirmed | HUB | Effective app access result. |
| `hub.app_grant` | HUB App Grant | source-confirmed | HUB | Access authority input. |
| `hub.app_exception` | HUB App Exception | source-confirmed | HUB | User-specific allow/deny override. |
| `hub.application_card` | HUB Application Card | source-confirmed UI/view | HUB UI | Presentation over application/access state; not access authority. |
## NDCAuth
| Entity ID | Canonical name | Status | Authority | Notes |
| --- | --- | --- | --- | --- |
| `ndcauth.identity` | NDCAuth Identity | source-confirmed | NDCAuth / HUB | Canonical auth identity. Authentik/OIDC/JWT are implementation aliases. |
| `ndcauth.session` | NDCAuth Session | source-confirmed | NDCAuth / HUB | Runtime authenticated/unauthenticated session. |
## OPS Product
OPS Product is the operational project/work layer. It is not ENGINE-side OpsLayer, Agent Monitor, tender-agent config, or OPS Gateway.
| Entity ID | Canonical name | Status | Authority | Notes |
| --- | --- | --- | --- | --- |
| `ops.workspace` | OPS Workspace | source-confirmed | OPS Product | Workspace/root collaboration context. |
| `ops.workspace_member` | OPS Workspace Member | source-confirmed | OPS Product | User membership in workspace. |
| `ops.project` | OPS Project | source-confirmed | OPS Product | Project inside workspace. |
| `ops.project_member` | OPS Project Member | source-confirmed | OPS Product | User membership in project. |
| `ops.card` | OPS Card | source-confirmed / owner-confirmed | OPS Product | Canonical work object. Plane `Issue`, task, work item are aliases/types. |
| `ops.card_status` | OPS Card Status | source-confirmed | OPS Product | State/status of a card. |
| `ops.card_priority` | OPS Card Priority | source-confirmed | OPS Product | Attribute of `ops.card`, not a root domain object. |
| `ops.card_assignment` | OPS Card Assignment | source-confirmed | OPS Product | Relation between card and assignee. |
| `ops.assignee` | OPS Assignee | source-confirmed | OPS Product | Assigned user/member. |
| `ops.responsible` | OPS Responsible | product-required / source-adjacent | OPS Product | Product-needed accountability role; not identical to assignee until source semantics are hardened. |
| `ops.card_type` | OPS Card Type | source-evidenced / pending taxonomy | OPS Product | Type/subtype of card: task, report, milestone, mailstone, architecture block, etc. |
| `ops.card_relation` | OPS Card Relation | source-confirmed | OPS Product | Dependency/link between cards. |
| `ops.external_ref` | OPS External Reference | source-confirmed | OPS Product | Link to external/source object. |
| `ops.card_label` | OPS Card Label | source-confirmed | OPS Product | Label/tag classification. |
| `ops.card_comment` | OPS Card Comment | source-confirmed | OPS Product | Discussion/comment on a card. |
| `ops.card_activity` | OPS Card Activity | source-evidenced | OPS Product | Change/activity history. |
| `ops.card_artifact_link` | OPS Card Artifact Link | source-evidenced | OPS Product | Relation/link to output artifact; not a global artifact root. |
| `ops.file_asset` | OPS File Asset | source-evidenced | OPS Product | File attached/linked through OPS. |
| `ops.stored_blob` | OPS Stored Blob | source-evidenced | OPS Product | Storage-level blob behind file assets. |
## OPS Gateway / MCP Agent Access
OPS Gateway is the scoped MCP/API access layer for agents. It does not own OPS product truth.
| Entity ID | Canonical name | Status | Authority | Notes |
| --- | --- | --- | --- | --- |
| `agent.identity` | Agent Identity | source-confirmed | OPS Gateway | Execution identity for an agent/client in Gateway, not a universal assistant persona. |
| `agent.token` | Agent Token | source-confirmed | OPS Gateway | Opaque credential; not user identity. |
| `agent.session` | Agent Session | source-confirmed | OPS Gateway | Runtime/session envelope for agent operations. |
| `agent.grant` | Agent Grant | source-confirmed | OPS Gateway | Permission grant for agent identity/session. |
| `agent.token_grant` | Token Grant | source-confirmed | OPS Gateway | Token-to-grant binding. |
| `agent.scope` | Agent Scope | source-confirmed | OPS Gateway | Tool/capability permission, not OPS role. |
| `agent.denied_capability` | Denied Capability | source-confirmed | OPS Gateway | Explicitly unavailable operation. |
| `agent.mcp_tool` | MCP Tool | source-confirmed | OPS Gateway | Exposed callable tool surface. |
| `agent.idempotency_key` | Idempotency Key | source-confirmed | OPS Gateway | Required write-safety key. |
| `agent.audit_event` | Agent Audit Event | source-confirmed | OPS Gateway | Audit trail for agent actions. |
| `agent.tasker_adapter` | Tasker Adapter | source-confirmed | OPS Gateway | Adapter to Tasker/OPS product API. |
| `agent.setup_packet` | Agent Setup Packet | source-evidenced | OPS Gateway | Setup payload for creating client app/tool access. |
| `agent.ai_workspace_entitlement` | AI Workspace Entitlement | source-evidenced / pending boundary | HUB + OPS Gateway | Entitlement/access context for AI workspace; exact entity vs policy boundary needs hardening. |
| `agent.pairing_code` | Pairing Code | source-evidenced / pending route hardening | OPS Gateway | Pairing/setup flow candidate. |
## ENGINE / Workflow System
ENGINE remains workflow/dev environment. It may reference OPS and assistants, but it is not the place for heavy product UI.
| Entity ID | Canonical name | Status | Authority | Notes |
| --- | --- | --- | --- | --- |
| `engine.workflow_l1` | ENGINE L1 Workflow | source-confirmed | ENGINE | NodeDC canvas/workflow stored as workflow metadata and graph. |
| `engine.node_l1` | ENGINE L1 Node | source-confirmed | ENGINE | React Flow node in L1 graph. |
| `engine.edge_l1` | ENGINE L1 Edge | source-confirmed | ENGINE | React Flow edge in L1 graph. |
| `engine.node_type_l1` | ENGINE L1 Node Type | source-confirmed | ENGINE | Auto-registered node type/module. |
| `engine.workflow_acl` | ENGINE Workflow ACL | source-confirmed | ENGINE | Workflow-level ACL with viewer/editor/owner/admin roles. |
| `engine.workflow_owner` | ENGINE Workflow Owner | source-confirmed | ENGINE | Owner field in ACL. |
| `engine.workflow_share` | ENGINE Workflow Share | source-confirmed | ENGINE | Share/invite/user access routes. |
| `engine.workflow_access_request` | ENGINE Workflow Access Request | source-confirmed | ENGINE | Access request route/model for workflow access. |
| `engine.workflow_l2` | ENGINE L2 Workflow | source-evidenced | ENGINE / n8n bridge | n8n subworkflow attached to L1 node by `workflowId` + `nodeId`. |
| `engine.node_l2` | ENGINE L2 Node | source-evidenced | ENGINE / n8n bridge | Node inside compiled/deployed n8n workflow. |
| `engine.l2_runtime_core` | ENGINE L2 Runtime Core | source-evidenced | ENGINE / n8n bridge | Target n8n runtime/core instance. |
| `engine.workflow_l2_runtime_id` | ENGINE L2 Runtime Workflow ID | source-confirmed | ENGINE / n8n bridge | n8n runtime workflow id, separate from NodeDC workflow id. |
| `engine.l2_execution` | ENGINE L2 Execution | source-confirmed | ENGINE / n8n bridge | n8n execution id mapped to run/execution records. |
| `engine.l2_run` | ENGINE L2 Run | source-confirmed | ENGINE-side OpsLayer | Runtime run id used to bind execution/session. Current implementation is tech-debt-adjacent. |
| `engine.l2_session` | ENGINE L2 Session | source-confirmed | ENGINE-side OpsLayer | Runtime session anchor, often derived from run id if upstream omits session id. |
| `engine.l2_runtime_event` | ENGINE L2 Runtime Event | source-confirmed | ENGINE / n8n bridge | `workflow:start`, `workflow:finish`, `node:start`, `node:success`, `node:error`. |
| `engine.source_stamp` | ENGINE Source Stamp | pending | ENGINE | Candidate for future source/version traceability. |
## Assistant / AI Workspace
These entities are required by near-term routing, but need a dedicated AI Workspace evidence pass before they become fully source-confirmed.
| Entity ID | Canonical name | Status | Authority | Notes |
| --- | --- | --- | --- | --- |
| `assistant.assistant` | Assistant | product-required / pending source pass | AI Workspace | User-facing assistant capability/persona. |
| `assistant.provider` | Assistant Provider | product-required / pending source pass | AI Workspace | Model/provider integration boundary. |
| `assistant.executor` | Assistant Executor | product-required / pending source pass | AI Workspace / Codex / MCP | Execution adapter: Codex, MCP tools, local agent, browser app. |
| `assistant.thread` | Assistant Thread | product-required / pending source pass | AI Workspace | Conversation context. |
| `assistant.message` | Assistant Message | product-required / pending source pass | AI Workspace | Message unit in thread. |
| `assistant.bridge` | Assistant Bridge | product-required / pending source pass | AI Workspace / OPS Gateway | Bridge between assistant, ontology, OPS Gateway, Codex, and Engine. |
## Future Interface Layer
The future interface layer owns automation work views and UI compositions. It is motivated by the current ENGINE-side tender/Agent Monitor tech debt.
| Entity ID | Canonical name | Status | Authority | Notes |
| --- | --- | --- | --- | --- |
| `future.interface_layer` | Future Interface Layer | owner-confirmed future-concept | Platform | Dedicated layer for user-facing automation UIs, dashboards, maps, forms, and control panels. |
| `future.interface_view` | Interface View | future-concept | Interface Layer | A generated/configured view over ontology-backed domain data. |
| `future.interface_widget` | Interface Widget | future-concept | Interface Layer | Map, table, analytics block, form, graph, control, etc. |
| `future.interface_binding` | Interface Binding | future-concept | Interface Layer + ENGINE | Binding from workflow/node output to interface data/control layer. |
| `future.domain_package` | Domain Ontology Package | future-concept | Ontology Core | Procurement/tenders, ecology, transport analytics, robotics/Helius, ERP/business process, client-specific domains. |
## ENGINE Tech Debt / Non-Canonical Boundary
These names identify current working areas that must not leak into canonical core ontology.
| Entity ID | Canonical name | Status | Authority | Notes |
| --- | --- | --- | --- | --- |
| `engine.tech_debt.ops_layer` | ENGINE OpsLayer Tech Debt | tech-debt-noncanonical | ENGINE | Current ENGINE-side operational/runtime layer; do not confuse with OPS Product. |
| `engine.tech_debt.ops_agent_instance` | ENGINE Ops Agent Instance | tech-debt-noncanonical | ENGINE | Local runtime agent instance; not OPS Gateway `agent.identity`. |
| `engine.tech_debt.agent_monitor_node` | Agent Monitor Node | tech-debt-noncanonical | ENGINE | Current monitor/control UI experiment. |
| `engine.tech_debt.monitor_profile` | Monitor Profile | tech-debt-noncanonical | ENGINE | Current profile/config around Agent Monitor. |
| `engine.tech_debt.tender_domain_ui` | Tender Domain UI | tech-debt-noncanonical | ENGINE | Hardcoded tender/procurement UI in ENGINE. |
| `engine.tech_debt.tender_storage` | Tender Storage | tech-debt-noncanonical | ENGINE | Current domain storage in ENGINE-side implementation. |
| `engine.tech_debt.tender_ai_review` | Tender AI Review | tech-debt-noncanonical | ENGINE | Current AI review implementation for tender domain. |
| `engine.tech_debt.ops_tabs_control` | ENGINE OPS Tabs Control | tech-debt-noncanonical | ENGINE | UI/control surface tied to current OpsLayer experiment. |

View File

@ -0,0 +1,806 @@
# Evidence Hardening Plan v0.3.1
Status: draft plan
Baseline: Compact Canonical Entity Catalog v0.3.1
Scope: documentation / ontology evidence only
## 1. Цель
Evidence hardening нужен, чтобы текущий canonical catalog v0.3.1 перестал быть только продуктовой договорённостью и получил проверяемую source-backed основу.
Для каждой ключевой сущности каталога нужно собрать прямое evidence:
```text
file path + line/section + короткое объяснение, почему этот фрагмент подтверждает entity или relation
```
Прямое evidence не обязано доказывать всю будущую продуктовую семантику. Оно должно честно фиксировать один из статусов:
- `source-confirmed` — сущность прямо представлена в source;
- `source-evidenced/context-derived` — сущность выводится из source-backed context/flow;
- `product-required` — сущность нужна продуктово, но source пока неполный;
- `future concept` — концепт оставлен на будущее;
- `tech debt / non-canonical` — существующий source есть, но он не является canonical truth.
## 2. Scope
### HUB
Readonly source:
```text
data/nodedc_launcher
```
Цель: укрепить evidence для `hub.open_contour`, `hub.public_pool_context`, `hub.public_pool_client`, `hub.user`, `hub.membership`, `hub.application`, `hub.application_access`, invite/access-request flows и NDCAuth aliases.
### OPS
Readonly source:
```text
data/dc_taskmanager/NODEDC_TASKMANAGER
```
Цель: укрепить evidence для `ops.card`, card submodel, workspace/project/member/status/assignment/labels/comments/files/activity/external refs.
### OPS Gateway
Readonly source:
```text
data/NODEDC_TASKMANAGER_CODEXAPI
```
Цель: укрепить evidence для `agent.identity`, grants, scopes, tokens, MCP tools, idempotency, audit, Tasker adapter, AI Workspace entitlement.
### ENGINE
Readonly source:
```text
NODEDC/NODEDC_ENGINE_INFRA
```
Цель: укрепить evidence для `engine.application_boundary`, Engine L1/L2 workflow entities, ACL/share, L2 runtime core/IDs/events/source stamps, Assistant/Provider/Executor split, and tech-debt boundary.
### Запреты
Не читать и не использовать как evidence:
```text
runtime/data/storage/env/secrets/logs/dumps/node_modules/build outputs
.env / .env.*
credentials / keys / pem / ssh
DB dumps / archives / backup contents
postgres/n8n runtime data
```
Не менять:
```text
source code
БД
миграции
docker files
package files
runtime config
```
Не запускать:
```text
bash
build
test
install
docker
migrations
servers
```
## 3. Entity groups needing direct evidence
### 3.1. HUB P0 — two-contour model
Entity ids:
```text
hub.open_contour
hub.public_pool_context
hub.public_pool_client
hub.client_context
hub.company_contour
```
Expected source files / search targets:
```text
data/nodedc_launcher/src/entities/public-pool/constants.ts
search: PUBLIC_POOL_CLIENT_ID
search: PUBLIC_POOL_CONTEXT_LABEL
search: PUBLIC_POOL_CONTEXT_DESCRIPTION
search: client_public_pool
data/nodedc_launcher/src/entities/client/types.ts
search: ClientType
search: ClientStatus
search: ClientTaskManagerWorkspaceBinding
data/nodedc_launcher/doc/base/nodedc_launcher_tz_frontend_mvp.md
search: Открытый контур
search: Клиент
search: Компания
```
Current confidence:
```text
source-evidenced/context-derived, owner-accepted baseline
```
Что надо проверить:
- что `client_public_pool` действительно source-backed special Client;
- что open contour не моделируется как ordinary `hub.client_context`;
- что closed managed context подтверждается через `Client`, memberships, groups, grants and TaskManager links.
Риск смешения терминов:
```text
Нельзя писать “hub.client inside hub.public_pool_context” как будто open contour является обычным managed client context.
Правильно: hub.public_pool_client is a source-backed special Client used by hub.public_pool_context.
```
### 3.2. HUB P0 — user, membership, access, application access
Entity ids:
```text
hub.user
hub.public_user
hub.client_user
hub.membership
hub.public_membership
hub.group
hub.role
hub.permission
hub.application
hub.application_access
hub.app_grant
hub.app_exception
hub.application_card
```
Expected source files / search targets:
```text
data/nodedc_launcher/src/entities/user/types.ts
search: LauncherUser
search: ClientMembership
search: ClientGroup
search: LauncherGlobalRole
search: ClientMembershipRole
data/nodedc_launcher/src/entities/access/types.ts
search: ServiceGrant
search: ServiceAccessException
search: EffectiveAccessResult
search: ServiceModuleEntitlement
data/nodedc_launcher/src/entities/access/computeEffectiveAccess.ts
search: computeEffectiveAccess
search: deny
search: allow
data/nodedc_launcher/src/entities/service/types.ts
search: Service
search: LauncherServiceView
data/nodedc_launcher/src/shared/lib/permissions.ts
search: resolvePermissions
search: LauncherPermissions
data/nodedc_launcher/src/widgets/service-rail/ServiceRail.tsx
search: ServiceRail
data/nodedc_launcher/src/widgets/service-stage/ServiceStage.tsx
search: ServiceStage
```
Current confidence:
```text
source-confirmed for base user/membership/group/application/access;
context-derived for public/client user classifications;
UI-confirmed for hub.application_card.
```
Что надо проверить:
- `hub.user` is source-backed by `LauncherUser`;
- `hub.public_user` and `hub.client_user` are classifications of `hub.user`, not hard entities;
- access authority belongs to `hub.application_access`, `hub.app_grant`, `hub.app_exception`;
- `hub.application_card` is presentation/UI only.
Риск смешения терминов:
```text
hub.application_card не является permission authority.
HUB app access не равен Engine workflow ACL.
```
### 3.3. HUB P1 — invite, access request, sync, NDCAuth aliases
Entity ids:
```text
hub.invite
hub.public_invite
hub.client_invite
hub.access_request
hub.public_access_request
hub.client_access_request
hub.sync_status
ndcauth.identity
ndcauth.session
```
Expected source files / search targets:
```text
data/nodedc_launcher/src/entities/invite/types.ts
search: InviteStatus
search: source
search: sourceTaskerInviteRequestId
data/nodedc_launcher/src/entities/access-request/types.ts
search: AccessRequest
search: targetClientId
search: approvedInviteId
data/nodedc_launcher/src/entities/sync/types.ts
search: SyncStatus
search: SyncTarget
data/nodedc_launcher/src/shared/api/authApi.ts
search: AuthUser
search: AuthSession
search: groups
search: /api/me
data/nodedc_launcher/server/dev-server.mjs
search: /api/access-requests
search: /api/invites
search: /api/internal/handoff/consume
search: /api/internal/access/check
```
Current confidence:
```text
source-confirmed for generic invite/access_request/sync/auth;
context-derived for public/client classifications.
```
Что надо проверить:
- access request can be classified by contour/context and target client;
- invite can be classified by contour/context and source/target;
- Authentik/OIDC/JWT names remain NDCAuth implementation aliases.
Риск смешения терминов:
```text
NDCAuth product names must not be replaced by Authentik/OIDC/JWT implementation aliases.
```
### 3.4. OPS P0 — card core
Entity ids:
```text
ops.workspace
ops.project
ops.card
ops.card_status
ops.card_priority
ops.card_assignment
ops.assignee
ops.responsible
ops.card_type
ops.card_relation
```
Expected source files / search targets:
```text
data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/apps/api/plane/db/models/workspace.py
search: class Workspace
search: class WorkspaceMember
data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/apps/api/plane/db/models/project.py
search: class Project
search: class ProjectMember
search: project_lead
search: default_assignee
data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/apps/api/plane/db/models/issue.py
search: class Issue
search: PRIORITY_CHOICES
search: state = models.ForeignKey
search: assignees = models.ManyToManyField
search: IssueAssignee
search: parent
search: type = models.ForeignKey
data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/apps/api/plane/db/models/state.py
search: class State
search: StateGroup
```
Current confidence:
```text
source-confirmed for workspace/project/card/status/priority/assignment/assignee;
product-required for responsible;
inferred/product-defined for card_type and card_relation until direct evidence is attached.
```
Что надо проверить:
- `Plane Issue` is source alias for canonical `ops.card`;
- `ops.card_priority` is card attribute, not global root;
- `ops.responsible` remains separate from `ops.assignee` and may be product-required;
- `IssueType` must not be overclaimed as source-confirmed unless direct evidence is strong.
Риск смешения терминов:
```text
task / issue / work item are aliases or card types, not canonical root.
Canonical root remains ops.card.
```
### 3.5. OPS P1 — card artifacts, activity, external refs
Entity ids:
```text
ops.card_label
ops.card_comment
ops.card_artifact_link
ops.file_asset
ops.stored_blob
ops.card_activity
ops.external_ref
```
Expected source files / search targets:
```text
data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/apps/api/plane/db/models/label.py
search: class Label
data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/apps/api/plane/db/models/issue.py
search: IssueLabel
search: IssueComment
search: IssueAttachment
search: IssueActivity
search: external_source
search: external_id
data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/apps/api/plane/db/models/asset.py
search: class FileAsset
search: class StoredBlob
search: sha256
search: canonical_object_key
search: storage_metadata
```
Current confidence:
```text
source-confirmed for labels/comments/attachments/file assets/stored blobs;
activity source-evidenced but exact event taxonomy needs hardening.
```
Что надо проверить:
- `ops.card_artifact_link` is relation/link, not global artifact root;
- `ops.file_asset` is OPS-local Plane `FileAsset`;
- `ops.stored_blob` is OPS-local `StoredBlob`;
- `ops.artifact` must not become a competing global artifact root.
Риск смешения терминов:
```text
Do not collide OPS-local file evidence with future global artifact layer.
```
### 3.6. OPS Gateway P1 — agent, grants, idempotency, audit
Entity ids:
```text
agent.identity
agent.token
agent.grant
agent.scope
agent.mcp_tool
agent.idempotency_key
agent.audit_event
agent.ai_workspace_entitlement
agent.tasker_adapter
```
Expected source files / search targets:
```text
data/NODEDC_TASKMANAGER_CODEXAPI/docs/ARCHITECTURE.md
search: Agent Gateway owns
search: agent
search: agent_token
search: agent_grant
search: pairing_code
search: agent_audit_event
search: idempotency_key
data/NODEDC_TASKMANAGER_CODEXAPI/docs/MCP_TOOLS_CONTRACT.md
search: tasker_
search: idempotency_key
search: denied tools
data/NODEDC_TASKMANAGER_CODEXAPI/src/repositories/agents.ts
search: agent_tokens
search: token_hash
search: agent_grants
search: idempotency_keys
data/NODEDC_TASKMANAGER_CODEXAPI/src/domain/scopes.ts
search: workspace:read
search: issue:create
data/NODEDC_TASKMANAGER_CODEXAPI/src/mcp/tool-runtime.ts
search: tasker_
search: claimIdempotencyKey
data/NODEDC_TASKMANAGER_CODEXAPI/src/routes/agents.ts
search: ai-workspace/entitlements
search: preflight
data/NODEDC_TASKMANAGER_CODEXAPI/src/tasker/client.ts
search: internal/nodedc/agent
```
Current confidence:
```text
source-confirmed for agent identity/tokens/grants/scopes/MCP/idempotency;
audit source-evidenced by docs and code search;
Tasker adapter source-confirmed by docs/API contract, exact code links pending.
```
Что надо проверить:
- Agent Identity is technical identity with owner, grants, scopes, tokens;
- Assistant/Agent split remains intact;
- MCP tools can request/perform scoped operations, not freely mutate OPS cards;
- writes require grants/scopes/idempotency/audit.
Риск смешения терминов:
```text
Codex is Assistant Provider, not Agent superclass.
Agent Identity is not Hub User.
```
### 3.7. ENGINE P0 — application boundary and tech debt boundary
Entity ids:
```text
engine.application_boundary
engine.tech_debt.ops_adapter
```
Expected source files / search targets:
```text
NODEDC/NODEDC_ENGINE_INFRA/nodedc-source/server/index.js
search: /auth/nodedc/handoff
search: NODEDC_LAUNCHER
search: /api/internal/access/check
NODEDC/NODEDC_ENGINE_INFRA/.ai-bridge/core-ontology-owner-decisions.md
search: engine.application_boundary
search: Engine-side OPS
search: Agent Monitor
search: tender-agent
NODEDC/NODEDC_ENGINE_INFRA/nodedc-source/server/routes/ops.js
search only, do not treat as canonical OPS:
search: opsAgentInstanceId
search: tender
search: activeTemplateId
NODEDC/NODEDC_ENGINE_INFRA/nodedc-source/src/driveinspector/nodes/AgentMonitor.definition.ts
search: AgentMonitor
```
Current confidence:
```text
owner-confirmed for application boundary;
tech debt / non-canonical for Engine-side OPS / Agent Monitor / tender config.
```
Что надо проверить:
- HUB to ENGINE goes through `engine.application_boundary`;
- no direct HUB relation to workflow internals;
- Engine-side OPS remains tech debt and non-canonical.
Риск смешения терминов:
```text
Do not use ENGINE server/routes/ops.js as OPS product truth.
```
### 3.8. ENGINE P1 — L1/L2 workflow/runtime IDs
Entity ids:
```text
engine.workflow_l1
engine.node_l1
engine.edge_l1
engine.node_type_l1
engine.workflow_acl
engine.workflow_owner
engine.workflow_share
engine.workflow_access_request
engine.workflow_l2
engine.node_l2
engine.l2_runtime_core
engine.workflow_l2_runtime_id
engine.l2_execution
engine.l2_run
engine.l2_session
engine.l2_runtime_event
engine.source_stamp
```
Expected source files / search targets:
```text
NODEDC/NODEDC_ENGINE_INFRA/nodedc-source/src/store.ts
search: nodes
search: edges
search: updateNodeData
search: sourceHandle
search: targetHandle
NODEDC/NODEDC_ENGINE_INFRA/nodedc-source/src/nodes/autoRegistry.ts
search: nodeTypes
search: meta.type
search: TYPE_LABEL
NODEDC/NODEDC_ENGINE_INFRA/nodedc-source/server/workflows/acl.js
search: ROLE_RANK
search: owner
search: users
search: groups
search: invites
search: canRead
search: canWrite
NODEDC/NODEDC_ENGINE_INFRA/nodedc-source/server/index.js
search: /api/workflows
search: /api/workflows/:id/share
search: workflow-access-requests
search: engine-role-access-requests
NODEDC/NODEDC_ENGINE_INFRA/nodedc-source/server/routes/n8n.js
search: runtimeWorkflowId
search: resolveNodeDcByRuntimeWorkflowId
search: resolveWorkflowIdByNodeDcBindingRest
search: executionId
NODEDC/NODEDC_ENGINE_INFRA/nodedc-source/server/ops/runtimeTap.js
search: runtimeWorkflowId
search: n8nCoreId
search: executionId
search: runId
search: sessionId
NODEDC/NODEDC_ENGINE_INFRA/nodedc-source/services/n8n/runtime-plugin/hooks.js
search: workflow:start
search: workflow:finish
search: node:start
search: node:success
search: node:error
NODEDC/NODEDC_ENGINE_INFRA/nodedc-source/server/routes/ndcAgentMcp.js
search: editedBy
search: editedAt
search: editIntent
```
Current confidence:
```text
source-evidenced by previous ENGINE pass; direct links pending.
```
Что надо проверить:
- L1 and L2 remain separate concrete workflow types;
- L2 runtime ID is not same as product workflow identity;
- runtime core/id/event concepts have direct source anchors.
Риск смешения терминов:
```text
n8n names are implementation aliases for Engine L2 runtime, not canonical product names.
```
### 3.9. ENGINE / Assistant P1 — Assistant, Provider, Executor, Bridge
Entity ids:
```text
assistant.assistant
assistant.provider
assistant.executor
assistant.thread
assistant.message
assistant.bridge
```
Expected source files / search targets:
```text
NODEDC/NODEDC_ENGINE_INFRA/nodedc-source/server/routes/aiWorkspace.js
search: EXECUTOR_TYPES
search: normalizeExecutor
search: thread
search: message
search: bridge
search: requestBridge
NODEDC/NODEDC_ENGINE_INFRA/nodedc-source/server/aiWorkspace/assistantRegistryClient.js
search: provider
search: executor
search: owner
NODEDC/NODEDC_ENGINE_INFRA/nodedc-source/src/components/ai/AIWorkspaceConsole.tsx
search: executor
search: thread
search: Ops workspace
search: Ops project
NODEDC/NODEDC_ENGINE_INFRA/nodedc-source/workers/ai-workspace-bridge/worker.mjs
search: workspaces
search: bridge
search: events
```
Current confidence:
```text
source-confirmed by previous ENGINE/AI Workspace pass; direct links should be hardened.
```
Что надо проверить:
- Assistant is product-facing interactive layer;
- Codex is Assistant Provider;
- Executor/Bridge are runtime/connection concepts;
- Agent Identity belongs to OPS Gateway and is not the Assistant superclass.
Риск смешения терминов:
```text
Do not model Codex as Agent superclass.
Do not collapse Assistant and Agent Identity.
```
## 4. Priority order
### P0
```text
HUB two-contour model
hub.user / membership / access / application access
ops.card core
ENGINE application boundary
Engine-side OPS tech debt boundary
```
### P1
```text
OPS artifacts / activity / external refs
OPS Gateway agent / grants / idempotency / audit
ENGINE L1/L2 workflow / runtime IDs
ENGINE Assistant / Provider / Executor split
```
### P2
```text
future concepts / product-required terms:
ops.responsible
ops.card_type taxonomy
ops.card_relation taxonomy
artifact.version / 3D model versioning
final global artifact/event layer
Agent Monitor future product role
```
## 5. Output artifacts
Следующие документы нужно создать после hardening passes:
```text
ontology/EVIDENCE_LEDGER.md
ontology/CANONICAL_ENTITY_CATALOG.md
ontology/RELATION_CATALOG.md
ontology/GUARDRAILS.md
ontology/OPEN_QUESTIONS.md
```
### ontology/EVIDENCE_LEDGER.md
Назначение: file+line ledger для каждой entity/relation.
Минимальная строка:
```text
Entity ID | Canonical name | Source root | Evidence file | Evidence line/section | Evidence status | Explanation | Notes
```
### ontology/CANONICAL_ENTITY_CATALOG.md
Назначение: frozen catalog после evidence hardening.
### ontology/RELATION_CATALOG.md
Назначение: canonical relations and boundaries.
### ontology/GUARDRAILS.md
Назначение: settled terminology and forbidden conflations.
### ontology/OPEN_QUESTIONS.md
Назначение: owner/product decisions not yet closed.
## 6. Next safe task
Следующая маленькая безопасная задача:
```text
Сделать HUB P0 evidence ledger section.
```
Scope следующей задачи:
```text
Только NodeDC HUB Readonly.
Только source/docs files, без runtime/data/storage/env/secrets/logs/dumps.
Собрать file+line evidence для:
- hub.open_contour
- hub.public_pool_context
- hub.public_pool_client
- hub.user
- hub.public_membership
- hub.membership
- hub.application
- hub.application_access
- hub.app_grant
- hub.app_exception
- hub.application_card
- ndcauth.identity
- ndcauth.session
```

View File

@ -0,0 +1,49 @@
# Evidence Ledger Index v0.4-pre
Status: consolidated index
Date: 2026-06-18
This file indexes the source-backed evidence ledgers. It is not a replacement for the detailed ledger files.
## Ledger Files
| Ledger | Scope | Status | Main result |
| --- | --- | --- | --- |
| `EVIDENCE_LEDGER_HUB_P0.md` | HUB / Launcher / NDCAuth | source-backed P0 | Confirms open contour vs client/company contour, public pool client/context, user classifications, app access/card split. |
| `EVIDENCE_LEDGER_OPS_P0.md` | OPS Product | source-backed P0 | Confirms workspace/project/card model and `ops.card` as canonical work object. |
| `EVIDENCE_LEDGER_OPS_GATEWAY_P1.md` | OPS Gateway / MCP access | source-backed P1 | Confirms scoped agent identity/token/grant/scope/tool/idempotency/audit boundary. |
| `EVIDENCE_LEDGER_ENGINE_DIRTY_BOUNDARY_P1.md` | ENGINE-side OpsLayer / Agent Monitor / tender tech debt | source-backed P1 | Confirms this is non-canonical tech debt and must not define OPS Product. |
| `EVIDENCE_LEDGER_ENGINE_WORKFLOW_P1.md` | ENGINE L1/L2 workflow and runtime evidence | source-backed P1 | Confirms L1 graph, workflow ACL/share, L2 n8n runtime ids/events/run/session bridge. |
## Source Roots Used
| Surface | Source root | Notes |
| --- | --- | --- |
| HUB | `/Users/dcconstructions/Downloads/mnt/data/nodedc_launcher` | Read-only source inspection. |
| OPS Product | `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER` | Read-only source inspection. |
| OPS Gateway | `/Users/dcconstructions/Downloads/mnt/data/NODEDC_TASKMANAGER_CODEXAPI` | Read-only source inspection. |
| ENGINE | `/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_ENGINE_INFRA/nodedc-source` | Read-only source inspection; runtime/data/storage excluded. |
| Ontology workspace | `/Users/dcconstructions/Downloads/mnt/NODEDC_ONTOLOGY` | Documentation-only write workspace. |
## Accepted Evidence-Based Decisions
- HUB has two operational contours:
- open/public contour for simple users;
- managed closed company/client contour.
- `hub.public_pool_client` is a source-backed special Client, but it must not be modeled as ordinary company context.
- OPS Product canonical work object is `ops.card`.
- OPS Product, OPS Gateway, and ENGINE-side OpsLayer are three different things.
- ENGINE-side Agent Monitor/tender UI is working tech debt, not target architecture.
- ENGINE L1/L2 split is real enough for first ontology implementation:
- L1 = NodeDC canvas/workflow graph;
- L2 = n8n/subworkflow runtime attached to L1 node.
- Future interface layer is a planned platform service/layer, motivated by current ENGINE-side custom UI debt.
## Evidence Gaps That Remain Non-Blocking
- Assistant / AI Workspace needs a dedicated source pass before its entities become source-confirmed.
- `ops.responsible` needs exact source/product semantics.
- `ops.card_type` needs taxonomy.
- Gateway pairing/entitlement flow needs route-level hardening.
- Domain ontology packages need separate passes; core must not absorb domain-specific tender/ecology/transport concepts.

View File

@ -0,0 +1,136 @@
# Evidence Ledger — ENGINE Dirty Boundary P1
Status: source-backed tech-debt boundary note
Baseline: Compact Canonical Entity Catalog v0.3.1
Date: 2026-06-18
Scope: ENGINE-side OpsLayer / tender-agent / Agent Monitor boundary
Source root:
```text
/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_ENGINE_INFRA/nodedc-source
```
Restrictions observed:
- Source inspection only.
- No runtime/data/storage/env/secrets/logs/dumps.
- No source code edits.
- No build/test/install/docker execution.
## Summary
This pass confirms an important ontology boundary:
```text
OPS product
!= OPS Gateway
!= ENGINE-side OpsLayer / tender-agent / Agent Monitor
```
The ENGINE repository contains a substantial embedded `OpsLayer` around n8n workflows, tender search/review, monitor profiles, agent runs, trace events, and Agent Monitor UI nodes.
This code is working product/legacy infrastructure, but it must be treated as:
```text
tech debt / non-canonical
```
It should not be promoted into canonical OPS product truth and should not define the final automation UI architecture.
## Boundary Evidence
| Boundary ID | Canonical treatment | Evidence status | Evidence file:line | Evidence explanation | Notes |
| --- | --- | --- | --- | --- | --- |
| `engine.tech_debt.ops_layer` | Engine-side legacy OpsLayer | source-confirmed tech debt | `server/index.js:25`, `server/index.js:333`, `server/routes/ops.js:1-34`, `server/routes/ops.js:6412-11855` | ENGINE imports `opsRouter` and mounts it at `/api/ops`; `routes/ops.js` implements a large set of run/result/tender/AI review/monitor endpoints. | This is not the separate OPS product / Tasker model. |
| `engine.tech_debt.ops_agent_instance` | Legacy Engine agent instance | source-confirmed tech debt | `server/ops/migrations/0001_init_opslayer.sql:89-137`, `src/store.ts:444-480`, `src/utils/opsApi.ts:759-855` | OpsLayer has `agents`, `agent_instances`, `agent_runs`; frontend prevents copied n8n nodes from inheriting `opsAgentInstanceId`; API can start/stop agent instances and record runs. | Do not merge with OPS Gateway `agent.identity`. |
| `engine.tech_debt.agent_monitor_node` | Agent Monitor canvas node | source-confirmed tech debt | `src/nodes/AgentMonitorNode.tsx:10-14`, `src/nodes/AgentMonitorNode.tsx:21-38`, `src/driveinspector/nodes/AgentMonitor.definition.ts:1-13` | ENGINE has a visible `agentMonitor` React Flow node and inspector definition that binds monitor profiles/agents through `opsApi`. | This is current UI embedding, not final interface layer. |
| `engine.tech_debt.monitor_profile` | Engine-side monitor profile | source-confirmed tech debt | `server/ops/migrations/0001_init_opslayer.sql:240-299`, `src/utils/opsApi.ts:260-360`, `src/utils/opsApi.ts:460-520` | OpsLayer stores monitor profiles, profile assignments, bound agent instances, n8n cores, and monitor nodes; frontend can list/create/update/bind profiles and save monitor layouts. | Useful evidence for future work-view requirements. |
| `engine.tech_debt.tender_domain_ui` | Tender-specific embedded UI/domain slice | source-confirmed tech debt | `server/routes/ops.js:45-63`, `server/routes/ops.js:75-145`, `src/n8n/N8nAgentMonitorPanel.tsx:1-44`, `src/n8n/N8nAgentMonitorPanel.tsx:1160-1245`, `src/n8n/tenderElectronicPlaceCountries.ts:1-12` | ENGINE includes Kontur tender URLs, tender categories/electronic places, tender search settings, and a large Agent Monitor panel. | Treat as procurement/tender domain evidence, not core ontology. |
| `engine.tech_debt.tender_storage` | Tender storage inside Engine OpsLayer | source-confirmed tech debt | `server/ops/migrations/0001_init_opslayer.sql:210-239`, `server/ops/migrations/0006_tender_dedup_registry.sql:1-17`, `server/ops/migrations/0007_tender_details.sql:1-24`, `server/ops/migrations/0008_tender_documents.sql:1-37` | OpsLayer stores tender results, dedup registry, details, and downloaded documents. | Do not confuse with future domain ontology persistence. |
| `engine.tech_debt.tender_ai_review` | Tender AI review workflow state | source-confirmed tech debt | `server/ops/migrations/0009_tender_ai_review.sql:1-90`, `src/n8n/N8nAgentMonitorPanel.tsx:1214-1238`, `src/utils/opsApi.ts:577-688` | OpsLayer stores AI review tasks/latest state; UI has default manual AI criteria/response format; API enqueues/retries review tasks. | Useful sample for domain-specific assistant review flows. |
| `engine.tech_debt.ops_tabs_control` | Engine inspector ops control | source-confirmed tech debt | `src/driveinspector/ControlRegistry.tsx:27-50` | Drive inspector registers `opsTabs` control. | Another marker that Ops UI is embedded in ENGINE editor controls. |
| `future.interface_layer` | Future platform UI/work-view layer | product-required / source-informed | `src/n8n/N8nAgentMonitorPanel.tsx:46-120`, `src/utils/opsApi.ts:460-520`, `server/ops/migrations/0001_init_opslayer.sql:240-299` | Current monitor panel/layout/profile code shows the kind of configurable work views the future interface layer must support. | Future home should be separate platform service/app, not Engine core. |
## Route / Surface Evidence
| Surface | Evidence status | Evidence file:line | Evidence explanation |
| --- | --- | --- | --- |
| `/api/ops` is mounted inside ENGINE server | source-confirmed | `server/index.js:25`, `server/index.js:333` | ENGINE server imports and mounts `opsRouter`. |
| Ops router owns runs/results/tender/AI review/monitor APIs | source-confirmed | `server/routes/ops.js:6412-11855` | Router exposes health, runs, tender results/details/docs, AI review, trace, monitor profiles/layout/admin, agent instance start/stop, actions, contour limits/search. |
| Agent Monitor is an ENGINE canvas node | source-confirmed | `src/nodes/AgentMonitorNode.tsx:10-14`, `src/nodes/AgentMonitorNode.tsx:73-170` | Node type is `agentMonitor`; UI opens monitor through n8n subworkflow event. |
| Agent Monitor panel directly consumes Ops APIs | source-confirmed | `src/n8n/N8nAgentMonitorPanel.tsx:5-31` | Panel imports many Ops API functions for tender details, AI review, layout, runs, trace, start/stop, take-in-work. |
| Engine graph copy logic special-cases Ops bindings | source-confirmed | `src/store.ts:444-480` | Copied n8n nodes clear `opsAgentInstanceId`; copied `agentMonitor` nodes clear monitor profile/binding fields. |
## Guardrails Confirmed
```text
Do not use ENGINE server/routes/ops.js as OPS product truth.
Do not use ENGINE OpsLayer agent_instances as OPS Gateway agent.identity.
Do not use Agent Monitor as the final automation UI model.
Do not put future custom product UI directly into ENGINE core.
Do not collapse tender/procurement domain concepts into core ontology roots.
```
Canonical separation:
```text
ops.*
= product OPS / Tasker / operational work system
agent.*
= OPS Gateway execution identity, token, grant, scope, tool boundary
engine.tech_debt.*
= current ENGINE-embedded OpsLayer / tender-agent / Agent Monitor legacy slice
future.interface_layer.*
= future platform UI/work-view/configurator surface, still not implemented
```
## Product / Architecture Notes
### Why this matters
The current Engine-side tender/Agent Monitor implementation proves the need for an ontology-backed interface layer:
- users need domain-specific work views;
- workflows need to emit structured data into user-facing controls;
- assistants will likely compose or modify these views;
- tender/procurement is only the first domain slice;
- future slices may include ecology, transport, robotics/Helius, ERP/business processes, and client-specific domains.
But the current implementation should remain a learning sample and migration source, not the target architecture.
### Future target
Recommended direction:
```text
ENGINE
owns workflow/node execution and development environment
future interface layer
owns configurable work views, dashboards, maps, analytics, forms, and domain UI composition
Ontology Core
resolves meanings, relations, aliases, domain schemas, and routing context
```
### Migration posture
Do not migrate or refactor this now.
Near-term treatment:
- document the boundary;
- avoid using these entities as canonical roots;
- use them as source-backed evidence for future interface/work-view requirements;
- preserve current working behavior until a real interface layer exists.
## Open Questions / Follow-up
1. Later ENGINE pass should separately document canonical workflow L1/L2 entities without mixing in OpsLayer.
2. Future interface-layer RFC should decide whether views/widgets/surfaces are core ontology entities or platform-service entities with ontology references.
3. Tender/procurement should become a domain ontology package, not a core ontology root.
4. Decide whether Engine-side OpsLayer data gets migrated, archived, or only bridged when the future interface layer exists.

View File

@ -0,0 +1,67 @@
# Evidence Ledger — ENGINE Workflow P1
Status: source-backed ledger section
Baseline: Canonical Entity Catalog v0.4-pre
Date: 2026-06-18
Scope: ENGINE L1/L2 workflow, ACL/share, n8n runtime bridge, runtime event tracking
Source root:
```text
/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_ENGINE_INFRA/nodedc-source
```
Restrictions observed:
- Source inspection only.
- No runtime/data/storage/env/secrets/logs/dumps.
- No source code edits.
- No build/test/install/docker execution.
## Summary
This pass confirms the minimum ENGINE ontology needed for the first implementation:
- `engine.workflow_l1`, `engine.node_l1`, and `engine.edge_l1` exist as NodeDC/React Flow graph concepts.
- `engine.node_type_l1` is sourced from the auto node registry.
- Workflow ACL/share/access-request concepts are implemented in source.
- `engine.workflow_l2` is source-evidenced as an n8n subworkflow attached to an L1 workflow/node pair.
- Runtime events carry `workflowId`, `nodeId`, `runtimeWorkflowId`, `executionId`, `runId`, and `sessionId`.
- Current runtime run/session tracking is tied to ENGINE-side OpsLayer tables and should remain tech-debt-adjacent.
## Entity Evidence
| Entity ID | Evidence status | Evidence file:line | Evidence explanation |
| --- | --- | --- | --- |
| `engine.workflow_l1` | source-confirmed | `src/store.ts:603-680`, `server/index.js:768-840`, `server/index.js:2716-3045` | Client state stores graph `nodes`/`edges`; server stores workflow metadata/graph and exposes workflow list/read/create/update routes. |
| `engine.node_l1` | source-confirmed | `src/store.ts:603-680`, `src/store.ts:1510-1585`, `src/store.ts:1770-1845` | Store manages React Flow nodes, creates nodes from templates/types, and updates node data. |
| `engine.edge_l1` | source-confirmed | `src/store.ts:764-780`, `src/store.ts:1510-1549` | Edges have source/target handles and are normalized in store. |
| `engine.node_type_l1` | source-confirmed | `src/nodes/autoRegistry.ts:4-56` | Node modules expose `meta`, `runtime`, `ports`, `monitor`, and derive type/label from module metadata. |
| `engine.workflow_acl` | source-confirmed | `server/workflows/acl.js:5-13`, `server/workflows/acl.js:109-165`, `server/workflows/acl.js:259-303` | ACL stores owner/users/groups/invites and supports viewer/editor/owner/admin checks. |
| `engine.workflow_owner` | source-confirmed | `server/workflows/acl.js:109-165` | ACL owner field is normalized and role checked. |
| `engine.workflow_share` | source-confirmed | `server/index.js:3185-3245`, `server/index.js:3250-3312`, `server/index.js:3371-3635` | Workflow share routes expose owner/users/groups/invites and email/internal invite operations. |
| `engine.workflow_access_request` | source-confirmed | `server/index.js:2792`, `server/index.js:2860`, `server/index.js:2916`, `server/index.js:3314` | Workflow access request, approve, reject, and request routes are present. |
| `engine.workflow_l2` | source-evidenced | `server/routes/n8n.js:11661-11737` | `/api/n8n/subworkflow/deploy` loads subworkflow graph for `workflowId` + `nodeId`, compiles it, injects runtime events, and writes compiled n8n workflow. |
| `engine.node_l2` | source-evidenced | `server/routes/n8n.js:2288-2365`, `services/n8n/runtime-plugin/hooks.js:132-145` | Runtime event node and hooks capture n8n node name/id. |
| `engine.l2_runtime_core` | source-evidenced | `server/routes/n8n.js:11685-11696`, `server/routes/n8n.js:11739-11748` | Deploy resolves n8n target/base URL/instance and docker compose service/container. |
| `engine.workflow_l2_runtime_id` | source-confirmed | `services/n8n/runtime-plugin/hooks.js:52-61`, `server/routes/n8n.js:2323-2324`, `server/routes/n8n.js:12041-12055` | Runtime plugin extracts n8n workflow id; injected event uses `$workflow.id`; deploy sync stores resolved workflow id. |
| `engine.l2_execution` | source-confirmed | `services/n8n/runtime-plugin/hooks.js:77-86`, `server/routes/n8n.js:2323-2325`, `server/ops/runtimeTap.js:773-818`, `server/ops/runtimeTap.js:820-855` | Runtime events carry execution id; runtime tap writes trace event and run execution binding. |
| `engine.l2_run` | source-confirmed / tech-debt-adjacent | `services/n8n/runtime-plugin/hooks.js:88-106`, `server/ops/runtimeTap.js:247-270`, `server/ops/runtimeTap.js:467-542` | Hooks extract run id; runtime tap resolves/canonicalizes run id through ENGINE-side OpsLayer tables. |
| `engine.l2_session` | source-confirmed / tech-debt-adjacent | `services/n8n/runtime-plugin/hooks.js:108-130`, `server/ops/runtimeTap.js:272-289`, `server/ops/runtimeTap.js:354-430` | Hooks extract session id or fall back to run id; runtime tap resolves session anchors and can create session-workflow split runs. |
| `engine.l2_runtime_event` | source-confirmed | `services/n8n/runtime-plugin/hooks.js:220-231`, `services/n8n/runtime-plugin/hooks.js:233-279`, `services/n8n/runtime-plugin/hooks.js:285-387`, `server/routes/n8n.js:2298-2332`, `services/n8n/runtime-plugin/runtime-bridge.js:200-223` | Runtime events are emitted/enqueued for workflow/node start/finish/success/error and sent to NodeDC runtime endpoint with retry/failure handling. |
## Guardrails Confirmed
```text
engine.workflow_l1 id != engine.workflow_l2_runtime_id.
engine.workflow_l2 is attached through NodeDC workflowId + nodeId.
engine.l2_run and engine.l2_session are useful routing evidence but currently live in ENGINE-side OpsLayer tech debt.
Engine-side OpsLayer runtime tables do not define OPS Product ontology.
```
## Non-Blocking Follow-Up
- Harden `engine.source_stamp` after implementation needs are clearer.
- Separate stable L2 domain concepts from current n8n-specific deployment helpers.
- Decide whether runtime run/session belongs in future Ontology Core, Interface Layer, OPS Gateway, or a dedicated runtime observability package.

View File

@ -0,0 +1,92 @@
# Evidence Ledger — HUB P0
Status: first source-backed ledger section
Baseline: Compact Canonical Entity Catalog v0.3.1
Date: 2026-06-18
Scope: HUB / Launcher / NDCAuth P0 evidence
Source root:
```text
/Users/dcconstructions/Downloads/mnt/data/nodedc_launcher
```
Restrictions observed:
- Source inspection only.
- No runtime/data/storage/env/secrets/logs/dumps.
- No source code edits.
- No build/test/install/docker/bash execution for the source repos.
## Summary
This pass confirms the core HUB P0 ontology:
- `hub.open_contour` is the product-level open contour.
- `hub.public_pool_context` is the source-backed public pool context.
- `hub.public_pool_client` is a source-backed special `Client` with id `client_public_pool`.
- `hub.open_contour` must not be treated as an ordinary `hub.client_context`.
- `hub.user` is source-backed by `LauncherUser`.
- `hub.public_user` / `hub.client_user` are context classifications of `hub.user`, not separate hard user entities.
- `hub.application_access`, `hub.app_grant`, and `hub.app_exception` are permission/policy concepts.
- `hub.application_card` is UI/presentation over `LauncherServiceView`, not permission authority.
- `ndcauth.identity` and `ndcauth.session` are backed by Auth/OIDC session types; Authentik/OIDC/JWT remain implementation aliases.
## Entity Evidence
| Entity ID | Canonical name | Evidence status | Evidence file:line | Evidence explanation | Notes |
| --- | --- | --- | --- | --- | --- |
| `hub.open_contour` | Hub Open Contour | source-evidenced / owner-confirmed | `src/entities/public-pool/constants.ts:3-5` | Defines `PUBLIC_POOL_CLIENT_ID`, `PUBLIC_POOL_CONTEXT_LABEL = "Открытый контур"`, and `PUBLIC_POOL_CONTEXT_DESCRIPTION = "Public access pool"`. | Product-level canonical open contour. |
| `hub.public_pool_context` | Hub Public Pool Context | source-confirmed | `src/entities/public-pool/constants.ts:3-5` | Public pool constants define the concrete source-backed public context. | Source-backed context alias for open contour. |
| `hub.public_pool_client` | Hub Public Pool Client | source-confirmed special Client | `src/entities/public-pool/constants.ts:7-20` | `PUBLIC_POOL_CLIENT` is typed as `Client`, uses id `client_public_pool`, type `person`, label `Открытый контур`, and note says it is the system contour for public requests, public invites, and self-service users. | This confirms special source-backed Client, not ordinary client context. |
| `hub.client_context` | Hub Client Context | source-confirmed / context-derived | `src/entities/client/types.ts:1-36` | `Client` supports `company` and `person`, status fields, contract/demo fields, contact fields, and TaskManager workspace bindings. | Safer canonical name for closed managed context. |
| `hub.company_contour` | Hub Company Contour | alias / subtype | `src/widgets/admin-overlay/AdminOverlay.tsx:431-443` | Admin UI separates `Открытый контур` from a `Компании` selector backed by `data.clients`. | Product wording/subtype over managed `hub.client_context`. |
| `hub.user` | Hub User / Launcher User | source-confirmed | `src/entities/user/types.ts:10-22` | `LauncherUser` defines the source user entity with `id`, `authentikUserId`, email, name, global status, timestamps. | Core user entity. |
| `hub.public_user` | Hub Public User Classification | source-evidenced / context-derived | `src/widgets/admin-overlay/AdminOverlay.tsx:4760-4799` | `getPlatformUserOrigin` detects a public-pool membership and labels the same user as open-contour/self-service/access-request/invite origin. | Classification of `hub.user`, not hard user class. |
| `hub.client_user` | Hub Client User Classification | source-evidenced / context-derived | `src/widgets/admin-overlay/AdminOverlay.tsx:4801-4809` | If memberships exist outside public handling, UI labels the user as `Компания · ${client.name}` with client legal name. | Classification of `hub.user`, not hard user class. |
| `hub.membership` | Hub Client Membership | source-confirmed | `src/entities/user/types.ts:24-40` | `ClientMembership` links `clientId`, `userId`, role, status, invite/source metadata, and timestamps. | Main user-to-client/context link. |
| `hub.public_membership` | Hub Public Pool Membership | source-confirmed / context-derived | `server/control-plane-store.mjs:3368-3400` | `upsertAccessRequestMembership` defaults `clientId` to `publicPoolClientId`, creates membership with `source: "access_request"` and default disabled status. | Public status comes from membership context. |
| `hub.public_access_request` | Hub Public Access Request | source-evidenced / context-derived | `server/control-plane-store.mjs:713-793` | Public access request creation upserts user, creates disabled public-pool membership, and creates access request with `targetClientId: publicPoolClientId`. | Classification of `hub.access_request`. |
| `hub.client_access_request` | Hub Client Access Request | source-evidenced / context-derived | `server/control-plane-store.mjs:822-890` | Approval resolves target client, activates membership in the selected client, and removes pending public-pool membership when target client is not public pool. | Shows promotion from public request into managed client context. |
| `hub.access_request` | Hub Access Request | source-confirmed | `src/entities/access-request/types.ts:5-22` | `AccessRequest` has email/name/company, status, `targetClientId`, role, approval/review fields. | Base entity for public/client classifications. |
| `hub.invite` | Hub Invite | source-confirmed | `src/entities/invite/types.ts:5-21` | `Invite` has `clientId`, email, role, source, Tasker source refs, token, expiry, status. | Base entity for public/client invite classifications. |
| `hub.group` | Hub Client Group | source-confirmed | `src/entities/user/types.ts:42-50` | `ClientGroup` links group to `clientId` and `memberIds`. | Client-local group. |
| `hub.role` | Hub Role | source-confirmed | `src/entities/user/types.ts:1-6`, `src/entities/user/types.ts:24-25` | Defines global roles and membership roles. | Role vocabulary for HUB. |
| `hub.permission` | Hub Permission | source-confirmed | `src/shared/lib/permissions.ts:3-11`, `src/shared/lib/permissions.ts:21-39` | Defines `LauncherPermissions` and derives permissions from launcher role and membership status. | Permission resolution helper. |
| `hub.application` | Hub Application | source-confirmed | `src/entities/service/types.ts:7-36` | `Service` defines app/service catalog entity with slug/title/URL/status/media/Auth aliases. | Canonical product application/service concept. |
| `hub.application_access` | Hub Application Access | source-confirmed | `src/entities/access/types.ts:28-38` | `EffectiveAccessResult` stores service/user access outcome, visibility, open-enabled, app role, reason, source. | Policy/debug entity for effective app access. |
| `hub.app_grant` | Hub App Access Grant | source-confirmed | `src/entities/access/types.ts:1-14` | `ServiceGrant` targets `client`, `group`, or `user` and grants an app role for a service. | Authority input for app access. |
| `hub.app_exception` | Hub App Access Exception | source-confirmed | `src/entities/access/types.ts:16-26` | `ServiceAccessException` stores user-specific `deny` or `allow` exceptions. | Override layer for app access. |
| `hub.application_card` | Hub Application Card | source-confirmed UI/view only | `src/entities/service/types.ts:45-72`, `src/widgets/service-rail/ServiceRail.tsx:6-52`, `src/widgets/service-stage/ServiceStage.tsx:22-147` | `LauncherServiceView` includes display fields and `effectiveAccess`; `ServiceRail` renders service tiles; `ServiceStage` renders a launch/detail surface using `effectiveAccess`. | Presentation over `hub.application` + access state, not permission authority. |
| `ndcauth.identity` | NDCAuth Identity | source-confirmed with implementation aliases | `src/shared/api/authApi.ts:1-8`, `src/entities/user/types.ts:10-13` | `AuthUser` has `sub`, email/name/groups; `LauncherUser` stores `authentikUserId`. | Authentik/OIDC subject is implementation alias. |
| `ndcauth.session` | NDCAuth Session | source-confirmed | `src/shared/api/authApi.ts:10-23`, `server/dev-server.mjs:270-294` | `AuthSession` represents authenticated/unauthenticated session; `/api/me` returns authenticated runtime session user and groups or login URL. | Session model for launcher runtime. |
## Relation / Flow Evidence
| Relation / guardrail | Evidence status | Evidence file:line | Evidence explanation |
| --- | --- | --- | --- |
| `hub.open_contour` is not ordinary `hub.client_context` | source-evidenced / owner-confirmed | `src/widgets/admin-overlay/AdminOverlay.tsx:411-443` | Admin UI shows a dedicated public-pool context switcher and a separate `Компании` selector. |
| Public pool supports public requests/invites/self-service users | source-confirmed | `src/entities/public-pool/constants.ts:7-20` | The special Client note explicitly says it is the system contour for public requests, public invites, and self-service users. |
| Public access request creates disabled public-pool membership | source-confirmed | `server/control-plane-store.mjs:740-742`, `server/control-plane-store.mjs:3368-3400` | Request creation upserts user and membership; membership helper defaults to `publicPoolClientId` and disabled status. |
| Public request can promote into client/company context | source-confirmed | `server/control-plane-store.mjs:846-865` | Approval creates active membership for target client; if target is not public pool, pending public-pool membership is removed. |
| App access is derived from grants/exceptions, not UI cards | source-confirmed | `src/entities/access/computeEffectiveAccess.ts:31-132` | Effective access checks deny/allow exceptions, user grants, group grants, client grants, then blocks by default. |
| `hub.application_card` is presentation over access state | source-confirmed | `src/entities/service/types.ts:45-72`, `src/widgets/service-stage/ServiceStage.tsx:101-126` | UI view includes `effectiveAccess`; launch button checks `service.effectiveAccess.openEnabled`, but authority is from access computation. |
| NDCAuth uses OIDC/JWT/Auth implementation evidence | source-confirmed | `server/dev-server.mjs:2475-2505`, `server/dev-server.mjs:270-294` | Server verifies JWT/OIDC claims, normalizes `sub/email/groups`, and serves `/api/me`. |
## Guardrails Confirmed
```text
hub.public_pool_client is a source-backed special Client used by hub.public_pool_context.
hub.open_contour must not be treated as an ordinary hub.client_context.
hub.user is the core user entity.
hub.public_user and hub.client_user are context classifications of hub.user.
hub.application_card is UI/presentation only, not permission authority.
hub.application_access / hub.app_grant / hub.app_exception are the app-access policy concepts.
NDCAuth is canonical; Authentik/OIDC/JWT remain implementation aliases.
```
## Open Questions / Follow-up
1. `hub.client_access_request` is source-evidenced by target-client approval flow, but final product wording should decide whether it remains a formal classification or only a relation state of `hub.access_request`.
2. `hub.public_invite` / `hub.client_invite` have base source evidence through `Invite.clientId`; a later HUB P1 pass should harden exact classification rules by public-pool vs client target.
3. Add these rows into the future combined `ontology/EVIDENCE_LEDGER.md` after OPS/ENGINE evidence sections are ready.

View File

@ -0,0 +1,138 @@
# Evidence Ledger — OPS Gateway P1
Status: first source-backed gateway ledger section
Baseline: Compact Canonical Entity Catalog v0.3.1
Date: 2026-06-18
Scope: OPS Gateway / Codex API / MCP agent boundary
Source root:
```text
/Users/dcconstructions/Downloads/mnt/data/NODEDC_TASKMANAGER_CODEXAPI
```
Restrictions observed:
- Source inspection only.
- No runtime/data/storage/env/secrets/logs/dumps.
- No source code edits.
- No build/test/install/docker execution.
## Summary
This pass confirms that OPS Gateway is not just a loose Codex config helper.
It is a separate boundary service that owns:
- agent identity;
- opaque agent tokens;
- project/workspace grants;
- tool scopes;
- MCP tool exposure;
- idempotency for write tools;
- audit events;
- a narrow Tasker adapter;
- AI Workspace entitlement projection into token-scoped MCP grants.
Ontology implication:
```text
agent.* entities belong to OPS Gateway.
They are not the same thing as Assistant, Codex, ChatGPT, or a human user.
```
## Entity Evidence
| Entity ID | Canonical name | Evidence status | Evidence file:line | Evidence explanation | Notes |
| --- | --- | --- | --- | --- | --- |
| `agent.identity` | OPS Gateway Agent | source-confirmed | `docs/ARCHITECTURE.md:72-89`, `migrations/001_initial.sql:8-17`, `src/repositories/agents.ts:15-24` | Agent Gateway owns agent profiles; `agents` table stores owner identity, display name, avatar, status, timestamps; `AgentRecord` mirrors those fields. | This is a gateway execution identity, not the assistant superclass. |
| `agent.token` | OPS Gateway Agent Token | source-confirmed | `docs/MCP_TOOLS_CONTRACT.md:19-50`, `migrations/001_initial.sql:38-47`, `src/repositories/agents.ts:488-545`, `src/security/tokens.ts:1-10` | Tokens are opaque bearer credentials; storage uses token hash/suffix/status/expiry; `createToken` stores hash and token grant scope; token strings use `ndcag_` prefix. | Token is not a user password and not a durable ontology identity. |
| `agent.session` | OPS Gateway Agent Session | source-confirmed | `src/repositories/agents.ts:50-55`, `src/repositories/agents.ts:579-630`, `src/routes/agents.ts:161-177` | Session joins active agent, active token, effective grants, and grant source; agent-session routes expose current session and setup packet. | Runtime auth context for MCP/tool calls. |
| `agent.grant` | OPS Gateway Grant | source-confirmed | `docs/ARCHITECTURE.md:134-143`, `migrations/001_initial.sql:22-33`, `src/repositories/agents.ts:26-36` | Grants bind an agent to workspace/project with scopes and mode. | Workspace-level grant is represented with empty/null project internally. |
| `agent.token_grant` | Token-scoped Run Grant | source-confirmed | `migrations/004_run_grants.sql:1-21`, `src/repositories/agents.ts:488-545`, `src/repositories/agents.ts:633-663`, `src/routes/agents.ts:964-972` | Tokens can have their own grant scope; token grants can reference source agent grants and intersect effective scopes. | Important for AI Workspace run-scoped access. |
| `agent.scope` | Agent Tool Scope | source-confirmed | `src/domain/scopes.ts:1-13`, `src/security/authorization.ts:11-39`, `src/mcp/tool-runtime.ts:540-557` | Allowed scopes list workspace/project/card operations; runtime checks both global scope and project grant scope before writes/reads. | Scopes are tool permissions, not product roles. |
| `agent.denied_capability` | Denied Gateway Capability | source-confirmed | `src/domain/scopes.ts:17-30`, `docs/ARCHITECTURE.md:91-107`, `docs/MCP_TOOLS_CONTRACT.md:321-333` | Delete/archive/settings/invite/raw Tasker API capabilities are explicitly denied. | Guardrail entity or policy list, not user-facing capability. |
| `agent.mcp_tool` | OPS MCP Tool | source-confirmed | `docs/MCP_TOOLS_CONTRACT.md:11-17`, `src/mcp/tool-runtime.ts:118-215`, `src/routes/mcp.ts:56-127` | Gateway exposes `/mcp`; supported methods include initialize/ping/tools/list/tools/call; runtime defines Tasker tools and required scopes. | Tools are filtered by effective session scopes. |
| `agent.idempotency_key` | Gateway Idempotency Key | source-confirmed | `docs/MCP_TOOLS_CONTRACT.md:303-319`, `migrations/001_initial.sql:77-86`, `src/repositories/agents.ts:676-765`, `src/mcp/tool-runtime.ts:390-448` | Write tools require idempotency key; Gateway stores processing/completed state, detects replay/conflict/in-progress, completes or releases keys. | Key is agent-scoped and protects Tasker writes from duplicate execution. |
| `agent.audit_event` | Gateway Audit Event | source-confirmed | `migrations/001_initial.sql:65-75`, `src/repositories/agents.ts:666-674`, `src/mcp/tool-runtime.ts:427-445`, `src/repositories/agents.ts:536-542` | Audit table stores event type/actor/metadata; tool executed/replayed/failed and token created events are recorded. | Required for trustworthy assistant/tool writes. |
| `agent.tasker_adapter` | Tasker Internal Adapter | source-confirmed | `docs/ARCHITECTURE.md:91-107`, `src/tasker/client.ts:104-203`, `src/tasker/client.ts:228-267` | Adapter exposes narrow intent-level Tasker operations and sends agent metadata/headers to Tasker internal endpoints. | Gateway must not call broad/raw Tasker APIs. |
| `agent.setup_packet` | Agent Setup Packet | source-confirmed | `docs/MCP_TOOLS_CONTRACT.md:335-350`, `src/routes/agents.ts:170-177`, `src/routes/agents.ts:1090-1128`, `src/routes/agents.ts:1153-1203` | Authenticated setup endpoint returns MCP server config template, available tools, and generated AGENTS.md rules. | Current Codex config workflow lives here; raw token is represented as placeholder in setup template. |
| `agent.ai_workspace_entitlement` | AI Workspace Entitlement Grant | source-confirmed | `src/routes/agents.ts:132-147`, `src/routes/agents.ts:179-249`, `src/routes/agents.ts:834-895`, `src/routes/agents.ts:942-972` | Internal AI Workspace route validates app/context/owner, selects matching grants, creates short-lived run token, records audit, and returns MCP server grant config. | This is the main future hook for ontology-assisted routing. |
| `agent.pairing_code` | Agent Pairing Code | storage-confirmed / route pending | `docs/ARCHITECTURE.md:74-83`, `docs/ARCHITECTURE.md:145-152`, `migrations/001_initial.sql:52-63` | Pairing codes are part of documented Gateway ownership and have storage table/status/expiry fields. | This pass did not confirm active route behavior; keep P2 pending. |
## Tool / Flow Evidence
| Flow / relation | Evidence status | Evidence file:line | Evidence explanation |
| --- | --- | --- | --- |
| `agent.session` exposes only scoped tools | source-confirmed | `src/mcp/tool-runtime.ts:373-375`, `src/routes/mcp.ts:92-106` | `getToolsForSession` filters tools by required scopes before `/mcp tools/list` returns them. |
| `agent.mcp_tool` executes through grants and scopes | source-confirmed | `src/mcp/tool-runtime.ts:450-557`, `src/security/authorization.ts:11-39` | Each tool validates args, scope, and project grant before calling Tasker adapter. |
| Write tools require idempotency | source-confirmed | `src/mcp/tool-runtime.ts:390-448`, `docs/MCP_TOOLS_CONTRACT.md:303-319` | Non-read tools fail without idempotency key and use claim/replay/conflict/in-progress behavior. |
| Gateway records write audit | source-confirmed | `src/mcp/tool-runtime.ts:427-445`, `src/repositories/agents.ts:666-674` | Successful, replayed, and failed tool attempts produce audit events. |
| Gateway calls Tasker through internal adapter | source-confirmed | `src/tasker/client.ts:104-203`, `src/tasker/client.ts:228-267` | Client calls internal Tasker endpoints and forwards agent id, owner user id, token id, and display metadata. |
| AI Workspace can receive token-scoped MCP grant | source-confirmed | `src/routes/agents.ts:179-249`, `src/routes/agents.ts:858-895` | Internal entitlement route returns an app grant with MCP server URL and bearer token for matching workspace/project grants. |
## Guardrails Confirmed
```text
agent.identity is an OPS Gateway execution identity, not a universal Assistant identity.
agent.token is an opaque credential and must not become a canonical user identity.
agent.scope is a tool permission model, not an OPS role model.
agent.grant binds agent/tool access to OPS workspace/project context.
agent.mcp_tool can request/perform scoped operations, not freely mutate OPS data.
All write tools require grants, scopes, idempotency, and audit.
Gateway should not expose raw Tasker API, delete/archive, workspace settings, invites, or arbitrary attachments.
Tasker remains source of truth for OPS cards/projects/states/labels/members/comments.
Launcher/HUB remains source of entitlement truth.
```
## Product / Architecture Notes
### Current MCP / Codex config path
The current path is workable but rough:
```text
AI Workspace or user setup
-> Agent Gateway setup packet / entitlement grant
-> MCP server config with bearer token
-> Codex or other MCP client
-> scoped Tasker tools
```
This should not be treated as final UX, but it is already a clean enough safety boundary:
- token is opaque and revocable;
- tools are scope-filtered;
- writes are idempotent;
- tool attempts are audited;
- Tasker calls go through a narrow adapter.
### Ontology hook for later
Ontology can improve the setup/routing layer without replacing Gateway:
```text
User asks from OPS / HUB / ENGINE / AI Workspace context
-> ontology resolves target workspace/project/card/workflow/application
-> Gateway issues or selects the correct token-scoped grant
-> Assistant receives only the tools/context it needs
```
Do not immediately rewrite the current Codex config flow. The future improvement is better context resolution and safer grant selection, not removing Gateway.
### Relation to future interface layer
The same pattern will matter for the future automation UI/interface layer:
- interface builder needs ontology-backed meanings;
- assistant may assemble views/widgets from domain entities;
- Gateway-style grants can bound which cards/projects/workflows/views can be mutated;
- Engine should emit structured automation data, not own every custom UI directly.
## Open Questions / Follow-up
1. Confirm active pairing-code route behavior or mark pairing as storage-only legacy/planned.
2. Decide whether `agent.ai_workspace_entitlement` should become a first-class ontology entity or remain a flow/policy object.
3. Map Gateway scopes to canonical OPS operations after the final OPS relation catalog exists.
4. Later, connect ontology resolver to AI Workspace entitlement context selection.
5. Keep `Assistant`, `Agent`, `MCP Client`, `Tool`, and `Gateway Token` as separate concepts.

View File

@ -0,0 +1,146 @@
# Evidence Ledger — OPS P0
Status: first source-backed ledger section
Baseline: Compact Canonical Entity Catalog v0.3.1
Date: 2026-06-18
Scope: OPS / TaskManager / Plane P0 evidence
Source root:
```text
/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER
```
Restrictions observed:
- Source inspection only.
- No runtime/data/storage/env/secrets/logs/dumps.
- No source code edits.
- No build/test/install/docker execution.
## Summary
This pass confirms the OPS P0 model:
- `ops.workspace` is source-backed by Plane `Workspace`.
- `ops.project` is source-backed by Plane `Project`.
- `ops.card` is the canonical NodeDC term for the Plane `Issue` work object.
- `ops.card_status` is backed by Plane `State` / `StateGroup`.
- `ops.card_priority` is a card attribute backed by `Issue.priority`.
- `ops.card_assignment` / `ops.assignee` are backed by `Issue.assignees` and `IssueAssignee`.
- `ops.card_relation` is backed by `IssueRelation` and relation choices.
- `ops.card_type` is source-backed by `IssueType`, but product taxonomy can grow beyond Plane's current naming.
- `ops.responsible` remains product-required/source-adjacent, not fully source-confirmed as a separate canonical entity.
## Entity Evidence
| Entity ID | Canonical name | Evidence status | Evidence file:line | Evidence explanation | Notes |
| --- | --- | --- | --- | --- | --- |
| `ops.workspace` | Ops Workspace | source-confirmed | `plane-src/apps/api/plane/db/models/workspace.py:119-183` | `Workspace` defines workspace name, owner, slug, timezone/storage fields and maps to `workspaces` table. | Plane source alias: `Workspace`. |
| `ops.workspace_member` | Ops Workspace Member | source-confirmed | `plane-src/apps/api/plane/db/models/workspace.py:200-233` | `WorkspaceMember` links workspace to member, role, active/banned state, and per-member props. | Membership context for workspace-level OPS access. |
| `ops.project` | Ops Project | source-confirmed | `plane-src/apps/api/plane/db/models/project.py:68-167` | `Project` links to workspace and defines name, identifier, project settings, default state, external refs. | Plane source alias: `Project`. |
| `ops.project_member` | Ops Project Member | source-confirmed | `plane-src/apps/api/plane/db/models/project.py:212-227` | `ProjectMember` links project/member with role, preferences, sort order, and active state. | Project-level OPS membership. |
| `ops.card` | Ops Card | source-confirmed canonical NodeDC term | `plane-src/apps/api/plane/db/models/issue.py:104-177` | Plane `Issue` defines the core work object with parent, state, name, description, priority, assignees, labels, dates, external refs, and type. | `Issue`, `task`, `work item` are aliases/types; canonical NodeDC term is `ops.card`. |
| `ops.card_status` | Ops Card Status | source-confirmed | `plane-src/apps/api/plane/db/models/state.py:14-20`, `plane-src/apps/api/plane/db/models/state.py:79-115`, `plane-src/apps/api/plane/db/models/issue.py:119-125` | `StateGroup` defines backlog/unstarted/started/completed/cancelled/triage; `State` is project-scoped; `Issue.state` links card to state. | Plane source aliases: `State`, `StateGroup`. |
| `ops.card_priority` | Ops Card Priority | source-confirmed card attribute | `plane-src/apps/api/plane/db/models/issue.py:105-111`, `plane-src/apps/api/plane/db/models/issue.py:140-145` | `Issue.PRIORITY_CHOICES` and `Issue.priority` define urgent/high/medium/low/none priority. | Rename from broad `ops.priority`; this is card-scoped. |
| `ops.card_assignment` | Ops Card Assignment | source-confirmed | `plane-src/apps/api/plane/db/models/issue.py:148-154`, `plane-src/apps/api/plane/db/models/issue.py:337-357` | `Issue.assignees` is a ManyToMany through `IssueAssignee`; `IssueAssignee` links issue to assignee. | Assignment relation/entity for card. |
| `ops.assignee` | Ops Assignee | source-confirmed | `plane-src/apps/api/plane/db/models/issue.py:337-360` | `IssueAssignee` links card to user and enforces uniqueness for non-deleted assignment. | Confirmed source term. |
| `ops.responsible` | Ops Responsible | product-required / source-adjacent | `plane-src/apps/api/plane/db/models/project.py:77-90` | `Project` has `default_assignee` and `project_lead`, which are adjacent to responsibility semantics but not the full product concept. | Keep separate from `ops.assignee`; do not mark fully source-confirmed yet. |
| `ops.card_type` | Ops Card Type | source-confirmed source anchor + product-defined taxonomy | `plane-src/apps/api/plane/db/models/issue.py:163-169`, `plane-src/apps/api/plane/db/models/issue_type.py:14-29`, `plane-src/apps/api/plane/db/models/issue_type.py:35-52` | `Issue.type` links to `IssueType`; `IssueType` has workspace-level name/description/default/active fields; `ProjectIssueType` maps types into projects. | Source-backed by Plane `IssueType`; NodeDC taxonomy can still include task/report/milestone/note/request as product-defined types. |
| `ops.card_relation` | Ops Card Relation | source-confirmed | `plane-src/apps/api/plane/db/models/issue.py:112-118`, `plane-src/apps/api/plane/db/models/issue.py:264-309` | `Issue.parent` supports hierarchy; `IssueRelationChoices` and `IssueRelation` model duplicate/relates_to/blocked_by/start_before/finish_before/implemented_by relations. | Relation taxonomy exists; product meaning may need curation. |
| `ops.external_ref` | Ops External Ref | source-confirmed cross-cutting field | `plane-src/apps/api/plane/db/models/issue.py:161-162`, `plane-src/apps/api/plane/db/models/project.py:120-122`, `plane-src/apps/api/plane/db/models/state.py:92-93`, `plane-src/apps/api/plane/db/models/issue_type.py:23-24` | Issue, Project, State, and IssueType all carry `external_source` / `external_id` fields. | Useful for future cross-app ontology links. |
## Supporting P1 Evidence Found During P0
| Entity ID | Canonical name | Evidence status | Evidence file:line | Evidence explanation | Notes |
| --- | --- | --- | --- | --- | --- |
| `ops.card_label` | Ops Card Label / Marker | source-confirmed | `plane-src/apps/api/plane/db/models/label.py:11-44`, `plane-src/apps/api/plane/db/models/issue.py:156`, `plane-src/apps/api/plane/db/models/issue.py:534-542` | `Label` is workspace/project-scoped; `Issue.labels` uses `IssueLabel`. | P1 but confirmed while reading card model. |
| `ops.card_comment` | Ops Card Comment | source-confirmed | `plane-src/apps/api/plane/db/models/issue.py:442-469` | `IssueComment` links to issue, actor, access, attachments, external refs, parent comment. | P1. |
| `ops.card_activity` | Ops Card Activity | source-confirmed | `plane-src/apps/api/plane/db/models/issue.py:406-435` | `IssueActivity` stores issue, verb, changed field, old/new values, comment/attachments, actor. | P1 event/activity evidence. |
| `ops.card_artifact_link` | Ops Card Artifact Link | source-confirmed | `plane-src/apps/api/plane/db/models/issue.py:389-399`, `plane-src/apps/api/plane/db/models/asset.py:28-75` | `IssueAttachment` links issue to file asset; `FileAsset` can link to issue and stores file metadata. | Relation/link, not global artifact root. |
| `ops.file_asset` | Ops File Asset | source-confirmed | `plane-src/apps/api/plane/db/models/asset.py:28-75` | `FileAsset` stores asset file, workspace/project/issue/comment links, entity type, external refs, metadata, blob ref. | OPS-local source entity. |
| `ops.stored_blob` | Ops Stored Blob | source-confirmed | `plane-src/apps/api/plane/db/models/asset.py:110-147` | `StoredBlob` is canonical stored object with workspace, sha256, size, mime type, canonical object key, status, ref count, metadata. | OPS-local stored binary/blob source entity. |
## Relation / Flow Evidence
| Relation / guardrail | Evidence status | Evidence file:line | Evidence explanation |
| --- | --- | --- | --- |
| `ops.workspace` contains `ops.project` | source-confirmed | `plane-src/apps/api/plane/db/models/project.py:68-76` | `Project.workspace` is a foreign key to workspace. |
| `ops.project` contains `ops.card` | source-confirmed | `plane-src/apps/api/plane/db/models/issue.py:104-177`, `plane-src/apps/api/plane/db/models/project.py:182-191` | `Issue` inherits `ProjectBaseModel`, which stores project and workspace; save copies workspace from project. |
| `ops.card` has status | source-confirmed | `plane-src/apps/api/plane/db/models/issue.py:119-125` | `Issue.state` links to `State`. |
| `ops.card` has priority | source-confirmed | `plane-src/apps/api/plane/db/models/issue.py:140-145` | `Issue.priority` is selected from `PRIORITY_CHOICES`. |
| `ops.card` has assignees | source-confirmed | `plane-src/apps/api/plane/db/models/issue.py:148-154`, `plane-src/apps/api/plane/db/models/issue.py:337-357` | `Issue.assignees` uses the `IssueAssignee` through model. |
| `ops.card` can have type | source-confirmed | `plane-src/apps/api/plane/db/models/issue.py:163-169`, `plane-src/apps/api/plane/db/models/issue_type.py:14-29` | `Issue.type` links to `IssueType`. |
| `ops.card` can relate to other cards | source-confirmed | `plane-src/apps/api/plane/db/models/issue.py:112-118`, `plane-src/apps/api/plane/db/models/issue.py:264-309` | `Issue.parent` and `IssueRelation` support hierarchical and typed cross-card relations. |
| `ops.card` can link to external systems | source-confirmed | `plane-src/apps/api/plane/db/models/issue.py:161-162` | `external_source` / `external_id` on Issue give source-backed external refs. |
## Guardrails Confirmed
```text
ops.card is the canonical NodeDC work object.
Plane Issue is a source alias for ops.card.
task / issue / work item are aliases or card types, not canonical roots.
ops.card_priority is a card attribute, not an OPS-wide root entity.
ops.card_type is source-backed by IssueType, but final NodeDC card type taxonomy is product-defined.
ops.responsible is not the same as ops.assignee.
ops.responsible remains product-required/source-adjacent until product semantics are defined.
ops.card_artifact_link is a relation/link, not a global artifact root.
ops.file_asset and ops.stored_blob are OPS-local source entities.
```
## Product / Architecture Notes
### OPS as assistant surface
OPS is likely the first high-value surface for ontology-backed assistant routing.
Near-term expected use:
```text
User is in OPS card/project context.
Assistant resolves workspace/project/card and can route requests to Engine or Gateway tools.
```
This requires:
- stable `ops.workspace` / `ops.project` / `ops.card` identifiers;
- aliases and external refs;
- clear relation from OPS card/project to Engine application/workflow context;
- scoped tool operations through OPS Gateway, not direct DB mutation.
### Responsible vs assignee
Current source confirms assignees strongly.
Source-adjacent evidence for broader responsibility exists at project level:
- `Project.default_assignee`
- `Project.project_lead`
But these do not fully define the product term `ops.responsible`.
Recommendation:
```text
Keep ops.responsible as product-required.
Define later whether it means owner, accountable person, reviewer, project lead, default assignee, or card-level responsibility role.
```
### Card type taxonomy
`IssueType` is source-confirmed, but NodeDC should not collapse product card semantics into raw Plane naming.
Recommendation:
```text
Use ops.card_type as canonical.
Map Plane IssueType into ops.card_type.
Allow future product taxonomy: task, report, milestone, note, request, automation output, etc.
```
## Open Questions / Follow-up
1. Define `ops.responsible` product semantics before implementing assistant routing around responsibility.
2. Decide canonical `ops.card_type` taxonomy and how it maps to Plane `IssueType`.
3. Later P1 pass should harden card artifacts/activity/external refs in a dedicated file.
4. OPS Gateway pass must confirm that assistant/tool writes happen through scoped operations, grants, idempotency, and audit.

View File

@ -0,0 +1,77 @@
# Ontology Guardrails v0.4-pre
Status: pre-release implementation baseline
Date: 2026-06-18
These rules exist to stop future agents and developers from merging similar-looking but different NodeDC concepts.
## Core Naming Rules
1. Use canonical IDs from `CANONICAL_ENTITY_CATALOG.md`.
2. Treat aliases as aliases, not new roots.
3. If source evidence is missing, mark the entity as `pending`; do not invent implementation facts.
4. Keep `source-confirmed`, `owner-confirmed`, `product-required`, and `future-concept` separate.
5. Use `ops.card` as the canonical work object. `Issue`, `task`, `work item`, and `ticket` are aliases or subtypes.
## HUB Guardrails
- `hub.open_contour` is not an ordinary `hub.client_context`.
- `hub.public_pool_client` is a special source-backed Client used by public pool context.
- `hub.public_user` and `hub.client_user` are classifications of `hub.user`, not separate hard user tables/classes unless future source proves otherwise.
- `hub.application_card` is presentation only. Access authority belongs to `hub.application_access`, `hub.app_grant`, and `hub.app_exception`.
- Authentik, OIDC, JWT, and provider-specific auth names are implementation aliases under `ndcauth.identity` / `ndcauth.session`.
## OPS Product Guardrails
- OPS Product is the operational work/project/card layer.
- OPS Product must not be derived from ENGINE-side `server/routes/ops.js` or Agent Monitor experiments.
- Plane `Issue` maps to canonical `ops.card`.
- `ops.card_priority` is a card attribute, not a global root entity.
- `ops.responsible` is not automatically equal to `ops.assignee`; keep the distinction until source/product semantics are finalized.
- `ops.card_artifact_link` is a relation/link to outputs, not a global artifact root.
## OPS Gateway Guardrails
- OPS Gateway is the scoped MCP/API access layer for agents.
- `agent.identity` is a Gateway execution identity, not a human user and not a universal assistant persona.
- `agent.token` is an opaque credential. Do not treat it as user identity.
- `agent.scope` is a tool/capability permission. Do not confuse it with HUB/OPS roles.
- Gateway writes must remain scoped, audited, and idempotent.
- Gateway does not own raw OPS product truth. Tasker/OPS remains source of truth for projects/cards.
- HUB remains source of truth for platform user/client/application entitlement.
## ENGINE Guardrails
- ENGINE is workflow/dev environment, not the final home for heavy product UIs.
- `engine.workflow_l1` is the NodeDC canvas/workflow graph.
- `engine.workflow_l2` is a runtime/subworkflow layer, currently n8n-backed.
- `engine.workflow_l2_runtime_id` is not the same as NodeDC `engine.workflow_l1` id.
- `engine.l2_run`, `engine.l2_session`, and current trace tables are useful evidence but are tech-debt-adjacent because they live in ENGINE-side OpsLayer.
- Do not turn ENGINE-side Agent Monitor/tender UI into core ontology roots.
- Procurement/tender concepts should later become a domain ontology package, not a core root.
## Future Interface Layer Guardrails
- Do not add new heavy custom product UI directly into ENGINE core.
- Future automation UI should live in a dedicated interface layer/service.
- ENGINE nodes may emit data/control bindings for that layer, but the layer owns views, widgets, dashboards, maps, forms, and control panels.
- Assistant-built interfaces must use ontology-backed meanings, not hardcoded one-off field guesses.
- Current tender/Agent Monitor UI is evidence of need and tech debt, not the target architecture.
## Assistant / MCP Routing Guardrails
- Assistant is not the same as Gateway identity.
- Codex, browser ChatGPT, MCP tool, local executor, and OPS Gateway are different execution surfaces.
- Ontology Core should help resolve target context before selecting grant/tool/workflow, especially for:
- OPS user asks to inspect or switch an Engine workflow.
- Engine user asks to create/update OPS cards.
- Assistant needs to choose project/workflow/card context by human-readable name.
- Browser ChatGPT can propose RFCs, but source evidence and accepted docs remain on disk.
## Data Safety Guardrails
- Do not read or copy env files, secrets, database dumps, storage, runtime buffers, logs, caches, build outputs, `node_modules`, or archives into ontology docs.
- Do not delete files unless explicitly asked by the user.
- Do not edit application source code from the ontology workspace.
- Do not run Docker/build/install/test for docs-only ontology work.

View File

@ -0,0 +1,94 @@
# Implementation Readiness v0.4-pre
Status: ready to start first implementation phase
Date: 2026-06-18
This is the stop point for the current ontology design pass. The next phase is implementation planning/code, not more broad ontology discussion.
## Ready Baseline
The following documents form the implementation baseline:
- `CANONICAL_ENTITY_CATALOG.md`
- `RELATION_CATALOG.md`
- `GUARDRAILS.md`
- `TERMS_GLOSSARY.md`
- `EVIDENCE_LEDGER.md`
- `EVIDENCE_LEDGER_HUB_P0.md`
- `EVIDENCE_LEDGER_OPS_P0.md`
- `EVIDENCE_LEDGER_OPS_GATEWAY_P1.md`
- `EVIDENCE_LEDGER_ENGINE_DIRTY_BOUNDARY_P1.md`
- `EVIDENCE_LEDGER_ENGINE_WORKFLOW_P1.md`
- `ONTOLOGY_SERVICE_PLACEMENT_AND_USE_CASES.md`
- `OPEN_QUESTIONS.md`
## Target Home
Final platform home:
```text
/Users/dcconstructions/Downloads/mnt/NODEDC/platform/services/ontology-core
```
Current working home:
```text
/Users/dcconstructions/Downloads/mnt/NODEDC_ONTOLOGY
```
Do not move source repositories during this phase. The next implementation step can create a clean service skeleton under `platform/services/ontology-core` and copy accepted docs there.
## First Implementation Slice
1. Create `platform/services/ontology-core` as docs-first platform service/module.
2. Add machine-readable catalog files:
- `catalog/entities.json`
- `catalog/relations.json`
- `catalog/aliases.json`
- `catalog/guardrails.json`
- `catalog/evidence.json`
3. Add schema validation for entity/relation IDs and evidence statuses.
4. Add resolver MVP for two high-value flows:
- OPS -> ENGINE: resolve project/card/user request into Engine workflow/L1/L2 context.
- ENGINE -> OPS: resolve workflow/node/domain context into target OPS project/card path.
5. Add read-only lookup API or CLI only after schema and resolver rules are stable.
6. Wire OPS Gateway grant/tool preflight later; ontology advises context, Gateway enforces permission.
## Non-Goals For First Slice
- No database migration yet unless schema pressure requires it.
- No runtime refactor.
- No Engine-side OpsLayer cleanup.
- No tender/Agent Monitor rewrite.
- No full UI constructor yet.
- No broad domain ontology buildout.
- No secret/env/runtime/data/storage ingestion.
## First Useful Product Outcomes
- User in OPS can ask assistant to inspect, switch, or reason about an Engine workflow by human-readable project/workflow/card context.
- User in Engine can ask assistant to create/update the right OPS cards without manually switching OPS project context.
- Agents stop confusing:
- OPS Product;
- OPS Gateway;
- ENGINE-side OpsLayer.
- Future interface layer has a stable conceptual runway and does not repeat the current ENGINE hardcoded UI pattern.
## Handoff Prompt For Implementation
```text
Implement Ontology Core first slice as docs-first platform service at
/Users/dcconstructions/Downloads/mnt/NODEDC/platform/services/ontology-core.
Use /Users/dcconstructions/Downloads/mnt/NODEDC_ONTOLOGY/ontology as accepted baseline.
Do not edit HUB/OPS/Gateway/ENGINE source code in this task.
Create catalog JSON files and validation around canonical entity/relation/alias/guardrail data.
Focus first resolver MVP on OPS -> ENGINE and ENGINE -> OPS context routing.
Keep Gateway permissions as enforcement authority; ontology only resolves/advises context.
Do not read env/secrets/runtime/data/storage/logs/dumps.
```
## Stop Point
The ontology design pass should pause here. Additional browser ChatGPT creativity is useful only as RFC review against these files, not as a replacement for the accepted baseline.

View File

@ -0,0 +1,340 @@
# Ontology Service Placement and Near-Term Use Cases
Status: planning note
Date: 2026-06-18
Scope: architecture direction for NodeDC ontology work
## Current State
Current research workspace:
```text
/Users/dcconstructions/Downloads/mnt/NODEDC_ONTOLOGY
```
This workspace is a temporary documentation/write area for ontology design, evidence ledgers, guardrails, and open questions.
It should not be treated as the final production home.
Do not move it while the active ChatGPT `NodeDC Ontology Write` app points at this path. Moving it now would break the connector.
## Target Home
The ontology belongs in the platform layer, not as a random folder in `mnt`.
Recommended future home:
```text
/Users/dcconstructions/Downloads/mnt/NODEDC/platform/services/ontology-core
```
Alternative names:
```text
platform/services/ontology-registry
platform/services/context-registry
```
Recommended name for now:
```text
ontology-core
```
Reason:
- It is a platform service, not a feature of only HUB, OPS, or ENGINE.
- It should live near:
- `services/notification-core`
- `services/ai-workspace-assistant`
- `services/ai-workspace-hub`
- It should not own HUB/OPS/ENGINE domain data directly.
- It should own canonical cross-app meaning, aliases, relations, routing context, and guardrails.
## What Ontology Core Should Own
Ontology Core should own:
- canonical entity catalog;
- relation catalog;
- alias registry;
- evidence ledger;
- guardrails / forbidden conflations;
- cross-surface context mapping;
- future API for entity/relation resolution;
- future API for assistant/tool routing context.
Ontology Core should not own:
- Launcher users/clients/groups/access data;
- Plane/OPS cards/projects/workspaces/comments;
- Engine workflows/nodes/runtime events;
- Authentik identity/session data;
- Notification deliveries/read-state;
- actual source-code edits.
Domain data stays with the owning service. Ontology Core describes and connects meaning across services.
## Near-Term Product Use Case
The first practical value is cross-application assistant routing.
### OPS to ENGINE
Example:
```text
User is in OPS and asks Assistant:
"Replace the workflow for this project/card with another Engine workflow and then continue coding."
```
Expected future behavior:
1. Assistant knows current OPS surface context:
- workspace;
- project;
- card;
- selected assistant/project context.
2. Ontology resolves that OPS context to relevant Engine application boundary / workflow context.
3. Assistant can ask the correct Engine-side tool/action to inspect or switch workflow.
4. Engine remains owner of workflow internals.
5. OPS remains owner of card/project/task context.
6. The action is auditable and bounded by user permissions.
Ontology requirement:
```text
ops.card / ops.project / ops.workspace
can relate to
engine.application_boundary / engine.workflow_l1 / engine.workflow_l2
through explicit relation and permission context,
not by hardcoded UI assumptions.
```
### ENGINE to OPS
Example:
```text
User is in ENGINE and asks Assistant:
"Create OPS tasks for this workflow issue / describe next implementation steps in the right project."
```
Expected future behavior:
1. Assistant knows current Engine surface context:
- workflow;
- node;
- application boundary;
- selected assistant/project context.
2. Ontology resolves the target OPS workspace/project/card destination.
3. Assistant can create or update OPS cards through scoped OPS Gateway operations.
4. User should not have to manually switch assistant project if target can be resolved by name/context.
Ontology requirement:
```text
engine.workflow_l1 / engine.node_l1 / engine.application_boundary
can relate to
ops.workspace / ops.project / ops.card
through explicit routing relations and aliases.
```
## Why This Matters
Without ontology:
- Assistant context is surface-local.
- OPS assistant may not know the correct Engine workflow target.
- Engine assistant may not know the correct OPS project/card destination.
- Users must manually switch projects/surfaces.
- Workflows become hardcoded by UI glue and fragile assumptions.
With ontology:
- Assistant can resolve user intent across HUB, OPS, ENGINE, and platform services.
- Names, aliases, and relations can be normalized.
- Cross-app actions can be permissioned, audited, and routed.
- Future automation can target stable meanings instead of brittle file/UI paths.
## Design Guardrails
Ontology must stay flexible:
- Core ontology should stay small and stable.
- Task-specific entities can be added later per domain.
- Do not force every future use case into the first core catalog.
- Use statuses:
- `source-confirmed`
- `source-evidenced/context-derived`
- `product-required`
- `future concept`
- `tech debt / non-canonical`
- Dirty but working implementations should be marked as tech debt, not silently promoted into canonical product truth.
## Known Dirty Areas To Track
### Engine-side OPS / tender-agent / Agent Monitor
Status:
```text
tech debt / non-canonical
```
Do not use it as OPS product truth.
Future direction:
```text
automation control surface / UI constructor / automation work views
```
Automation nodes should emit structured data into a separate work/control layer rather than keeping heavy custom OPS-like UI inside ENGINE.
Clarification:
- `OPS product` means the separate operational system / Tasker surface with workspaces, projects, cards, statuses, labels, comments, and assignment.
- `Engine-side OPS / tender-agent / Agent Monitor` means the old custom workflow/UI implementation inside ENGINE. It is a working prototype/legacy slice, not the canonical OPS product model.
- Do not let the ontology merge these two meanings under one root.
- Treat the Engine-side tender UI as evidence for future interface needs, not as the final architecture.
### Future Interface / Automation UI Layer
Status:
```text
future concept / product-required soon
```
Strategic direction:
- ENGINE should remain a workflow/development environment.
- New heavy custom product interfaces should not be hardcoded inside ENGINE.
- Automation/workflow nodes should be able to emit structured data for a separate interface/control layer.
- A future platform service or app should own user-facing automation work views, dashboards, cards, maps, analytics panels, and other generated/custom UI surfaces.
- Assistants are expected to help assemble these interfaces from ontology-backed domain meanings and UI rules.
Possible future names:
```text
platform/services/interface-core
platform/services/automation-ui
platform/services/workview-core
platform/services/ui-workbench
```
No final name is selected yet.
The ontology must be ready to describe:
- domain entities and their aliases;
- workflow node outputs;
- UI view definitions;
- widget/control types;
- dashboard/work-view composition;
- source application and permission context;
- relation from OPS card/project or ENGINE workflow/node to the generated UI surface.
Near-term implication:
```text
Do not canonize the current tender-agent / Agent Monitor UI embedded in ENGINE.
Use it as a learning sample for what the future interface layer must support.
```
### Domain Ontology Expansion
Core ontology should stay compact, but it must leave a clean path for domain ontologies.
Expected domain families already visible from product direction:
- tenders / procurement;
- ecology;
- transport analytics;
- robotics / Helius-style analysis;
- business processes;
- ERP-like operational domains;
- future client-specific domains.
These domains should extend the core catalog rather than polluting the core roots.
Recommended shape:
```text
core ontology
-> platform/service/application/surface/user/card/workflow basics
domain ontology
-> domain-specific entities, fields, relations, UI views, and automation outputs
```
### Assistant Project / Surface Selection
Current concern:
```text
Assistant can be attached to one project/context at a time, forcing manual switching.
```
Ontology should support later resolution by:
- entity name;
- aliases;
- current surface;
- user permissions;
- project/workflow/card relation;
- explicit target override from the user.
This is a core near-term reason for building the ontology.
### MCP / Codex Config Routing
Current OPS Gateway already has a rough setup/config route:
```text
AI Workspace / Codex config
-> token-scoped MCP grant
-> OPS Gateway
-> Tasker internal adapter
```
Do not refactor this immediately.
Ontology can later improve this by resolving the correct target context before issuing or selecting grants:
- owner/user identity;
- active surface;
- OPS workspace/project/card;
- ENGINE application/workflow/node;
- explicit user target;
- aliases and product names;
- permitted tool/action set.
This keeps the current working MCP path while creating a path toward less manual config and safer assistant routing.
## Migration Plan
Short term:
1. Keep writing research docs in `NODEDC_ONTOLOGY`.
2. Finish evidence ledgers for HUB / OPS / OPS Gateway / ENGINE.
3. Create consolidated:
- `CANONICAL_ENTITY_CATALOG.md`
- `RELATION_CATALOG.md`
- `EVIDENCE_LEDGER.md`
- `GUARDRAILS.md`
- `OPEN_QUESTIONS.md`
Medium term:
1. Create `platform/services/ontology-core`.
2. Move accepted docs from `NODEDC_ONTOLOGY/ontology` into the service docs.
3. Reconnect ChatGPT write app to the final path.
4. Decide whether the service starts as docs-only, JSON registry, or API + DB.
Long term:
1. Add machine-readable catalog format.
2. Add resolver APIs for Assistant / OPS Gateway / Engine tools.
3. Add relation/evidence versioning.
4. Add permissions-aware context resolution.

View File

@ -0,0 +1,63 @@
# Open Questions v0.4-pre
Status: pre-release open question register
Date: 2026-06-18
No question below blocks the first Ontology Core implementation. These are the items to resolve during implementation or the next evidence pass.
## P0 Before First Code Merge
| ID | Question | Current stance |
| --- | --- | --- |
| `OQ-001` | What exact package/service shape starts `platform/services/ontology-core`? | Start docs-first + machine-readable catalog schema; add API/db only after resolver MVP needs it. |
| `OQ-002` | What is the first resolver use case? | OPS -> ENGINE workflow context and ENGINE -> OPS project/card context. |
| `OQ-003` | What is accepted source of truth for current docs? | `NODEDC_ONTOLOGY/ontology/*` until moved/copied into target platform service. |
## OPS Product Questions
| ID | Question | Current stance |
| --- | --- | --- |
| `OQ-101` | Exact `ops.responsible` semantics? | Keep distinct from `ops.assignee`; define during OPS card workflow hardening. |
| `OQ-102` | `ops.card_type` taxonomy? | Need explicit list: architecture block, task, report, milestone/mailstone, incident, decision, research, etc. |
| `OQ-103` | How should artifacts/files attach to cards? | Keep as `ops.card_artifact_link` until storage/file authority is clear. |
## OPS Gateway Questions
| ID | Question | Current stance |
| --- | --- | --- |
| `OQ-201` | Is `agent.ai_workspace_entitlement` a durable entity or a policy/flow? | Keep as source-evidenced pending boundary. |
| `OQ-202` | Pairing/setup flow exact route model? | Keep `agent.pairing_code` pending route hardening. |
| `OQ-203` | How should ontology select Gateway grants? | First version can be advisory/preflight; Gateway remains enforcement authority. |
## ENGINE Questions
| ID | Question | Current stance |
| --- | --- | --- |
| `OQ-301` | Should `engine.l2_run` and `engine.l2_session` be canonical long term? | They are source-confirmed but tech-debt-adjacent; keep but do not overbuild around current tables. |
| `OQ-302` | Where should future runtime observability live? | Candidate: dedicated runtime/observability package, not OPS Product and not final Interface Layer. |
| `OQ-303` | What is `engine.source_stamp` exactly? | Pending. Use only when implementation needs stable version/source traceability. |
| `OQ-304` | How to retire ENGINE-side tender/Agent Monitor UI? | Defer until Interface Layer MVP exists; mark as tech debt now. |
## Assistant / AI Workspace Questions
| ID | Question | Current stance |
| --- | --- | --- |
| `OQ-401` | Exact source entities for assistant/thread/message/provider/executor? | Dedicated AI Workspace source pass needed. |
| `OQ-402` | How much browser ChatGPT/codex bridge state becomes product state? | Treat current bridge as workflow/process, not product data model, until source exists. |
| `OQ-403` | Can ontology reduce MCP config/token routing pain? | Yes as resolver/preflight later; do not rewrite config flow in this phase. |
## Future Interface Layer Questions
| ID | Question | Current stance |
| --- | --- | --- |
| `OQ-501` | Final service name? | Candidate names: `interface-core`, `automation-ui`, `workview-core`, `ui-workbench`, `notg-controller-layer`. |
| `OQ-502` | First MVP view type? | Likely table/report/control panel over an Engine workflow output, not the full UI constructor. |
| `OQ-503` | How are view bindings emitted from Engine? | Future custom nodes should emit ontology-backed data/control bindings into a separate interface data layer. |
## Domain Ontology Questions
| ID | Question | Current stance |
| --- | --- | --- |
| `OQ-601` | Which domain package goes first? | Candidate: procurement/tenders because existing dirty implementation exposes many lessons. |
| `OQ-602` | How to handle ecology/transport/robotics/ERP domains? | Add as packages after core resolver and entity/relation schema stabilize. |

View File

@ -0,0 +1,33 @@
# Ontology Documents
Primary documents:
- `CANONICAL_ENTITY_CATALOG.md`
- `RELATION_CATALOG.md`
- `EVIDENCE_LEDGER.md`
- `GUARDRAILS.md`
- `TERMS_GLOSSARY.md`
- `OPEN_QUESTIONS.md`
- `IMPLEMENTATION_READINESS.md`
Current accepted baseline:
- Canonical Entity Catalog v0.4-pre is the active pre-release baseline.
- HUB has an open contour and a managed client/company context.
- `hub.public_pool_client` is a source-backed special Client used by the public pool context.
- `hub.open_contour` must not be treated as an ordinary `hub.client_context`.
- `ops.card` is the canonical OPS work object.
- OPS Product, OPS Gateway, and ENGINE-side OpsLayer are different entities.
- ENGINE L1/L2 workflow split is source-evidenced enough for first resolver implementation.
- Engine-side OPS / Agent Monitor / tender-agent config is non-canonical tech debt.
- Future Interface Layer is a planned separate layer/service, not more custom UI inside ENGINE core.
Current working evidence artifacts:
- `EVIDENCE_HARDENING_PLAN_V0_3_1.md`
- `EVIDENCE_LEDGER_HUB_P0.md`
- `EVIDENCE_LEDGER_OPS_P0.md`
- `EVIDENCE_LEDGER_OPS_GATEWAY_P1.md`
- `EVIDENCE_LEDGER_ENGINE_DIRTY_BOUNDARY_P1.md`
- `EVIDENCE_LEDGER_ENGINE_WORKFLOW_P1.md`
- `ONTOLOGY_SERVICE_PLACEMENT_AND_USE_CASES.md`

View File

@ -0,0 +1,90 @@
# Relation Catalog v0.4-pre
Status: pre-release implementation baseline
Date: 2026-06-18
Relations are written as product-level semantics, not database foreign keys. Implementation can later map them to tables, APIs, MCP tools, or resolver rules.
## HUB Relations
| Relation ID | From | To | Meaning | Status |
| --- | --- | --- | --- | --- |
| `hub.open_contour.uses_public_pool` | `hub.open_contour` | `hub.public_pool_context` | Open contour is implemented through public pool context. | source-evidenced |
| `hub.public_pool_context.backed_by_client` | `hub.public_pool_context` | `hub.public_pool_client` | Public pool context uses a special source-backed Client. | source-confirmed |
| `hub.user.has_membership` | `hub.user` | `hub.membership` | User belongs to a context through membership. | source-confirmed |
| `hub.membership.in_context` | `hub.membership` | `hub.client_context` / `hub.public_pool_context` | Membership points to closed client context or public pool. | source-confirmed |
| `hub.access_request.targets_context` | `hub.access_request` | `hub.client_context` / `hub.public_pool_context` | Request targets a context. | source-confirmed |
| `hub.public_access_request.promotes_to_client_context` | `hub.public_access_request` | `hub.client_context` | Public request can become client membership after approval. | source-confirmed |
| `hub.invite.targets_context` | `hub.invite` | `hub.client_context` / `hub.public_pool_context` | Invite targets a context. | source-confirmed |
| `hub.application.has_access_result` | `hub.application` | `hub.application_access` | Application exposes effective access state. | source-confirmed |
| `hub.app_grant.grants_access_to` | `hub.app_grant` | `hub.application` | Grant contributes to effective access. | source-confirmed |
| `hub.app_exception.overrides_access_to` | `hub.app_exception` | `hub.application` | User-specific allow/deny exception. | source-confirmed |
| `hub.application_card.presents` | `hub.application_card` | `hub.application` | UI card presents app plus access state. | source-confirmed |
| `ndcauth.identity.maps_to_hub_user` | `ndcauth.identity` | `hub.user` | Auth subject resolves to launcher user. | source-confirmed |
## OPS Product Relations
| Relation ID | From | To | Meaning | Status |
| --- | --- | --- | --- | --- |
| `ops.workspace.contains_project` | `ops.workspace` | `ops.project` | Project belongs to workspace. | source-confirmed |
| `ops.workspace.has_member` | `ops.workspace` | `ops.workspace_member` | Workspace membership. | source-confirmed |
| `ops.project.has_member` | `ops.project` | `ops.project_member` | Project membership. | source-confirmed |
| `ops.project.contains_card` | `ops.project` | `ops.card` | Card belongs to project. | source-confirmed |
| `ops.card.has_status` | `ops.card` | `ops.card_status` | Card state. | source-confirmed |
| `ops.card.has_priority` | `ops.card` | `ops.card_priority` | Card priority attribute. | source-confirmed |
| `ops.card.assigned_to` | `ops.card` | `ops.assignee` | Card assignment. | source-confirmed |
| `ops.card.accountable_to` | `ops.card` | `ops.responsible` | Product-level responsibility relation. | product-required |
| `ops.card.typed_as` | `ops.card` | `ops.card_type` | Card subtype/purpose. | source-evidenced |
| `ops.card.related_to` | `ops.card` | `ops.card_relation` | Relation/dependency between cards. | source-confirmed |
| `ops.card.has_label` | `ops.card` | `ops.card_label` | Label/tag classification. | source-confirmed |
| `ops.card.has_comment` | `ops.card` | `ops.card_comment` | Discussion/comment relation. | source-confirmed |
| `ops.card.has_activity` | `ops.card` | `ops.card_activity` | Timeline/change relation. | source-evidenced |
| `ops.card.links_artifact` | `ops.card` | `ops.card_artifact_link` | Links output artifacts or docs. | source-evidenced |
| `ops.card.has_external_ref` | `ops.card` | `ops.external_ref` | Links external/source object. | source-confirmed |
## OPS Gateway Relations
| Relation ID | From | To | Meaning | Status |
| --- | --- | --- | --- | --- |
| `agent.token.represents_identity` | `agent.token` | `agent.identity` | Token authenticates agent identity, not user identity. | source-confirmed |
| `agent.identity.has_grant` | `agent.identity` | `agent.grant` | Identity receives grants. | source-confirmed |
| `agent.token.has_token_grant` | `agent.token` | `agent.token_grant` | Token-level grant binding. | source-confirmed |
| `agent.grant.allows_scope` | `agent.grant` | `agent.scope` | Grant enables tool/capability scope. | source-confirmed |
| `agent.scope.exposes_tool` | `agent.scope` | `agent.mcp_tool` | Scope controls MCP tool exposure. | source-confirmed |
| `agent.mcp_tool.writes_via_adapter` | `agent.mcp_tool` | `agent.tasker_adapter` | Gateway writes to OPS through adapter. | source-confirmed |
| `agent.mcp_tool.requires_idempotency` | `agent.mcp_tool` | `agent.idempotency_key` | Write operations require idempotency. | source-confirmed |
| `agent.mcp_tool.emits_audit` | `agent.mcp_tool` | `agent.audit_event` | Operations produce audit records. | source-confirmed |
| `agent.setup_packet.provisions_token` | `agent.setup_packet` | `agent.token` | Setup flow produces token/connect config. | source-evidenced |
## ENGINE Relations
| Relation ID | From | To | Meaning | Status |
| --- | --- | --- | --- | --- |
| `engine.workflow_l1.contains_node` | `engine.workflow_l1` | `engine.node_l1` | L1 workflow graph contains nodes. | source-confirmed |
| `engine.workflow_l1.contains_edge` | `engine.workflow_l1` | `engine.edge_l1` | L1 workflow graph contains edges. | source-confirmed |
| `engine.edge_l1.connects_nodes` | `engine.edge_l1` | `engine.node_l1` | Edge connects source/target node handles. | source-confirmed |
| `engine.node_l1.has_type` | `engine.node_l1` | `engine.node_type_l1` | Node type comes from registry/module. | source-confirmed |
| `engine.workflow_l1.has_acl` | `engine.workflow_l1` | `engine.workflow_acl` | Workflow uses access control. | source-confirmed |
| `engine.workflow_acl.has_owner` | `engine.workflow_acl` | `engine.workflow_owner` | ACL owns owner field. | source-confirmed |
| `engine.workflow_l1.has_share` | `engine.workflow_l1` | `engine.workflow_share` | Workflow can be shared/invited. | source-confirmed |
| `engine.workflow_l1.has_access_request` | `engine.workflow_l1` | `engine.workflow_access_request` | Access requests target workflow. | source-confirmed |
| `engine.node_l1.embeds_l2_workflow` | `engine.node_l1` | `engine.workflow_l2` | n8n subworkflow is attached to L1 node. | source-evidenced |
| `engine.workflow_l2.deployed_as_runtime_workflow` | `engine.workflow_l2` | `engine.workflow_l2_runtime_id` | Compiled n8n workflow deploy returns runtime workflow id. | source-confirmed |
| `engine.workflow_l2.runs_on_runtime_core` | `engine.workflow_l2` | `engine.l2_runtime_core` | L2 workflow targets n8n instance/core. | source-evidenced |
| `engine.l2_execution.belongs_to_runtime_workflow` | `engine.l2_execution` | `engine.workflow_l2_runtime_id` | Execution is emitted for n8n workflow id. | source-confirmed |
| `engine.l2_runtime_event.describes_execution` | `engine.l2_runtime_event` | `engine.l2_execution` | Runtime events carry execution id. | source-confirmed |
| `engine.l2_runtime_event.binds_run` | `engine.l2_runtime_event` | `engine.l2_run` | Event binds to run id. Current storage is ENGINE-side OpsLayer. | source-confirmed / tech-debt-adjacent |
| `engine.l2_run.has_session` | `engine.l2_run` | `engine.l2_session` | Session anchors related runtime events/runs. | source-confirmed / tech-debt-adjacent |
## Cross-Surface Relations Needed First
These are the first useful implementation targets for Ontology Core.
| Relation ID | From | To | Meaning | Status |
| --- | --- | --- | --- | --- |
| `ontology.resolves_ops_card_to_engine_context` | `ops.card` | `engine.workflow_l1` / `engine.workflow_l2` | Assistant in OPS can route a request to the correct Engine workflow context. | product-required |
| `ontology.resolves_engine_context_to_ops_project` | `engine.workflow_l1` / `engine.workflow_l2` | `ops.project` | Assistant in Engine can create/update cards in the correct OPS project. | product-required |
| `ontology.resolves_hub_app_to_project_context` | `hub.application` | `ops.project` / `engine.workflow_l1` | Application selection can suggest project/workflow context. | product-required |
| `ontology.selects_gateway_grant_context` | `assistant.bridge` | `agent.grant` | Ontology can help choose proper MCP/Gateway grant before tool calls. | product-required |
| `ontology.routes_interface_view_to_domain_package` | `future.interface_view` | `future.domain_package` | Future UI layer renders domain-specific views from ontology-backed data. | future-concept |

View File

@ -0,0 +1,49 @@
# Terms Glossary v0.4-pre
Status: pre-release glossary
Date: 2026-06-18
## Canonical Terms
| Term | Meaning |
| --- | --- |
| HUB | Launcher/application access layer with open and company/client contours. |
| Open contour | Public/open HUB branch for simple users and public pool access. |
| Client/company contour | Managed closed HUB context for companies/clients. |
| OPS Product | Operational work/project/card layer. |
| OPS Card | Canonical work object in OPS; Plane Issue/task/work item are aliases or subtypes. |
| OPS Gateway | Scoped MCP/API access layer for agents working with OPS. |
| Agent identity | Gateway execution identity, not a human user and not assistant persona. |
| Agent token | Opaque credential used by Gateway; not user identity. |
| ENGINE | Workflow/dev environment. |
| L1 workflow | NodeDC/React Flow canvas workflow. |
| L2 workflow | Runtime/subworkflow layer, currently n8n-backed. |
| Runtime workflow id | n8n runtime workflow id, separate from NodeDC L1 workflow id. |
| Runtime event | Event emitted by L2 runtime: workflow/node start/finish/success/error. |
| Assistant | Product assistant capability; exact source model pending AI Workspace pass. |
| Ontology Core | Future platform service/module for canonical entities, relations, aliases, evidence, and resolver rules. |
| Interface Layer | Future UI/workview layer for automation dashboards, maps, forms, and controls. |
| Domain package | Domain-specific ontology extension, e.g. tenders, ecology, transport, robotics, ERP. |
## Alias Rules
| Alias | Canonical term |
| --- | --- |
| Plane Issue | `ops.card` |
| task / ticket / work item | `ops.card` or `ops.card_type`, depending on context |
| Authentik user / OIDC subject / JWT subject | `ndcauth.identity` |
| Launcher service / app | `hub.application` |
| Application tile / app card | `hub.application_card` |
| n8n workflow id | `engine.workflow_l2_runtime_id` |
| n8n execution | `engine.l2_execution` |
| Engine-side OPS / Agent Monitor OPS | `engine.tech_debt.ops_layer`, not OPS Product |
| Tender Agent Monitor UI | `engine.tech_debt.tender_domain_ui`, not future Interface Layer |
## Forbidden Shortcuts
- Do not say "OPS" without specifying OPS Product, OPS Gateway, or ENGINE-side OpsLayer.
- Do not say "workflow" without specifying L1 workflow, L2 workflow, or runtime workflow id when ambiguity matters.
- Do not say "user" when the context needs HUB user, NDCAuth identity, OPS member, or Gateway agent identity.
- Do not say "task" as the root concept when the object is an OPS card.
- Do not treat current tender/Agent Monitor implementation as target architecture.

View File

@ -0,0 +1,8 @@
{
"assistantRole": "admin",
"launcherUserStatus": "active",
"launcherGlobalRole": "client_admin",
"membershipRole": "client_admin",
"membershipStatus": "active",
"groups": []
}

View File

@ -0,0 +1,8 @@
{
"assistantRole": "admin",
"launcherUserStatus": "blocked",
"launcherGlobalRole": "client_admin",
"membershipRole": "client_admin",
"membershipStatus": "active",
"groups": []
}

View File

@ -0,0 +1,8 @@
{
"assistantRole": "member",
"launcherUserStatus": "active",
"launcherGlobalRole": "member",
"membershipRole": "member",
"membershipStatus": "active",
"groups": []
}

View File

@ -0,0 +1,6 @@
{
"launcherUserStatus": "active",
"launcherGlobalRole": "root_admin",
"membershipStatus": "active",
"groups": ["nodedc:superadmin"]
}

View File

@ -0,0 +1,8 @@
{
"intent": "заблокируй пользователя",
"assistantRole": "admin",
"membershipRole": "client_admin",
"actorUserId": "user_root",
"targetUserId": "user_silver_psih",
"confirmed": true
}

View File

@ -0,0 +1,11 @@
{
"actionId": "hub.assistant_access.change_role",
"assistantRole": "admin",
"membershipRole": "client_admin",
"actorUserId": "user_root",
"targetUserId": "user_silver_psih",
"membershipId": "mem_client_public_pool_user_silver_psih",
"targetAssistantRole": "blocked",
"confirmed": true,
"idempotencyKey": "example:assistant-role:user_silver_psih:blocked"
}

View File

@ -0,0 +1,8 @@
{
"actionId": "hub.user.delete",
"assistantRole": "admin",
"membershipRole": "client_admin",
"actorUserId": "user_root",
"targetUserId": "user_silver_psih",
"confirmed": true
}

View File

@ -0,0 +1,8 @@
{
"actionId": "engine.workflow.share",
"assistantRole": "admin",
"membershipRole": "client_admin",
"actorUserId": "user_root",
"targetUserId": "user_silver_psih",
"confirmed": true
}

View File

@ -0,0 +1,10 @@
{
"actionId": "hub.membership.disable",
"assistantRole": "admin",
"membershipRole": "client_admin",
"actorUserId": "user_root",
"targetUserId": "user_root",
"confirmed": true,
"removesOwnAccess": true,
"hasAlternativeAdminPath": false
}

View File

@ -0,0 +1,12 @@
{
"phase": "preview",
"input": {
"actionId": "hub.user.block",
"assistantRole": "admin",
"membershipRole": "client_admin",
"actorUserId": "user_root",
"targetUserId": "user_silver_psih",
"confirmed": true,
"idempotencyKey": "example:caller:block:user_silver_psih"
}
}

View File

@ -0,0 +1,12 @@
{
"id": "context.engine.example.workflow",
"surface": "engine",
"entityId": "engine.workflow_l1",
"sourceSystem": "nodedc-engine",
"label": "Example Engine Workflow",
"status": "planned",
"refs": {
"workflow_id": "example-workflow-id",
"node_id": "example-node-id"
}
}

View File

@ -0,0 +1,9 @@
{
"surface": "engine",
"entityId": "engine.workflow_l1",
"intent": "создай карточку в ops по текущему workflow",
"refs": {
"workflow_id": "example-workflow-id",
"node_id": "example-node-id"
}
}

View File

@ -0,0 +1,19 @@
{
"workflow": {
"id": "example-workflow-id",
"label": "Example Engine Workflow",
"nodeId": "example-node-id",
"runtimeWorkflowId": "example-n8n-runtime-workflow-id",
"n8nInstanceId": "example-n8n-instance"
},
"context": {
"status": "planned",
"sourceSystem": "nodedc-engine"
},
"bindToOps": {
"enabled": true,
"status": "planned",
"fromContextId": "context.ops.nodedc.ndc_platform.ontology_core_card",
"summary": "Example planned binding from Ontology Core OPS card to an Engine workflow context."
}
}

View File

@ -0,0 +1,10 @@
{
"surface": "ops",
"alias": "issue",
"intent": "inspect engine workflow for this ontology card",
"refs": {
"workspace_slug": "nodedc",
"project_id": "86629a11-eaff-4ad2-9f89-e5245a344fcc",
"issue_id": "1312519a-0eda-4ea0-9e34-2113c3724ecf"
}
}

View File

@ -0,0 +1,8 @@
{
"id": "binding.ops.ontology_core_card.engine.example_workflow",
"typeId": "binding_type.ops_card_to_engine_workflow",
"status": "planned",
"fromContextId": "context.ops.nodedc.ndc_platform.ontology_core_card",
"toContextId": "context.engine.example.workflow",
"summary": "Example planned binding from Ontology Core OPS card to an Engine workflow context."
}

View File

@ -0,0 +1,22 @@
{
"name": "@nodedc/ontology-core",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"assistant:action": "node src/assistant-action-resolver.mjs",
"assistant:caller": "node src/assistant-action-caller.mjs",
"assistant:execute": "node src/assistant-action-executor.mjs",
"assistant:plan": "node src/assistant-action-plan.mjs",
"assistant:policy": "node src/assistant-policy.mjs",
"registry": "node src/registry.mjs",
"resolve": "node src/resolver.mjs",
"validate": "node src/validate.mjs",
"smoke:assistant-action-plan": "node src/assistant-action-plan.mjs --smoke",
"smoke:assistant-caller": "node src/assistant-action-caller.mjs --smoke",
"smoke:assistant-executor": "node src/assistant-action-executor.mjs --smoke",
"smoke:assistant-actions": "node src/assistant-action-resolver.mjs --smoke",
"smoke:assistant-policy": "node src/assistant-policy.mjs --smoke",
"smoke:resolver": "node src/resolver.mjs --smoke"
}
}

View File

@ -0,0 +1,199 @@
import { resolveAssistantAction } from '../assistant-action-resolver.mjs'
const HUB_ACTIONS = new Set([
'hub.user.read_admin_summary',
'hub.invite.list_pending',
'hub.access_request.list_pending',
'hub.user.block',
'hub.user.unblock',
'hub.membership.change_role',
'hub.membership.disable',
'hub.assistant_access.change_role',
])
const MEMBERSHIP_ROLES = new Set(['client_owner', 'client_admin', 'member'])
const CORE_ASSISTANT_ROLES = new Set(['blocked', 'member', 'admin'])
function requireValue(input, key) {
const value = input[key]
if (value === undefined || value === null || String(value).trim() === '') {
throw new Error(`Missing required adapter input: ${key}`)
}
return String(value)
}
function encode(value) {
return encodeURIComponent(String(value))
}
function makeIdempotencyKey(actionId, input) {
if (input.idempotencyKey) return String(input.idempotencyKey)
return [
actionId,
input.actorUserId || 'actor_unknown',
input.targetUserId || input.membershipId || 'target_unknown',
input.targetRole || input.targetAssistantRole || input.targetStatus || 'default',
].join(':')
}
function patchPlan(actionId, input, path, body, summary) {
return {
method: 'PATCH',
path,
body,
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': makeIdempotencyKey(actionId, input),
},
summary,
}
}
function buildPlanForAction(actionId, input) {
switch (actionId) {
case 'hub.user.read_admin_summary':
return {
method: 'GET',
path: '/api/admin/control-plane',
query: {
projection: 'users,memberships,services,grants,exceptions,invites,accessRequests',
clientId: input.clientId || null,
},
summary: 'Read admin-visible Launcher control-plane summary and filter in adapter output.',
}
case 'hub.invite.list_pending':
return {
method: 'GET',
path: '/api/admin/control-plane',
query: {
projection: 'invites',
status: 'pending',
clientId: input.clientId || null,
},
summary: 'Read Launcher invites and return pending invites visible in current admin scope.',
}
case 'hub.access_request.list_pending':
return {
method: 'GET',
path: '/api/admin/control-plane',
query: {
projection: 'accessRequests,engineWorkflowAccessRequests,users,clients',
status: 'pending',
clientId: input.clientId || null,
},
summary: 'Read Launcher access requests and return pending requests visible in current admin scope.',
}
case 'hub.user.block':
return patchPlan(
actionId,
input,
`/api/admin/users/${encode(requireValue(input, 'targetUserId'))}/profile`,
{ globalStatus: 'blocked' },
'Block Launcher user via guarded profile update route.',
)
case 'hub.user.unblock':
return patchPlan(
actionId,
input,
`/api/admin/users/${encode(requireValue(input, 'targetUserId'))}/profile`,
{ globalStatus: 'active' },
'Unblock Launcher user via guarded profile update route.',
)
case 'hub.membership.change_role': {
const targetRole = requireValue(input, 'targetRole')
if (!MEMBERSHIP_ROLES.has(targetRole)) {
throw new Error(`Invalid targetRole: ${targetRole}`)
}
return patchPlan(
actionId,
input,
`/api/admin/memberships/${encode(requireValue(input, 'membershipId'))}`,
{ role: targetRole },
'Change Launcher client membership role via guarded membership update route.',
)
}
case 'hub.membership.disable':
return patchPlan(
actionId,
input,
`/api/admin/memberships/${encode(requireValue(input, 'membershipId'))}`,
{ status: 'disabled' },
'Disable Launcher client membership via guarded membership update route.',
)
case 'hub.assistant_access.change_role': {
const targetAssistantRole = requireValue(input, 'targetAssistantRole')
if (!CORE_ASSISTANT_ROLES.has(targetAssistantRole)) {
throw new Error(`Invalid targetAssistantRole: ${targetAssistantRole}`)
}
return patchPlan(
actionId,
input,
`/api/admin/memberships/${encode(requireValue(input, 'membershipId'))}`,
{ coreAssistantRole: targetAssistantRole },
'Change NDC Core Assistant access role via guarded membership update route.',
)
}
default:
throw new Error(`Unsupported HUB action: ${actionId}`)
}
}
export async function buildHubLauncherAdminPlan(input = {}, options = {}) {
const decision = options.decision || await resolveAssistantAction(input, options)
const actionId = decision.action?.id
if (!actionId) {
return {
ok: false,
decision: 'unsupported_hub_action',
reason: 'Input did not resolve to a supported HUB Launcher admin action.',
actionDecision: decision,
}
}
if (
decision.decision !== 'allowed' &&
!(options.planPreview === true && decision.decision === 'needs_confirmation')
) {
return {
ok: false,
decision: decision.decision,
reason: decision.reason,
actionDecision: decision,
}
}
if (!HUB_ACTIONS.has(actionId)) {
return {
ok: false,
decision: 'unsupported_hub_action',
reason: 'Input did not resolve to a supported HUB Launcher admin action.',
actionDecision: decision,
}
}
const plan = buildPlanForAction(actionId, input)
const adapterReady = decision.action.adapterStatus === 'implemented'
return {
ok: true,
decision: 'planned',
adapterId: 'adapter.hub.launcher_admin_api',
adapterStatus: decision.action.adapterStatus,
executionReady: adapterReady,
dryRunOnly: !adapterReady,
gatewayActor: {
userId: input.actorUserId || null,
email: input.actorEmail || null,
subject: input.actorSubject || null,
},
safety: {
noDeleteRouteMapped: true,
requiresExternalExecutor: true,
sourceGuardedRoutesOnly: true,
confirmationAlreadyChecked: decision.execution.confirmed || options.planPreview === true,
},
request: plan,
actionDecision: decision,
}
}

View File

@ -0,0 +1,303 @@
import { createHash } from 'node:crypto'
import { resolveAssistantAction } from '../assistant-action-resolver.mjs'
const OPS_PRODUCT_API_ADAPTER_ID = 'adapter.ops.product_api'
const OPS_ACTIONS = new Set([
'ops.card.list_recent',
'ops.card.create',
'ops.card.add_comment',
])
const OPS_CARD_PRIORITIES = new Set(['none', 'low', 'medium', 'high', 'urgent'])
function cleanString(value) {
return String(value || '').trim()
}
function cleanText(value, max = 4000) {
const text = cleanString(value)
if (!text) return ''
return text.length > max ? text.slice(0, max).trim() : text
}
function cleanLimit(value) {
const number = Number(value)
if (!Number.isFinite(number)) return 5
return Math.min(20, Math.max(1, Math.trunc(number)))
}
function firstString(...values) {
for (const value of values) {
const text = cleanString(value)
if (text) return text
}
return ''
}
function normalizeProjectQuery(input = {}) {
return {
workspaceSlug: firstString(
input.workspaceSlug,
input.workspace_slug,
input.opsWorkspaceSlug,
input.ops_workspace_slug,
input.context?.workspaceSlug,
input.context?.opsWorkspaceSlug,
),
projectId: firstString(
input.projectId,
input.project_id,
input.opsProjectId,
input.ops_project_id,
input.context?.projectId,
input.context?.opsProjectId,
),
}
}
function cleanPriority(value) {
const text = cleanString(value).toLowerCase()
return OPS_CARD_PRIORITIES.has(text) ? text : ''
}
function cleanIssueId(value) {
return cleanString(value).replace(/^#/, '')
}
function idempotencyKeyFor(actionId, input, fallbackParts = {}) {
const explicit = firstString(
input.idempotencyKey,
input.idempotency_key,
input.context?.idempotencyKey,
input.context?.idempotency_key,
)
if (explicit) return explicit
const digest = createHash('sha256')
.update(JSON.stringify({
actionId,
actorUserId: input.actorUserId || null,
actorEmail: input.actorEmail || null,
...fallbackParts,
}))
.digest('hex')
.slice(0, 24)
return `ai-workspace:${actionId}:${digest}`
}
function buildPlanForAction(actionId, input = {}, options = {}) {
switch (actionId) {
case 'ops.card.list_recent': {
const normalized = normalizeProjectQuery(input)
const workspaceSlug = normalized.workspaceSlug || cleanString(options.defaultOpsWorkspaceSlug)
const projectId = normalized.projectId || cleanString(options.defaultOpsProjectId)
const limit = cleanLimit(input.limit)
if (!projectId) {
return {
ok: false,
decision: 'ops_project_context_missing',
reason: 'OPS project id is required for reading cards.',
}
}
return {
ok: true,
request: {
method: 'GET',
path: '/api/v1/tools/issues',
query: {
project_id: projectId,
...(workspaceSlug ? { workspace_slug: workspaceSlug } : {}),
...(cleanString(input.query || input.search) ? { query: cleanString(input.query || input.search) } : {}),
},
summary: 'Read recent OPS Product cards through local Ops Gateway REST tool route.',
},
opsContext: {
workspaceSlug,
projectId,
limit,
},
}
}
case 'ops.card.create': {
const normalized = normalizeProjectQuery(input)
const workspaceSlug = normalized.workspaceSlug || cleanString(options.defaultOpsWorkspaceSlug)
const projectId = normalized.projectId || cleanString(options.defaultOpsProjectId)
const title = cleanText(
firstString(input.title, input.name, input.cardTitle, input.taskTitle, input.summary),
260,
)
const description = cleanText(
firstString(input.description, input.body, input.details, input.text, input.message),
12000,
)
const priority = cleanPriority(input.priority)
if (!projectId) {
return {
ok: false,
decision: 'ops_project_context_missing',
reason: 'OPS project id is required for creating a card.',
}
}
if (!title) {
return {
ok: false,
decision: 'ops_card_title_missing',
reason: 'OPS card title is required.',
}
}
const body = {
project_id: projectId,
...(workspaceSlug ? { workspace_slug: workspaceSlug } : {}),
title,
...(description ? { description } : {}),
...(priority ? { priority } : {}),
}
return {
ok: true,
request: {
method: 'POST',
path: '/api/v1/tools/issues',
headers: {
'Idempotency-Key': idempotencyKeyFor(actionId, input, body),
},
body,
summary: 'Create OPS Product card through local Ops Gateway REST tool route.',
},
opsContext: {
workspaceSlug,
projectId,
},
}
}
case 'ops.card.add_comment': {
const normalized = normalizeProjectQuery(input)
const workspaceSlug = normalized.workspaceSlug || cleanString(options.defaultOpsWorkspaceSlug)
const projectId = normalized.projectId || cleanString(options.defaultOpsProjectId)
const issueId = cleanIssueId(firstString(
input.issueId,
input.issue_id,
input.cardId,
input.card_id,
input.targetCardId,
input.target_card_id,
input.context?.issueId,
input.context?.issue_id,
input.context?.cardId,
input.context?.card_id,
))
const bodyText = cleanText(
firstString(input.body, input.comment, input.text, input.message),
12000,
)
if (!projectId) {
return {
ok: false,
decision: 'ops_project_context_missing',
reason: 'OPS project id is required for commenting on a card.',
}
}
if (!issueId) {
return {
ok: false,
decision: 'ops_issue_id_missing',
reason: 'OPS issue id is required for commenting on a card.',
}
}
if (!bodyText) {
return {
ok: false,
decision: 'ops_comment_body_missing',
reason: 'OPS comment body is required.',
}
}
const body = {
project_id: projectId,
...(workspaceSlug ? { workspace_slug: workspaceSlug } : {}),
body: bodyText,
}
return {
ok: true,
request: {
method: 'POST',
path: `/api/v1/tools/issues/${encodeURIComponent(issueId)}/comments`,
headers: {
'Idempotency-Key': idempotencyKeyFor(actionId, input, { issueId, ...body }),
},
body,
summary: 'Append comment to OPS Product card through local Ops Gateway REST tool route.',
},
opsContext: {
workspaceSlug,
projectId,
issueId,
},
}
}
default:
return {
ok: false,
decision: 'unsupported_ops_action',
reason: `Unsupported OPS action: ${actionId}`,
}
}
}
export async function buildOpsProductPlan(input = {}, options = {}) {
const decision = options.decision || await resolveAssistantAction(input, options)
const actionId = decision.action?.id
if (!actionId || !OPS_ACTIONS.has(actionId)) {
return {
ok: false,
decision: 'unsupported_ops_action',
reason: 'Input did not resolve to a supported OPS Product action.',
actionDecision: decision,
}
}
if (decision.decision !== 'allowed') {
return {
ok: false,
decision: decision.decision,
reason: decision.reason,
actionDecision: decision,
}
}
const actionPlan = buildPlanForAction(actionId, input, options)
if (!actionPlan.ok) {
return {
...actionPlan,
actionDecision: decision,
}
}
return {
ok: true,
decision: 'planned',
adapterId: OPS_PRODUCT_API_ADAPTER_ID,
adapterStatus: decision.action.adapterStatus,
executionReady: decision.execution?.executionReady === true,
dryRunOnly: decision.action.adapterStatus !== 'implemented',
actionDecision: decision,
gatewayActor: {
userId: input.actorUserId || null,
email: input.actorEmail || null,
subject: input.actorSubject || null,
},
request: actionPlan.request,
opsContext: actionPlan.opsContext,
safety: {
noDeleteRouteMapped: true,
sourceGuardedRoutesOnly: true,
confirmationAlreadyChecked: true,
},
}
}

View File

@ -0,0 +1,268 @@
import fs from 'node:fs/promises'
import { executeAssistantAction } from './assistant-action-executor.mjs'
import { validateCatalog } from './validate.mjs'
function actionFromResult(result) {
return result?.plan?.actionDecision?.action || null
}
function actorFromResult(result) {
return result?.plan?.actionDecision?.actor || null
}
function requestFromResult(result) {
return result?.plan?.request || null
}
function executionStateFromResult(result) {
const action = actionFromResult(result)
const request = requestFromResult(result)
const confirmationEnvelope = result?.confirmationEnvelope || null
return {
adapterId: result?.plan?.adapterId || action?.adapterId || null,
adapterStatus: result?.plan?.adapterStatus || action?.adapterStatus || null,
executionReady: Boolean(result?.plan?.executionReady),
requiresUserConfirmation: Boolean(confirmationEnvelope),
requiresInternalToken: Boolean(result?.plan?.adapterId === 'adapter.hub.launcher_admin_api'),
method: request?.method || null,
path: request?.path || null,
idempotencyKey: request?.headers?.['Idempotency-Key'] || request?.headers?.['idempotency-key'] || null,
}
}
function previewFromDryRun(result) {
const action = actionFromResult(result)
const actor = actorFromResult(result)
const request = requestFromResult(result)
const confirmationEnvelope = result?.confirmationEnvelope || null
return {
ok: Boolean(result?.ok),
phase: 'preview',
decision: result?.ok ? 'preview_ready' : result?.decision || 'preview_blocked',
reason: result?.reason || null,
action,
actor,
preview: {
app: action?.app || null,
actionId: action?.id || null,
riskLevel: action?.riskLevel || null,
confirmationMode: action?.confirmationMode || null,
expectedEffect: confirmationEnvelope?.expectedEffect || action?.summary || request?.summary || null,
request: request ? {
method: request.method,
path: request.path,
query: request.query || null,
body: request.body ?? null,
} : null,
safeAlternatives: result?.plan?.actionDecision?.safeAlternatives || [],
},
confirmation: confirmationEnvelope ? {
token: confirmationEnvelope.token,
version: confirmationEnvelope.version,
expectedEffect: confirmationEnvelope.expectedEffect,
summary: confirmationEnvelope.summary,
request: confirmationEnvelope.request,
} : null,
execution: executionStateFromResult(result),
raw: result,
}
}
function executeResponseFromResult(result) {
const action = actionFromResult(result)
const request = requestFromResult(result)
return {
ok: Boolean(result?.ok),
phase: 'execute',
decision: result?.decision || 'execution_unknown',
reason: result?.reason || null,
action,
execution: executionStateFromResult(result),
request: request ? {
method: request.method,
path: request.path,
query: request.query || null,
body: request.body ?? null,
} : null,
result: result?.result || null,
confirmation: result?.confirmationEnvelope ? {
token: result.confirmationEnvelope.token,
version: result.confirmationEnvelope.version,
expectedEffect: result.confirmationEnvelope.expectedEffect,
} : null,
raw: result,
}
}
export async function previewAssistantAction(input = {}, options = {}) {
// Preview must build the write plan and confirmation envelope without doing I/O.
// The actual execute phase still requires the returned confirmation token.
const dryRun = await executeAssistantAction({
...input,
confirmed: true,
}, {
...options,
planPreview: true,
mode: 'dry-run',
allowNetworkExecution: false,
})
return previewFromDryRun(dryRun)
}
export async function executeAssistantCallerAction(input = {}, options = {}) {
const executed = await executeAssistantAction({
...input,
confirmed: true,
}, {
...options,
mode: 'execute',
allowNetworkExecution: true,
confirmationToken: options.confirmationToken || input.confirmationToken || input.confirmation?.token || null,
})
return executeResponseFromResult(executed)
}
export async function handleAssistantCallerRequest(payload = {}, options = {}) {
const phase = payload.phase || payload.mode || 'preview'
const input = payload.input || payload
if (phase === 'preview' || phase === 'dry-run') {
return previewAssistantAction(input, options)
}
if (phase === 'execute') {
return executeAssistantCallerAction(input, {
...options,
confirmationToken: payload.confirmationToken || input.confirmationToken || options.confirmationToken || null,
})
}
return {
ok: false,
phase,
decision: 'caller_phase_unknown',
reason: `Unsupported assistant caller phase: ${phase}`,
}
}
async function readInputJson() {
const inputIndex = process.argv.indexOf('--input-json')
if (inputIndex >= 0) {
const inputPath = process.argv[inputIndex + 1]
if (!inputPath) throw new Error('--input-json requires a path')
return JSON.parse(await fs.readFile(inputPath, 'utf8'))
}
const inlineIndex = process.argv.indexOf('--input')
if (inlineIndex >= 0) {
return JSON.parse(process.argv[inlineIndex + 1] || '{}')
}
return null
}
function readArg(name) {
const index = process.argv.indexOf(name)
if (index === -1) return null
return process.argv[index + 1] || null
}
function assertSmoke(condition, message) {
if (!condition) throw new Error(message)
}
async function runSmoke() {
const validation = await validateCatalog()
assertSmoke(validation.ok, 'catalog must validate before caller smoke')
const writeInput = {
actionId: 'hub.user.block',
assistantRole: 'admin',
membershipRole: 'client_admin',
actorUserId: 'user_root',
targetUserId: 'user_pupa',
confirmed: true,
idempotencyKey: 'smoke:caller:block:user_pupa',
}
const preview = await previewAssistantAction(writeInput)
const executeWithoutToken = await executeAssistantCallerAction(writeInput, {
baseUrl: 'http://launcher.local.test',
launcherInternalToken: 'smoke-token',
fetchImpl: async () => {
throw new Error('fetch must not run without confirmation token')
},
})
const mockCalls = []
const executeWithToken = await executeAssistantCallerAction({
...writeInput,
confirmationToken: preview.confirmation?.token,
}, {
baseUrl: 'http://launcher.local.test',
launcherInternalToken: 'smoke-token',
fetchImpl: async (url, init) => {
mockCalls.push({ url: String(url), init })
return {
ok: true,
status: 200,
statusText: 'OK',
headers: { get: () => 'application/json' },
text: async () => JSON.stringify({ ok: true, source: 'mock-launcher' }),
}
},
})
const readPreview = await previewAssistantAction({
actionId: 'hub.user.read_admin_summary',
assistantRole: 'admin',
groups: ['nodedc:superadmin'],
actorUserId: 'user_root',
})
assertSmoke(preview.decision === 'preview_ready', 'write preview should be ready')
assertSmoke(Boolean(preview.confirmation?.token), 'write preview should include confirmation token')
assertSmoke(preview.execution.requiresUserConfirmation, 'write preview should require user confirmation')
assertSmoke(executeWithoutToken.reason === 'write_confirmation_envelope_missing', 'execute without token must be blocked')
assertSmoke(executeWithToken.decision === 'executed', 'execute with token should call adapter')
assertSmoke(mockCalls.length === 1, 'execute with token should issue one request')
assertSmoke(readPreview.decision === 'preview_ready', 'read preview should be ready')
assertSmoke(readPreview.confirmation === null, 'read preview should not require confirmation token')
return {
ok: true,
validation: validation.counts,
cases: {
preview,
executeWithoutToken,
executeWithToken,
readPreview,
},
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
if (process.argv.includes('--smoke')) {
console.log(JSON.stringify(await runSmoke(), null, 2))
} else {
const payload = await readInputJson()
if (!payload) throw new Error('Use --smoke, --input <json>, or --input-json <path>')
const output = await handleAssistantCallerRequest(payload, {
baseUrl: readArg('--base-url') || process.env.NDC_LAUNCHER_BASE_URL || null,
confirmationToken: readArg('--confirmation-token') || null,
launcherInternalToken:
readArg('--launcher-internal-token') ||
readArg('--internal-token') ||
process.env.NDC_LAUNCHER_INTERNAL_ACCESS_TOKEN ||
process.env.NODEDC_INTERNAL_ACCESS_TOKEN ||
process.env.NODEDC_PLATFORM_SERVICE_TOKEN ||
null,
})
console.log(JSON.stringify(output, null, 2))
}
}

View File

@ -0,0 +1,994 @@
import fs from 'node:fs/promises'
import { createHash } from 'node:crypto'
import { buildHubLauncherAdminPlan } from './adapters/hub-launcher-admin.mjs'
import { buildOpsProductPlan } from './adapters/ops-product-api.mjs'
import { resolveAssistantAction } from './assistant-action-resolver.mjs'
import { validateCatalog } from './validate.mjs'
const WRITE_METHODS = new Set(['PATCH', 'POST', 'PUT'])
const HUB_LAUNCHER_ADMIN_ADAPTER_ID = 'adapter.hub.launcher_admin_api'
const OPS_PRODUCT_API_ADAPTER_ID = 'adapter.ops.product_api'
const ASSISTANT_GATEWAY_NAME = 'ontology-core'
const CONFIRMATION_ENVELOPE_VERSION = 'assistant-write-confirmation-v1'
const ALLOWED_ROUTES = [
{ method: 'GET', pattern: /^\/api\/admin\/control-plane$/ },
{ method: 'PATCH', pattern: /^\/api\/admin\/users\/[^/?#]+\/profile$/ },
{ method: 'PATCH', pattern: /^\/api\/admin\/memberships\/[^/?#]+$/ },
]
const OPS_ALLOWED_ROUTES = [
{ method: 'GET', pattern: /^\/api\/v1\/tools\/issues$/ },
{ method: 'POST', pattern: /^\/api\/v1\/tools\/issues$/ },
{ method: 'POST', pattern: /^\/api\/v1\/tools\/issues\/[^/?#]+\/comments$/ },
]
function headerValue(headers, key) {
const wanted = key.toLowerCase()
for (const [candidateKey, value] of Object.entries(headers || {})) {
if (candidateKey.toLowerCase() === wanted) return value
}
return null
}
function normalizeMethod(method) {
return String(method || '').trim().toUpperCase()
}
function isAllowedRoute(method, path) {
return ALLOWED_ROUTES.some((route) => route.method === method && route.pattern.test(path))
}
function isAllowedOpsRoute(method, path) {
return OPS_ALLOWED_ROUTES.some((route) => route.method === method && route.pattern.test(path))
}
function appendQuery(url, query = {}) {
for (const [key, value] of Object.entries(query || {})) {
if (value === null || value === undefined || value === '') continue
url.searchParams.set(key, String(value))
}
}
function buildRequestUrl(baseUrl, request) {
if (!baseUrl) throw new Error('baseUrl is required for network execution')
const url = new URL(request.path, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`)
appendQuery(url, request.query)
return url
}
function readResponseHeader(headers, key) {
if (!headers) return ''
if (typeof headers.get === 'function') return headers.get(key) || ''
return headerValue(headers, key) || ''
}
function normalizeHeaderString(value) {
return String(value || '').trim()
}
function stableJson(value) {
if (Array.isArray(value)) {
return `[${value.map((item) => stableJson(item)).join(',')}]`
}
if (value && typeof value === 'object') {
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
.join(',')}}`
}
return JSON.stringify(value)
}
function confirmationDigest(payload) {
return createHash('sha256').update(stableJson(payload)).digest('hex').slice(0, 24)
}
function buildWriteConfirmationEnvelope(plan) {
const request = plan?.request || {}
const method = normalizeMethod(request.method)
if (!WRITE_METHODS.has(method)) return null
const action = plan?.actionDecision?.action || {}
const actor = plan?.gatewayActor || {}
const payload = {
version: CONFIRMATION_ENVELOPE_VERSION,
actionId: action.id || null,
app: action.app || null,
adapterId: plan?.adapterId || null,
actor: {
userId: actor.userId || null,
email: actor.email || null,
subject: actor.subject || null,
},
request: {
method,
path: String(request.path || ''),
body: request.body ?? null,
idempotencyKey: headerValue(request.headers, 'Idempotency-Key') || null,
},
}
return {
...payload,
token: confirmationDigest(payload),
riskLevel: action.riskLevel || null,
confirmationMode: action.confirmationMode || null,
expectedEffect: action.summary || request.summary || null,
summary: request.summary || null,
}
}
function readConfirmationToken(input = {}, options = {}) {
return normalizeHeaderString(
options.confirmationToken ||
input.confirmationToken ||
input.confirmation?.token ||
input.confirmationEnvelope?.token,
)
}
function verifyWriteConfirmationEnvelope(input, options, envelope) {
if (!envelope) return { ok: true }
const providedToken = readConfirmationToken(input, options)
if (!providedToken) {
return {
ok: false,
reason: 'write_confirmation_envelope_missing',
}
}
if (providedToken !== envelope.token) {
return {
ok: false,
reason: 'write_confirmation_envelope_mismatch',
}
}
return { ok: true }
}
function buildBearerHeader(token) {
const value = normalizeHeaderString(token)
if (!value) return ''
return /^Bearer\s+/i.test(value) ? value : `Bearer ${value}`
}
function buildHubLauncherGatewayHeaders(plan, options = {}) {
if (plan?.adapterId !== HUB_LAUNCHER_ADMIN_ADAPTER_ID) {
return { ok: true, headers: {} }
}
const token = normalizeHeaderString(options.launcherInternalToken || options.internalAccessToken)
if (!token) {
return {
ok: false,
reason: 'launcher_internal_token_missing',
}
}
const actor = options.gatewayActor || plan.gatewayActor || {}
const actorUserId = normalizeHeaderString(actor.userId)
const actorEmail = normalizeHeaderString(actor.email)
const actorSubject = normalizeHeaderString(actor.subject)
if (!actorUserId && !actorEmail && !actorSubject) {
return {
ok: false,
reason: 'assistant_gateway_actor_missing',
}
}
return {
ok: true,
headers: {
Authorization: buildBearerHeader(token),
'X-NODEDC-Assistant-Gateway': ASSISTANT_GATEWAY_NAME,
...(actorUserId ? { 'X-NODEDC-Assistant-Actor-User-Id': actorUserId } : {}),
...(actorEmail ? { 'X-NODEDC-Assistant-Actor-Email': actorEmail } : {}),
...(actorSubject ? { 'X-NODEDC-Assistant-Actor-Subject': actorSubject } : {}),
},
}
}
function endpointOrigin(value) {
try {
return new URL(value).origin
} catch {
return ''
}
}
function firstArray(...values) {
for (const value of values) {
if (Array.isArray(value)) return value
}
return []
}
function entitlementOpsGrant(payload) {
const grants = payload?.appGrants || payload?.grant || payload?.appGrant || payload?.entitlement || payload?.entitlements
if (grants?.ops && typeof grants.ops === 'object') return grants.ops
if (payload?.ops && typeof payload.ops === 'object') return payload.ops
return null
}
function authorizationFromOpsGrant(grant) {
const servers = firstArray(grant?.mcpServers, grant?.mcp_servers)
for (const server of servers) {
const headers = server?.httpHeaders || server?.headers || {}
const authorization = normalizeHeaderString(headerValue(headers, 'Authorization'))
if (authorization) return authorization
}
return ''
}
function omitEmptyObject(value = {}) {
const out = {}
for (const [key, raw] of Object.entries(value || {})) {
const text = normalizeHeaderString(raw)
if (text) out[key] = text
}
return out
}
async function resolveOpsAuthorization(plan, options = {}) {
const explicit = buildBearerHeader(options.opsRunToken || options.opsGatewayToken)
if (explicit) return { ok: true, authorization: explicit }
const entitlementUrl = normalizeHeaderString(options.opsEntitlementUrl)
const entitlementAuthorization = normalizeHeaderString(
options.opsEntitlementAuthorization || buildBearerHeader(options.opsEntitlementToken),
)
if (!entitlementUrl || !entitlementAuthorization) {
return {
ok: false,
reason: 'ops_entitlement_not_configured',
}
}
const actor = options.gatewayActor || plan.gatewayActor || {}
const opsContext = plan.opsContext || {}
const fetchImpl = options.fetchImpl || globalThis.fetch
if (typeof fetchImpl !== 'function') {
throw new Error('No fetch implementation available')
}
const owner = omitEmptyObject({
userId: actor.userId,
email: actor.email,
ownerKey: actor.subject,
})
const ops = omitEmptyObject({
workspaceSlug: opsContext.workspaceSlug,
projectId: opsContext.projectId,
})
const response = await fetchImpl(entitlementUrl, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: entitlementAuthorization,
},
body: JSON.stringify({
schemaVersion: 'ai-workspace.entitlement-request.v1',
appId: 'ops',
owner,
runContext: {
...(Object.keys(ops).length ? { ops } : {}),
...omitEmptyObject({
opsWorkspaceSlug: opsContext.workspaceSlug,
opsProjectId: opsContext.projectId,
}),
},
activeContext: {},
requestedAt: new Date().toISOString(),
}),
})
const payload = await readResponsePayload(response)
if (!response.ok || payload?.ok === false) {
return {
ok: false,
reason: payload?.error || payload?.message || `ops_entitlement_http_${response.status}`,
}
}
const grant = entitlementOpsGrant(payload)
const authorization = authorizationFromOpsGrant(grant)
if (!authorization) {
return {
ok: false,
reason: grant?.status || 'ops_entitlement_token_missing',
grantStatus: grant?.status || null,
grantContext: grant?.context || null,
}
}
return { ok: true, authorization }
}
async function buildGatewayHeaders(plan, options = {}) {
if (plan?.adapterId === HUB_LAUNCHER_ADMIN_ADAPTER_ID) {
return buildHubLauncherGatewayHeaders(plan, options)
}
if (plan?.adapterId === OPS_PRODUCT_API_ADAPTER_ID) {
const resolved = await resolveOpsAuthorization(plan, options)
if (!resolved.ok) return resolved
return {
ok: true,
headers: {
Authorization: resolved.authorization,
Accept: 'application/json',
},
}
}
return { ok: true, headers: {} }
}
async function readResponsePayload(response) {
const raw = typeof response.text === 'function' ? await response.text() : ''
const contentType = readResponseHeader(response.headers, 'content-type')
if (!raw) return null
if (contentType.includes('application/json')) {
try {
return JSON.parse(raw)
} catch {
return raw
}
}
return raw
}
function planActionId(plan) {
return String(plan?.actionDecision?.action?.id || '').trim()
}
function dataArray(payload, key) {
const value = payload?.data?.[key]
return Array.isArray(value) ? value : []
}
function isPendingLikeStatus(value) {
const status = String(value || '').trim().toLowerCase()
return ['new', 'pending', 'requested', 'open', 'waiting', 'submitted'].includes(status)
}
function compactAccessRequest(item = {}) {
return {
id: item.id || null,
email: item.email || null,
name: [item.lastName, item.firstName, item.middleName].filter(Boolean).join(' ').trim() || null,
company: item.company || null,
phone: item.phone || null,
status: item.status || null,
targetClientId: item.targetClientId || null,
role: item.role || null,
createdAt: item.createdAt || null,
updatedAt: item.updatedAt || null,
}
}
function compactInvite(item = {}) {
return {
id: item.id || null,
email: item.email || item.inviteeEmail || null,
status: item.status || null,
clientId: item.clientId || item.targetClientId || null,
role: item.role || null,
createdAt: item.createdAt || null,
updatedAt: item.updatedAt || null,
}
}
function compactUser(item = {}) {
return {
id: item.id || null,
name: item.name || null,
email: item.email || null,
globalStatus: item.globalStatus || null,
}
}
function compactMembership(item = {}) {
return {
id: item.id || null,
clientId: item.clientId || null,
userId: item.userId || null,
role: item.role || null,
status: item.status || null,
coreAssistantRole: item.coreAssistantRole || null,
}
}
function firstIssueArray(payload) {
return firstArray(
payload?.issues,
payload?.items,
payload?.results,
payload?.data?.issues,
payload?.data?.items,
payload?.data?.results,
Array.isArray(payload?.data) ? payload.data : null,
)
}
function firstObject(...values) {
for (const value of values) {
if (value && typeof value === 'object' && !Array.isArray(value)) return value
}
return null
}
function issueTimestamp(item = {}) {
const value = item.updatedAt || item.updated_at || item.createdAt || item.created_at || item.created || item.updated
const time = Date.parse(value)
return Number.isFinite(time) ? time : 0
}
function compactIssue(item = {}) {
const state = item.state && typeof item.state === 'object' ? item.state : null
const project = item.project && typeof item.project === 'object' ? item.project : null
return {
id: item.id || item.issue_id || null,
identifier: item.identifier || item.sequence_id || item.issueIdentifier || item.issue_identifier || null,
title: item.title || item.name || item.subject || null,
description: item.description || null,
state: state?.name || item.stateName || item.state_name || item.state || null,
priority: item.priority || null,
projectId: item.project_id || item.projectId || project?.id || null,
projectName: item.project_name || item.projectName || project?.name || null,
createdAt: item.createdAt || item.created_at || item.created || null,
updatedAt: item.updatedAt || item.updated_at || item.updated || null,
url: item.url || item.web_url || item.webUrl || null,
}
}
function compactComment(item = {}) {
return {
id: item.id || item.comment_id || item.commentId || null,
issueId: item.issue_id || item.issueId || item.issue?.id || null,
body: item.body || item.comment || item.text || null,
createdAt: item.createdAt || item.created_at || item.created || null,
updatedAt: item.updatedAt || item.updated_at || item.updated || null,
}
}
function normalizeOpsPayload(plan, payload) {
if (!payload || typeof payload !== 'object') return payload
const actionId = planActionId(plan)
if (actionId === 'ops.card.create') {
const issue = firstObject(
payload.issue,
payload.card,
payload.data?.issue,
payload.data?.card,
payload.data,
)
return {
card: issue ? compactIssue(issue) : null,
source: 'ops-agent-gateway',
context: plan.opsContext || null,
rawOk: payload.ok ?? null,
}
}
if (actionId === 'ops.card.add_comment') {
const comment = firstObject(
payload.comment,
payload.issueComment,
payload.data?.comment,
payload.data?.issueComment,
payload.data,
)
return {
comment: comment ? compactComment(comment) : null,
source: 'ops-agent-gateway',
context: plan.opsContext || null,
rawOk: payload.ok ?? null,
}
}
if (actionId !== 'ops.card.list_recent') return payload
const limit = Number(plan?.opsContext?.limit || 5)
const issues = firstIssueArray(payload)
.slice()
.sort((a, b) => issueTimestamp(b) - issueTimestamp(a))
.slice(0, Math.max(1, Math.min(20, Number.isFinite(limit) ? limit : 5)))
.map(compactIssue)
return {
counts: {
cards: issues.length,
},
latestCard: issues[0] || null,
cards: issues,
source: 'ops-agent-gateway',
context: plan.opsContext || null,
}
}
function normalizeExecutionPayload(plan, payload) {
if (plan?.adapterId === OPS_PRODUCT_API_ADAPTER_ID) {
return normalizeOpsPayload(plan, payload)
}
return normalizeControlPlanePayload(plan, payload)
}
function normalizeControlPlanePayload(plan, payload) {
if (!payload || typeof payload !== 'object') return payload
const actionId = planActionId(plan)
const counts = payload.counts && typeof payload.counts === 'object' ? payload.counts : {}
const accessRequests = dataArray(payload, 'accessRequests')
const pendingAccessRequests = accessRequests.filter((item) => isPendingLikeStatus(item?.status))
const engineRequests = dataArray(payload, 'engineWorkflowAccessRequests')
const pendingEngineRequests = engineRequests.filter((item) => isPendingLikeStatus(item?.status))
const invites = dataArray(payload, 'invites')
const pendingInvites = invites.filter((item) => isPendingLikeStatus(item?.status))
if (actionId === 'hub.access_request.list_pending') {
return {
actor: payload.actor || null,
counts: {
accessRequests: counts.accessRequests ?? accessRequests.length,
engineWorkflowAccessRequests: counts.engineWorkflowAccessRequests ?? engineRequests.length,
},
summary: {
accessRequests: accessRequests.length,
pendingAccessRequests: pendingAccessRequests.length,
engineWorkflowAccessRequests: engineRequests.length,
pendingEngineWorkflowAccessRequests: pendingEngineRequests.length,
newAccessRequests: pendingAccessRequests.map((item) => item.email).filter(Boolean),
},
accessRequests: pendingAccessRequests.map(compactAccessRequest),
engineWorkflowAccessRequests: pendingEngineRequests.map(compactAccessRequest),
}
}
if (actionId === 'hub.invite.list_pending') {
return {
actor: payload.actor || null,
counts: {
invites: counts.invites ?? invites.length,
},
summary: {
invites: invites.length,
pendingInvites: pendingInvites.length,
pendingInviteEmails: pendingInvites.map((item) => item.email || item.inviteeEmail).filter(Boolean),
},
invites: pendingInvites.map(compactInvite),
}
}
if (actionId === 'hub.user.read_admin_summary') {
return {
actor: payload.actor || null,
counts,
summary: {
users: counts.users ?? dataArray(payload, 'users').length,
memberships: counts.memberships ?? dataArray(payload, 'memberships').length,
services: counts.services ?? dataArray(payload, 'services').length,
grants: counts.grants ?? dataArray(payload, 'grants').length,
invites: counts.invites ?? invites.length,
accessRequests: counts.accessRequests ?? accessRequests.length,
pendingAccessRequests: pendingAccessRequests.length,
newAccessRequests: pendingAccessRequests.map((item) => item.email).filter(Boolean),
},
users: dataArray(payload, 'users').map(compactUser),
memberships: dataArray(payload, 'memberships').map(compactMembership),
accessRequests: pendingAccessRequests.map(compactAccessRequest),
}
}
return payload
}
export function validateExecutionPlan(plan) {
const errors = []
const warnings = []
const request = plan?.request || {}
const method = normalizeMethod(request.method)
const path = String(request.path || '')
const adapterId = plan?.adapterId || plan?.actionDecision?.action?.adapterId || ''
if (!plan?.ok) {
errors.push('plan_not_ok')
}
if (!method) {
errors.push('request_method_missing')
}
if (method === 'DELETE') {
errors.push('delete_method_forbidden')
}
if (adapterId === OPS_PRODUCT_API_ADAPTER_ID) {
if (!path.startsWith('/api/v1/tools/')) {
errors.push('request_path_not_ops_tools_api')
}
if (!['GET', 'POST'].includes(method)) {
errors.push(`method_not_allowed:${method || 'empty'}`)
}
if (method && path && !isAllowedOpsRoute(method, path)) {
errors.push(`route_not_allowlisted:${method} ${path}`)
}
if (method === 'GET' && !request.query?.project_id) {
errors.push('ops_project_id_missing')
}
if (method === 'POST' && !request.body?.project_id) {
errors.push('ops_project_id_missing')
}
} else {
if (!path.startsWith('/api/admin/')) {
errors.push('request_path_not_admin_api')
}
if (!['GET', 'PATCH'].includes(method)) {
errors.push(`method_not_allowed:${method || 'empty'}`)
}
if (method && path && !isAllowedRoute(method, path)) {
errors.push(`route_not_allowlisted:${method} ${path}`)
}
}
if (WRITE_METHODS.has(method) && !headerValue(request.headers, 'Idempotency-Key')) {
errors.push('write_requires_idempotency_key')
}
if (WRITE_METHODS.has(method) && plan?.safety?.confirmationAlreadyChecked !== true) {
errors.push('write_requires_prior_confirmation_check')
}
if (plan?.actionDecision?.action?.riskLevel === 'destructive') {
errors.push('destructive_action_forbidden')
}
if (plan?.actionDecision?.decision === 'forbidden') {
errors.push('forbidden_action_decision')
}
if (plan?.safety?.noDeleteRouteMapped !== true) {
warnings.push('no_delete_route_mapping_not_asserted')
}
if (plan?.safety?.sourceGuardedRoutesOnly !== true) {
warnings.push('source_guarded_routes_only_not_asserted')
}
return {
ok: errors.length === 0,
errors,
warnings,
method,
path,
}
}
async function executeHttpPlan(plan, options) {
const request = plan.request
const method = normalizeMethod(request.method)
const url = buildRequestUrl(options.baseUrl, request)
const headers = {
...(request.headers || {}),
...(options.headers || {}),
}
const init = {
method,
headers,
}
if (method !== 'GET' && request.body !== undefined) {
if (!headerValue(headers, 'Content-Type')) {
headers['Content-Type'] = 'application/json'
}
if (!headerValue(headers, 'Accept')) {
headers.Accept = 'application/json'
}
init.body = JSON.stringify(request.body)
}
const fetchImpl = options.fetchImpl || globalThis.fetch
if (typeof fetchImpl !== 'function') {
throw new Error('No fetch implementation available')
}
const response = await fetchImpl(url, init)
const payload = normalizeExecutionPayload(plan, await readResponsePayload(response))
return {
ok: Boolean(response.ok),
status: response.status,
statusText: response.statusText || '',
url: String(url),
method,
payload,
}
}
async function buildAssistantExecutionPlan(input = {}, options = {}) {
const decision = options.decision || await resolveAssistantAction(input, options)
const adapterId = decision?.action?.adapterId
if (adapterId === OPS_PRODUCT_API_ADAPTER_ID) {
return buildOpsProductPlan(input, { ...options, decision })
}
return buildHubLauncherAdminPlan(input, { ...options, decision })
}
function executionBaseUrlForPlan(plan, options = {}) {
if (plan?.adapterId === OPS_PRODUCT_API_ADAPTER_ID) {
return normalizeHeaderString(options.opsGatewayBaseUrl) || endpointOrigin(options.opsEntitlementUrl)
}
return normalizeHeaderString(options.baseUrl)
}
export async function executeAssistantAction(input = {}, options = {}) {
const mode = options.mode || 'dry-run'
const plan = options.plan || await buildAssistantExecutionPlan(input, options)
const safety = validateExecutionPlan(plan)
const confirmationEnvelope = buildWriteConfirmationEnvelope(plan)
if (!safety.ok) {
return {
ok: false,
decision: 'execution_blocked',
reason: 'execution_safety_check_failed',
safety,
confirmationEnvelope,
plan,
}
}
if (mode !== 'execute') {
return {
ok: true,
decision: 'dry_run',
reason: 'network_execution_not_requested',
safety,
confirmationEnvelope,
plan,
result: null,
}
}
if (!options.allowNetworkExecution) {
return {
ok: false,
decision: 'execution_blocked',
reason: 'network_execution_not_enabled',
safety,
confirmationEnvelope,
plan,
}
}
if ((plan.dryRunOnly || plan.adapterStatus !== 'implemented') && !options.allowPlannedAdapterExecution) {
return {
ok: false,
decision: 'execution_blocked',
reason: 'adapter_not_marked_implemented',
safety,
confirmationEnvelope,
plan,
}
}
const baseUrl = executionBaseUrlForPlan(plan, options)
if (!baseUrl) {
return {
ok: false,
decision: 'execution_blocked',
reason: plan?.adapterId === OPS_PRODUCT_API_ADAPTER_ID ? 'ops_gateway_base_url_missing' : 'base_url_missing',
safety,
confirmationEnvelope,
plan,
}
}
const confirmationCheck = verifyWriteConfirmationEnvelope(input, options, confirmationEnvelope)
if (!confirmationCheck.ok) {
return {
ok: false,
decision: 'execution_blocked',
reason: confirmationCheck.reason,
safety,
confirmationEnvelope,
plan,
}
}
const gatewayHeaders = await buildGatewayHeaders(plan, options)
if (!gatewayHeaders.ok) {
return {
ok: false,
decision: 'execution_blocked',
reason: gatewayHeaders.reason,
safety,
confirmationEnvelope,
plan,
}
}
const result = await executeHttpPlan(plan, {
...options,
baseUrl,
headers: {
...(options.headers || {}),
...gatewayHeaders.headers,
},
})
return {
ok: result.ok,
decision: result.ok ? 'executed' : 'execution_failed',
safety,
confirmationEnvelope,
plan,
result,
}
}
async function readInputJson() {
const inputIndex = process.argv.indexOf('--input-json')
if (inputIndex >= 0) {
const inputPath = process.argv[inputIndex + 1]
if (!inputPath) throw new Error('--input-json requires a path')
return JSON.parse(await fs.readFile(inputPath, 'utf8'))
}
const inlineIndex = process.argv.indexOf('--input')
if (inlineIndex >= 0) {
return JSON.parse(process.argv[inlineIndex + 1] || '{}')
}
return null
}
function readArg(name) {
const index = process.argv.indexOf(name)
if (index === -1) return null
return process.argv[index + 1] || null
}
function assertSmoke(condition, message) {
if (!condition) throw new Error(message)
}
async function runSmoke() {
const validation = await validateCatalog()
assertSmoke(validation.ok, 'catalog must validate before executor smoke')
const blockInput = {
actionId: 'hub.user.block',
assistantRole: 'admin',
membershipRole: 'client_admin',
actorUserId: 'user_root',
targetUserId: 'user_pupa',
confirmed: true,
idempotencyKey: 'smoke:execute:block:user_pupa',
}
const dryRun = await executeAssistantAction(blockInput)
const executeWithoutConfirmation = await executeAssistantAction(blockInput, {
mode: 'execute',
allowNetworkExecution: true,
baseUrl: 'http://launcher.local.test',
})
const confirmedBlockInput = {
...blockInput,
confirmationToken: dryRun.confirmationEnvelope?.token,
}
const executeBlocked = await executeAssistantAction(confirmedBlockInput, {
mode: 'execute',
allowNetworkExecution: true,
baseUrl: 'http://launcher.local.test',
})
const mockCalls = []
const mockFetch = async (url, init) => {
mockCalls.push({ url: String(url), init })
return {
ok: true,
status: 200,
statusText: 'OK',
headers: { get: () => 'application/json' },
text: async () => JSON.stringify({ ok: true, source: 'mock-launcher' }),
}
}
const mockExecuted = await executeAssistantAction(confirmedBlockInput, {
mode: 'execute',
allowNetworkExecution: true,
baseUrl: 'http://launcher.local.test',
launcherInternalToken: 'smoke-token',
fetchImpl: mockFetch,
})
const deleteBlocked = await executeAssistantAction({
actionId: 'hub.user.delete',
assistantRole: 'admin',
membershipRole: 'client_admin',
actorUserId: 'user_root',
targetUserId: 'user_pupa',
confirmed: true,
}, {
mode: 'execute',
allowNetworkExecution: true,
allowPlannedAdapterExecution: true,
baseUrl: 'http://launcher.local.test',
fetchImpl: mockFetch,
})
const badDeletePlanSafety = validateExecutionPlan({
ok: true,
adapterStatus: 'implemented',
dryRunOnly: false,
safety: {
noDeleteRouteMapped: false,
sourceGuardedRoutesOnly: false,
confirmationAlreadyChecked: true,
},
actionDecision: {
decision: 'allowed',
action: { riskLevel: 'privileged' },
},
request: {
method: 'DELETE',
path: '/api/admin/users/user_pupa',
headers: { 'Idempotency-Key': 'bad-delete' },
},
})
assertSmoke(dryRun.decision === 'dry_run', 'default executor mode must be dry_run')
assertSmoke(Boolean(dryRun.confirmationEnvelope?.token), 'dry-run write should return confirmation envelope token')
assertSmoke(executeWithoutConfirmation.reason === 'write_confirmation_envelope_missing', 'write execution must require confirmation envelope token')
assertSmoke(executeBlocked.reason === 'launcher_internal_token_missing', 'confirmed implemented gateway adapter must require an internal token')
assertSmoke(mockExecuted.decision === 'executed', 'mock execution should execute with gateway auth headers')
assertSmoke(mockCalls.length === 1, 'mock execution should issue exactly one request')
assertSmoke(mockCalls[0].init.method === 'PATCH', 'mock execution should use PATCH')
assertSmoke(mockCalls[0].url === 'http://launcher.local.test/api/admin/users/user_pupa/profile', 'mock execution should hit profile route')
assertSmoke(mockCalls[0].init.headers.Authorization === 'Bearer smoke-token', 'mock execution should send internal bearer token')
assertSmoke(mockCalls[0].init.headers['X-NODEDC-Assistant-Gateway'] === ASSISTANT_GATEWAY_NAME, 'mock execution should send assistant gateway header')
assertSmoke(mockCalls[0].init.headers['X-NODEDC-Assistant-Actor-User-Id'] === 'user_root', 'mock execution should send actor user id')
assertSmoke(deleteBlocked.decision === 'execution_blocked', 'delete action must block before network execution')
assertSmoke(!badDeletePlanSafety.ok && badDeletePlanSafety.errors.includes('delete_method_forbidden'), 'DELETE plan must fail safety check')
return {
ok: true,
validation: validation.counts,
cases: {
dryRun,
executeWithoutConfirmation,
executeBlocked,
mockExecuted,
deleteBlocked,
badDeletePlanSafety,
},
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
if (process.argv.includes('--smoke')) {
console.log(JSON.stringify(await runSmoke(), null, 2))
} else {
const input = await readInputJson()
if (!input) throw new Error('Use --smoke, --input <json>, or --input-json <path>')
const allowPlannedAdapterExecution =
process.argv.includes('--allow-planned-adapter-execution') &&
process.env.NDC_ASSISTANT_EXECUTOR_UNSAFE_DEV === '1'
const output = await executeAssistantAction(input, {
mode: process.argv.includes('--execute') ? 'execute' : 'dry-run',
allowNetworkExecution: process.argv.includes('--execute'),
allowPlannedAdapterExecution,
baseUrl: readArg('--base-url') || process.env.NDC_LAUNCHER_BASE_URL || null,
confirmationToken: readArg('--confirmation-token') || null,
launcherInternalToken:
readArg('--launcher-internal-token') ||
readArg('--internal-token') ||
process.env.NDC_LAUNCHER_INTERNAL_ACCESS_TOKEN ||
process.env.NODEDC_INTERNAL_ACCESS_TOKEN ||
process.env.NODEDC_PLATFORM_SERVICE_TOKEN ||
null,
})
console.log(JSON.stringify(output, null, 2))
}
}

View File

@ -0,0 +1,78 @@
import fs from 'node:fs/promises'
import { buildHubLauncherAdminPlan } from './adapters/hub-launcher-admin.mjs'
import { validateCatalog } from './validate.mjs'
async function readInputJson() {
const inputIndex = process.argv.indexOf('--input-json')
if (inputIndex >= 0) {
const inputPath = process.argv[inputIndex + 1]
if (!inputPath) throw new Error('--input-json requires a path')
return JSON.parse(await fs.readFile(inputPath, 'utf8'))
}
const inlineIndex = process.argv.indexOf('--input')
if (inlineIndex >= 0) {
return JSON.parse(process.argv[inlineIndex + 1] || '{}')
}
return null
}
function assertSmoke(condition, message) {
if (!condition) throw new Error(message)
}
async function runSmoke() {
const validation = await validateCatalog()
assertSmoke(validation.ok, 'catalog must validate before adapter plan smoke')
const cases = {
blockUser: await buildHubLauncherAdminPlan({
actionId: 'hub.user.block',
assistantRole: 'admin',
membershipRole: 'client_admin',
actorUserId: 'user_root',
targetUserId: 'user_pupa',
confirmed: true,
idempotencyKey: 'smoke:block:user_pupa',
}),
assistantRole: await buildHubLauncherAdminPlan({
actionId: 'hub.assistant_access.change_role',
assistantRole: 'admin',
membershipRole: 'client_admin',
actorUserId: 'user_root',
targetUserId: 'user_pupa',
membershipId: 'mem_client_public_pool_user_pupa',
targetAssistantRole: 'blocked',
confirmed: true,
idempotencyKey: 'smoke:assistant-role:user_pupa',
}),
deleteUser: await buildHubLauncherAdminPlan({
actionId: 'hub.user.delete',
assistantRole: 'admin',
membershipRole: 'client_admin',
actorUserId: 'user_root',
targetUserId: 'user_pupa',
confirmed: true,
}),
}
assertSmoke(cases.blockUser.ok, 'block user should produce a HUB adapter plan')
assertSmoke(cases.blockUser.request.method === 'PATCH', 'block user should use PATCH')
assertSmoke(cases.blockUser.request.body.globalStatus === 'blocked', 'block user should set globalStatus=blocked')
assertSmoke(cases.assistantRole.ok, 'assistant role change should produce a HUB adapter plan')
assertSmoke(cases.assistantRole.request.body.coreAssistantRole === 'blocked', 'assistant role should set coreAssistantRole=blocked')
assertSmoke(!cases.deleteUser.ok, 'delete user must not produce an adapter plan')
return { ok: true, validation: validation.counts, cases }
}
if (import.meta.url === `file://${process.argv[1]}`) {
if (process.argv.includes('--smoke')) {
console.log(JSON.stringify(await runSmoke(), null, 2))
} else {
const input = await readInputJson()
if (!input) throw new Error('Use --smoke, --input <json>, or --input-json <path>')
console.log(JSON.stringify(await buildHubLauncherAdminPlan(input), null, 2))
}
}

View File

@ -0,0 +1,331 @@
import fs from 'node:fs/promises'
import { loadCatalog } from './catalog.mjs'
import { resolveAssistantAccess } from './assistant-policy.mjs'
import { validateCatalog } from './validate.mjs'
function normalizeText(value) {
return String(value || '').trim().toLowerCase()
}
function tokenize(value) {
return normalizeText(value)
.split(/[^a-zа-яё0-9_]+/iu)
.filter(Boolean)
}
function unique(values) {
return [...new Set(values)]
}
function actionSearchText(action) {
return [
action.id,
action.app,
action.domain,
action.summary,
...(action.intentAliases || []),
].join(' ')
}
function scoreAction(action, input) {
if (input.actionId && input.actionId === action.id) return 1000
const query = normalizeText(input.intent || input.term || input.text || input.query)
if (!query) return 0
const aliases = (action.intentAliases || []).map(normalizeText)
if (aliases.includes(query)) return 200
if (aliases.some((alias) => alias && query.includes(alias))) return 160
const queryTokens = new Set(tokenize(query))
if (!queryTokens.size) return 0
const actionTokens = new Set(tokenize(actionSearchText(action)))
let score = 0
for (const token of queryTokens) {
if (actionTokens.has(token)) score += 10
}
if (input.app && input.app === action.app) score += 20
return score
}
function findAction(catalog, input) {
const candidates = catalog.assistantActions.actions
.map((action) => ({ action, score: scoreAction(action, input) }))
.filter((candidate) => candidate.score > 0)
.sort((a, b) => b.score - a.score || a.action.id.localeCompare(b.action.id))
return {
selected: candidates[0]?.action || null,
candidates: candidates.slice(0, 5).map(({ action, score }) => ({
id: action.id,
app: action.app,
riskLevel: action.riskLevel,
confirmationMode: action.confirmationMode,
adapterStatus: action.adapterStatus,
score,
summary: action.summary,
})),
}
}
function collectSafeAlternativeIds(action, riskPolicy) {
const ids = [...(action.safeAlternativeActionIds || [])]
for (const denyId of action.hardDenyIds || []) {
const deny = riskPolicy.hardDenies.find((candidate) => candidate.id === denyId)
ids.push(...(deny?.safeAlternatives || []))
}
return unique(ids.filter((id) => id !== action.id))
}
function describeAlternatives(ids, actionById) {
return ids.map((id) => {
const action = actionById.get(id)
return {
id,
app: action?.app || null,
riskLevel: action?.riskLevel || null,
confirmationMode: action?.confirmationMode || null,
summary: action?.summary || null,
}
})
}
function missingCapabilities(action, access) {
const allowed = new Set(access.allowedCapabilities || [])
return (action.capabilityIds || []).filter((capabilityId) => !allowed.has(capabilityId))
}
function isSelfTarget(input) {
if (!input.actorUserId || !input.targetUserId) return false
return String(input.actorUserId) === String(input.targetUserId)
}
function makeDecision(decision, action, input, access, extra = {}) {
return {
ok: decision === 'allowed',
decision,
action: action ? {
id: action.id,
app: action.app,
domain: action.domain,
riskLevel: action.riskLevel,
confirmationMode: action.confirmationMode,
selfActionPolicy: action.selfActionPolicy,
adapterId: action.adapterId,
adapterStatus: action.adapterStatus,
requiredScopes: action.requiredScopes || [],
capabilityIds: action.capabilityIds || [],
summary: action.summary,
} : null,
actor: {
assistantRole: input.assistantRole || null,
effectiveRole: access?.effectiveRole || null,
adminScope: access?.adminScope || null,
},
execution: action ? {
policyAllowed: decision === 'allowed',
adapterReady: action.adapterStatus === 'implemented',
executionReady: decision === 'allowed' && action.adapterStatus === 'implemented',
requiresConfirmation: action.confirmationMode === 'explicit' || action.confirmationMode === 'typed',
confirmed: Boolean(input.confirmed),
} : null,
...extra,
}
}
export async function resolveAssistantAction(input = {}, options = {}) {
const catalog = options.catalog || await loadCatalog()
const actionById = new Map(catalog.assistantActions.actions.map((action) => [action.id, action]))
const riskPolicy = catalog.assistantRiskPolicy
const found = findAction(catalog, input)
const action = found.selected
if (!action) {
return {
ok: false,
decision: 'action_not_registered',
reason: 'Request did not resolve to a registered assistant action card.',
candidates: found.candidates,
}
}
const access = await resolveAssistantAccess(input, { policy: catalog.assistantAccessPolicy })
const safeAlternativeIds = collectSafeAlternativeIds(action, riskPolicy)
const safeAlternatives = describeAlternatives(safeAlternativeIds, actionById)
if (action.riskLevel === 'destructive' || action.confirmationMode === 'forbidden' || action.adapterStatus === 'forbidden') {
return makeDecision('forbidden', action, input, access, {
reason: 'destructive_or_forbidden_action',
refusal: action.refusal || 'Assistant action is forbidden by risk policy.',
hardDenyIds: action.hardDenyIds || [],
safeAlternatives,
candidates: found.candidates,
})
}
if (action.selfActionPolicy === 'blocked' && isSelfTarget(input)) {
return makeDecision('denied', action, input, access, {
reason: 'self_targeting_blocked',
hardDenyIds: action.hardDenyIds || [],
safeAlternatives,
candidates: found.candidates,
})
}
if (action.selfActionPolicy === 'no_self_lockout' && isSelfTarget(input)) {
const removesOwnAccess = Boolean(input.removesOwnAccess || input.targetRole === 'assistant_blocked' || input.targetStatus === 'disabled' || input.globalStatus === 'blocked')
const hasAlternativeAdminPath = Boolean(input.hasAlternativeAdminPath)
if (removesOwnAccess && !hasAlternativeAdminPath) {
return makeDecision('denied', action, input, access, {
reason: 'self_lockout_denied',
hardDenyIds: unique([...(action.hardDenyIds || []), 'assistant.action.deny.self_lockout']),
safeAlternatives,
candidates: found.candidates,
})
}
}
const missing = missingCapabilities(action, access)
if (missing.length) {
return makeDecision('denied', action, input, access, {
reason: 'assistant_role_missing_capability',
missingCapabilities: missing,
safeAlternatives,
candidates: found.candidates,
})
}
if (action.riskLevel === 'privileged' && access.effectiveRole !== 'assistant_admin') {
return makeDecision('denied', action, input, access, {
reason: 'privileged_action_requires_assistant_admin',
safeAlternatives,
candidates: found.candidates,
})
}
if (action.adapterStatus === 'future') {
return makeDecision('future_adapter', action, input, access, {
reason: 'app_owned_adapter_not_available_yet',
safeAlternatives,
candidates: found.candidates,
})
}
if ((action.confirmationMode === 'explicit' || action.confirmationMode === 'typed') && !input.confirmed) {
return makeDecision('needs_confirmation', action, input, access, {
reason: 'explicit_confirmation_required',
confirmationRequest: {
actionId: action.id,
app: action.app,
targetUserId: input.targetUserId || null,
expectedEffect: action.summary,
},
safeAlternatives,
candidates: found.candidates,
})
}
return makeDecision('allowed', action, input, access, {
reason: action.adapterStatus === 'implemented' ? 'policy_and_adapter_ready' : 'policy_allowed_adapter_not_implemented',
safeAlternatives,
candidates: found.candidates,
})
}
async function readInputJson() {
const inputIndex = process.argv.indexOf('--input-json')
if (inputIndex >= 0) {
const inputPath = process.argv[inputIndex + 1]
if (!inputPath) throw new Error('--input-json requires a path')
return JSON.parse(await fs.readFile(inputPath, 'utf8'))
}
const inlineIndex = process.argv.indexOf('--input')
if (inlineIndex >= 0) {
return JSON.parse(process.argv[inlineIndex + 1] || '{}')
}
return null
}
function assertSmoke(condition, message) {
if (!condition) throw new Error(message)
}
async function runSmoke() {
const validation = await validateCatalog()
assertSmoke(validation.ok, 'catalog must validate before assistant action smoke')
const cases = {
deleteUser: await resolveAssistantAction({
actionId: 'hub.user.delete',
assistantRole: 'admin',
membershipRole: 'client_admin',
actorUserId: 'user_root',
targetUserId: 'user_pupa',
confirmed: true,
}),
blockAsMember: await resolveAssistantAction({
actionId: 'hub.user.block',
assistantRole: 'member',
membershipRole: 'member',
actorUserId: 'user_root',
targetUserId: 'user_pupa',
confirmed: true,
}),
blockNeedsConfirmation: await resolveAssistantAction({
actionId: 'hub.user.block',
assistantRole: 'admin',
membershipRole: 'client_admin',
actorUserId: 'user_root',
targetUserId: 'user_pupa',
}),
blockAllowed: await resolveAssistantAction({
actionId: 'hub.user.block',
assistantRole: 'admin',
membershipRole: 'client_admin',
actorUserId: 'user_root',
targetUserId: 'user_pupa',
confirmed: true,
}),
selfLockout: await resolveAssistantAction({
actionId: 'hub.membership.disable',
assistantRole: 'admin',
membershipRole: 'client_admin',
actorUserId: 'user_root',
targetUserId: 'user_root',
removesOwnAccess: true,
hasAlternativeAdminPath: false,
confirmed: true,
}),
futureEngineAdapter: await resolveAssistantAction({
actionId: 'engine.workflow.share',
assistantRole: 'admin',
membershipRole: 'client_admin',
actorUserId: 'user_root',
targetUserId: 'user_pupa',
confirmed: true,
}),
}
assertSmoke(cases.deleteUser.decision === 'forbidden', 'delete user must be forbidden')
assertSmoke(cases.blockAsMember.decision === 'denied', 'member must not block users')
assertSmoke(cases.blockNeedsConfirmation.decision === 'needs_confirmation', 'privileged write must require confirmation')
assertSmoke(cases.blockAllowed.decision === 'allowed', 'confirmed assistant admin block should be policy-allowed')
assertSmoke(cases.blockAllowed.execution.executionReady, 'implemented HUB allowlist adapter should be execution-ready after policy')
assertSmoke(cases.selfLockout.decision === 'denied', 'self lockout must be denied')
assertSmoke(cases.futureEngineAdapter.decision === 'future_adapter', 'future engine adapter must not execute')
return { ok: true, validation: validation.counts, cases }
}
if (import.meta.url === `file://${process.argv[1]}`) {
if (process.argv.includes('--smoke')) {
console.log(JSON.stringify(await runSmoke(), null, 2))
} else {
const input = await readInputJson()
if (!input) throw new Error('Use --smoke, --input <json>, or --input-json <path>')
console.log(JSON.stringify(await resolveAssistantAction(input), null, 2))
}
}

View File

@ -0,0 +1,195 @@
import { readJson } from './catalog.mjs'
const LAUNCHER_ROOT_GROUPS = new Set(['nodedc:superadmin', 'nodedc:launcher:admin'])
const LAUNCHER_ROOT_ROLES = new Set(['root_admin'])
const LAUNCHER_CLIENT_ADMIN_ROLES = new Set(['client_owner', 'client_admin'])
export async function loadAssistantAccessPolicy() {
return readJson('catalog/assistant-access-policy.json')
}
function normalizeString(value) {
return String(value || '').trim()
}
function normalizeRole(value, policy) {
const raw = normalizeString(value).toLowerCase()
if (!raw) return ''
const roleIds = new Set(policy.roleVocabulary.map((role) => role.id))
if (roleIds.has(raw)) return raw
const alias = policy.roleAliases.find((candidate) => candidate.alias.toLowerCase() === raw)
return alias?.roleId || raw
}
function toStringSet(value) {
if (!Array.isArray(value)) return new Set()
return new Set(value.map((item) => String(item)))
}
function resolveLauncherAdminScope(input) {
const groups = toStringSet(input.groups)
const launcherGlobalRole = normalizeString(input.launcherGlobalRole)
const membershipRole = normalizeString(input.membershipRole)
const membershipStatus = normalizeString(input.membershipStatus || 'active')
const isRoot = Boolean(input.isRoot || input.isSuperAdmin || LAUNCHER_ROOT_ROLES.has(launcherGlobalRole) || [...groups].some((group) => LAUNCHER_ROOT_GROUPS.has(group)))
const isClientAdmin = membershipStatus === 'active' && LAUNCHER_CLIENT_ADMIN_ROLES.has(membershipRole)
return {
isRoot,
isClientAdmin,
present: isRoot || isClientAdmin,
reason: isRoot ? 'launcher_root_scope' : isClientAdmin ? 'launcher_client_admin_scope' : 'no_launcher_admin_scope',
}
}
function uniqueSorted(values) {
return [...new Set(values)].sort()
}
export async function resolveAssistantAccess(input, options = {}) {
const policy = options.policy || await loadAssistantAccessPolicy()
const launcherUserStatus = normalizeString(input.launcherUserStatus || input.globalStatus || 'active')
const membershipStatus = normalizeString(input.membershipStatus || 'active')
const adminScope = resolveLauncherAdminScope(input)
let requestedRole = normalizeRole(input.assistantRole, policy)
if (!requestedRole) {
requestedRole = adminScope.isRoot
? policy.defaultResolution.superAdminDefaultRole
: policy.defaultResolution.activeUserDefaultRole
}
const forcedBlocked = launcherUserStatus === 'blocked' || membershipStatus === 'disabled'
const effectiveRole = forcedBlocked ? policy.defaultResolution.blockedUserEffectiveRole : requestedRole
const matrix = policy.roleMatrix.find((entry) => entry.roleId === effectiveRole)
if (!matrix) {
throw new Error(`Unknown assistant role: ${effectiveRole}`)
}
const allCapabilities = policy.capabilities.map((capability) => capability.id)
let allowedCapabilities = matrix.deniedCapabilities?.includes('*') ? [] : [...(matrix.allowedCapabilities || [])]
const deniedCapabilities = new Set(matrix.deniedCapabilities?.includes('*') ? allCapabilities : matrix.deniedCapabilities || [])
const limitations = []
for (const conditional of matrix.conditionalCapabilities || []) {
if (conditional.requires === 'launcher_admin_scope.root' && adminScope.isRoot) {
allowedCapabilities.push(conditional.capabilityId)
deniedCapabilities.delete(conditional.capabilityId)
} else {
deniedCapabilities.add(conditional.capabilityId)
limitations.push({
capabilityId: conditional.capabilityId,
reason: conditional.requires,
})
}
}
if (effectiveRole === 'assistant_admin' && !adminScope.present) {
for (const capabilityId of matrix.deniedWhenNoAdminScope || []) {
deniedCapabilities.add(capabilityId)
allowedCapabilities = allowedCapabilities.filter((candidate) => candidate !== capabilityId)
}
limitations.push({
reason: 'assistant_admin_requires_existing_launcher_admin_scope',
})
}
if (forcedBlocked) {
allowedCapabilities = []
for (const capabilityId of allCapabilities) {
deniedCapabilities.add(capabilityId)
}
limitations.push({
reason: launcherUserStatus === 'blocked' ? 'launcher_user_blocked' : 'membership_disabled',
})
}
allowedCapabilities = uniqueSorted(allowedCapabilities.filter((capabilityId) => !deniedCapabilities.has(capabilityId)))
return {
ok: true,
requestedRole,
effectiveRole,
visible: Boolean(matrix.visible && !forcedBlocked),
canUse: Boolean(matrix.canUse && !forcedBlocked),
launcherUserStatus,
membershipStatus,
adminScope,
allowedCapabilities,
deniedCapabilities: uniqueSorted([...deniedCapabilities]),
limitations,
hardDenies: policy.hardDenies.map((deny) => deny.id),
}
}
async function readInputJson() {
const inputIndex = process.argv.indexOf('--input-json')
if (inputIndex === -1) return null
const inputPath = process.argv[inputIndex + 1]
if (!inputPath) {
throw new Error('--input-json requires a path')
}
return readJson(inputPath)
}
function assertSmoke(condition, message) {
if (!condition) {
throw new Error(message)
}
}
async function runSmoke() {
const policy = await loadAssistantAccessPolicy()
const member = await resolveAssistantAccess({
assistantRole: 'member',
launcherUserStatus: 'active',
membershipRole: 'member',
}, { policy })
const clientAdmin = await resolveAssistantAccess({
assistantRole: 'admin',
launcherUserStatus: 'active',
membershipRole: 'client_admin',
membershipStatus: 'active',
}, { policy })
const blocked = await resolveAssistantAccess({
assistantRole: 'admin',
launcherUserStatus: 'blocked',
membershipRole: 'client_admin',
}, { policy })
const superAdmin = await resolveAssistantAccess({
groups: ['nodedc:superadmin'],
launcherUserStatus: 'active',
}, { policy })
assertSmoke(member.visible, 'member assistant should be visible')
assertSmoke(!member.allowedCapabilities.includes('assistant.manage_users'), 'member assistant must not manage users')
assertSmoke(clientAdmin.allowedCapabilities.includes('assistant.manage_users'), 'client assistant admin should manage users inside Launcher scope')
assertSmoke(!clientAdmin.allowedCapabilities.includes('assistant.manage_service_catalog'), 'client assistant admin must not get root service catalog capability')
assertSmoke(!blocked.visible && !blocked.canUse, 'blocked assistant must be hidden and unusable')
assertSmoke(superAdmin.effectiveRole === 'assistant_admin', 'superadmin should default to assistant_admin')
assertSmoke(superAdmin.allowedCapabilities.includes('assistant.manage_service_catalog'), 'superadmin should receive root conditional capabilities')
return {
ok: true,
cases: { member, clientAdmin, blocked, superAdmin },
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
if (process.argv.includes('--smoke')) {
console.log(JSON.stringify(await runSmoke(), null, 2))
} else {
const input = await readInputJson()
if (!input) {
throw new Error('Use --smoke or --input-json <path>')
}
console.log(JSON.stringify(await resolveAssistantAccess(input), null, 2))
}
}

View File

@ -0,0 +1,76 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
export const serviceRoot = path.resolve(__dirname, '..')
export const catalogRoot = path.join(serviceRoot, 'catalog')
export async function readJson(relativePath) {
const fullPath = path.join(serviceRoot, relativePath)
const raw = await fs.readFile(fullPath, 'utf8')
return JSON.parse(raw)
}
export async function loadCatalog() {
const [
entities,
relations,
aliases,
guardrails,
evidence,
resolverRules,
assistantAccessPolicy,
assistantActions,
assistantRiskPolicy,
] = await Promise.all([
readJson('catalog/entities.json'),
readJson('catalog/relations.json'),
readJson('catalog/aliases.json'),
readJson('catalog/guardrails.json'),
readJson('catalog/evidence.json'),
readJson('catalog/resolver-rules.json'),
readJson('catalog/assistant-access-policy.json'),
readJson('catalog/assistant-actions.json'),
readJson('catalog/assistant-risk-policy.json'),
])
const contextBindings = await readJson('catalog/context-bindings.json')
const entityById = new Map(entities.entities.map((entity) => [entity.id, entity]))
const relationById = new Map(relations.relations.map((relation) => [relation.id, relation]))
const contextById = new Map(contextBindings.contexts.map((context) => [context.id, context]))
const bindingTypeById = new Map(contextBindings.bindingTypes.map((bindingType) => [bindingType.id, bindingType]))
const aliasByKey = new Map(
aliases.aliases.map((alias) => [normalizeAlias(alias.alias), alias.canonicalId]),
)
return {
entities,
relations,
aliases,
guardrails,
evidence,
resolverRules,
assistantAccessPolicy,
assistantActions,
assistantRiskPolicy,
contextBindings,
entityById,
relationById,
contextById,
bindingTypeById,
aliasByKey,
}
}
export function normalizeAlias(value) {
return String(value || '').trim().toLowerCase()
}
export function canonicalizeEntityId(value, catalog) {
const raw = String(value || '').trim()
if (!raw) return ''
if (catalog.entityById.has(raw)) return raw
return catalog.aliasByKey.get(normalizeAlias(raw)) || raw
}

View File

@ -0,0 +1,280 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { loadCatalog, serviceRoot } from './catalog.mjs'
import { validateCatalog } from './validate.mjs'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const contextBindingsPath = path.resolve(__dirname, '..', 'catalog', 'context-bindings.json')
function usage() {
return [
'Usage:',
' node src/registry.mjs list-contexts',
' node src/registry.mjs list-bindings',
' node src/registry.mjs add-context --input-json examples/engine-context.example.json [--dry-run]',
' node src/registry.mjs add-binding --input-json examples/ops-engine-binding.example.json [--dry-run]',
' node src/registry.mjs import-engine-context --input-json examples/engine-workflow-manifest.example.json [--dry-run]',
].join('\n')
}
function argValue(name) {
const index = process.argv.indexOf(name)
if (index < 0) return ''
return process.argv[index + 1] || ''
}
async function readInputJson() {
const inputPath = argValue('--input-json')
const inputRaw = argValue('--input')
if (inputRaw) return JSON.parse(inputRaw)
if (!inputPath) throw new Error('missing --input-json or --input')
const fullPath = path.resolve(process.cwd(), inputPath)
return JSON.parse(await fs.readFile(fullPath, 'utf8'))
}
async function readContextBindings() {
return JSON.parse(await fs.readFile(contextBindingsPath, 'utf8'))
}
async function writeContextBindings(next) {
const raw = `${JSON.stringify(next, null, 2)}\n`
await fs.writeFile(contextBindingsPath, raw, 'utf8')
}
function required(value, label) {
if (value === undefined || value === null || String(value).trim() === '') {
throw new Error(`${label} is required`)
}
}
function isObject(value) {
return value && typeof value === 'object' && !Array.isArray(value)
}
function slugPart(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9а-яё]+/giu, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64) || 'unknown'
}
function validateDraftContext(draft, catalog, registry) {
required(draft.id, 'context.id')
required(draft.surface, 'context.surface')
required(draft.entityId, 'context.entityId')
required(draft.sourceSystem, 'context.sourceSystem')
required(draft.label, 'context.label')
required(draft.status, 'context.status')
if (!catalog.entityById.has(draft.entityId)) throw new Error(`unknown context.entityId: ${draft.entityId}`)
if (!registry.contextStatuses.includes(draft.status)) throw new Error(`invalid context.status: ${draft.status}`)
if (!isObject(draft.refs)) throw new Error('context.refs must be an object')
if (draft.parentContextId && !catalog.contextById.has(draft.parentContextId)) {
throw new Error(`unknown context.parentContextId: ${draft.parentContextId}`)
}
}
function validateDraftBinding(draft, catalog, registry) {
required(draft.id, 'binding.id')
required(draft.typeId, 'binding.typeId')
required(draft.status, 'binding.status')
required(draft.fromContextId, 'binding.fromContextId')
required(draft.toContextId, 'binding.toContextId')
if (!registry.bindingStatuses.includes(draft.status)) throw new Error(`invalid binding.status: ${draft.status}`)
if (!catalog.bindingTypeById.has(draft.typeId)) throw new Error(`unknown binding.typeId: ${draft.typeId}`)
if (!catalog.contextById.has(draft.fromContextId)) throw new Error(`unknown binding.fromContextId: ${draft.fromContextId}`)
if (!catalog.contextById.has(draft.toContextId)) throw new Error(`unknown binding.toContextId: ${draft.toContextId}`)
}
function validateDraftBindingWithKnownContextIds(draft, catalog, registry, knownContextIds) {
required(draft.id, 'binding.id')
required(draft.typeId, 'binding.typeId')
required(draft.status, 'binding.status')
required(draft.fromContextId, 'binding.fromContextId')
required(draft.toContextId, 'binding.toContextId')
if (!registry.bindingStatuses.includes(draft.status)) throw new Error(`invalid binding.status: ${draft.status}`)
if (!catalog.bindingTypeById.has(draft.typeId)) throw new Error(`unknown binding.typeId: ${draft.typeId}`)
if (!knownContextIds.has(draft.fromContextId)) throw new Error(`unknown binding.fromContextId: ${draft.fromContextId}`)
if (!knownContextIds.has(draft.toContextId)) throw new Error(`unknown binding.toContextId: ${draft.toContextId}`)
}
function upsertById(list, item) {
const index = list.findIndex((existing) => existing.id === item.id)
if (index >= 0) {
const next = [...list]
next[index] = item
return { list: next, action: 'updated' }
}
return { list: [...list, item], action: 'created' }
}
async function updateRegistry(mutator, dryRun) {
const beforeRaw = await fs.readFile(contextBindingsPath, 'utf8')
const registry = JSON.parse(beforeRaw)
const next = structuredClone(registry)
const result = mutator(next)
if (dryRun) return { dryRun: true, ...result, registry: next }
await writeContextBindings(next)
const validation = await validateCatalog()
if (!validation.ok) {
await fs.writeFile(contextBindingsPath, beforeRaw, 'utf8')
const err = new Error('registry validation failed; reverted context-bindings.json')
err.validation = validation
throw err
}
return { dryRun: false, ...result, validation: validation.counts }
}
async function listContexts() {
const catalog = await loadCatalog()
return catalog.contextBindings.contexts.map((context) => ({
id: context.id,
surface: context.surface,
entityId: context.entityId,
sourceSystem: context.sourceSystem,
label: context.label,
status: context.status,
refs: context.refs,
parentContextId: context.parentContextId || null,
}))
}
async function listBindings() {
const catalog = await loadCatalog()
return catalog.contextBindings.bindings.map((binding) => ({
id: binding.id,
typeId: binding.typeId,
status: binding.status,
fromContextId: binding.fromContextId,
toContextId: binding.toContextId,
summary: binding.summary || '',
}))
}
async function addContext(dryRun) {
const draft = await readInputJson()
const catalog = await loadCatalog()
const registry = await readContextBindings()
validateDraftContext(draft, catalog, registry)
return updateRegistry((next) => {
const out = upsertById(next.contexts, draft)
next.contexts = out.list
return { action: out.action, context: draft }
}, dryRun)
}
async function addBinding(dryRun) {
const draft = await readInputJson()
const catalog = await loadCatalog()
const registry = await readContextBindings()
validateDraftBinding(draft, catalog, registry)
return updateRegistry((next) => {
const out = upsertById(next.bindings, draft)
next.bindings = out.list
return { action: out.action, binding: draft }
}, dryRun)
}
function buildEngineContextFromManifest(manifest) {
if (!isObject(manifest)) throw new Error('manifest must be an object')
const workflow = manifest.workflow || {}
required(workflow.id, 'workflow.id')
required(workflow.label, 'workflow.label')
const refs = {
workflow_id: String(workflow.id),
}
if (workflow.nodeId) refs.node_id = String(workflow.nodeId)
if (workflow.runtimeWorkflowId) refs.runtime_workflow_id = String(workflow.runtimeWorkflowId)
if (workflow.n8nInstanceId) refs.n8n_instance_id = String(workflow.n8nInstanceId)
return {
id: `context.engine.${slugPart(workflow.id)}`,
surface: 'engine',
entityId: workflow.runtimeWorkflowId ? 'engine.workflow_l2' : 'engine.workflow_l1',
sourceSystem: String(manifest.context?.sourceSystem || 'nodedc-engine'),
label: String(workflow.label),
status: String(manifest.context?.status || 'planned'),
refs,
}
}
function buildOpsEngineBindingFromManifest(manifest, engineContext) {
const bind = manifest.bindToOps || {}
if (bind.enabled !== true) return null
required(bind.fromContextId, 'bindToOps.fromContextId')
return {
id: `binding.ops.${slugPart(bind.fromContextId)}.engine.${slugPart(engineContext.id)}`,
typeId: 'binding_type.ops_card_to_engine_workflow',
status: String(bind.status || 'planned'),
fromContextId: String(bind.fromContextId),
toContextId: engineContext.id,
summary: String(bind.summary || `Binds ${bind.fromContextId} to ${engineContext.id}.`),
}
}
async function importEngineContext(dryRun) {
const manifest = await readInputJson()
const catalog = await loadCatalog()
const registry = await readContextBindings()
const engineContext = buildEngineContextFromManifest(manifest)
const binding = buildOpsEngineBindingFromManifest(manifest, engineContext)
validateDraftContext(engineContext, catalog, registry)
if (binding) {
const knownContextIds = new Set([
...catalog.contextById.keys(),
engineContext.id,
])
validateDraftBindingWithKnownContextIds(binding, catalog, registry, knownContextIds)
}
return updateRegistry((next) => {
const contextOut = upsertById(next.contexts, engineContext)
next.contexts = contextOut.list
let bindingOut = null
if (binding) {
bindingOut = upsertById(next.bindings, binding)
next.bindings = bindingOut.list
}
return {
action: bindingOut ? `${contextOut.action}_context_${bindingOut.action}_binding` : `${contextOut.action}_context`,
context: engineContext,
binding,
}
}, dryRun)
}
async function main() {
const command = process.argv[2]
const dryRun = process.argv.includes('--dry-run')
let output
if (command === 'list-contexts') output = await listContexts()
else if (command === 'list-bindings') output = await listBindings()
else if (command === 'add-context') output = await addContext(dryRun)
else if (command === 'add-binding') output = await addBinding(dryRun)
else if (command === 'import-engine-context') output = await importEngineContext(dryRun)
else {
console.error(usage())
process.exit(2)
}
console.log(JSON.stringify({ ok: true, serviceRoot, command, output }, null, 2))
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => {
console.error(JSON.stringify({
ok: false,
error: err.message || String(err),
validation: err.validation || undefined,
}, null, 2))
process.exit(1)
})
}

View File

@ -0,0 +1,212 @@
import fs from 'node:fs/promises'
import { canonicalizeEntityId, loadCatalog } from './catalog.mjs'
import { validateCatalog } from './validate.mjs'
function tokenize(value) {
return String(value || '')
.toLowerCase()
.split(/[^a-zа-яё0-9_]+/iu)
.filter(Boolean)
}
function scoreRule(rule, input) {
let score = 0
const entityId = input.entityId || ''
if (entityId && rule.inputEntityIds.includes(entityId)) score += 10
if (input.surface && input.surface === rule.fromSurface) score += 6
if (score === 0) return 0
const intentTokens = new Set(tokenize(input.intent))
for (const hint of rule.intentHints || []) {
const hintTokens = tokenize(hint)
if (hintTokens.some((token) => intentTokens.has(token))) score += 2
}
return score
}
function refMatches(candidateRefs = {}, inputRefs = {}) {
const candidateEntries = Object.entries(candidateRefs || {}).filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== '')
const inputEntries = Object.entries(inputRefs || {}).filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== '')
if (!candidateEntries.length || !inputEntries.length) return false
const matches = (entries, refs) => entries.every(([key, value]) => String(refs[key] || '') === String(value))
const candidateInsideInput = matches(candidateEntries, inputRefs)
const inputInsideCandidate = matches(inputEntries, candidateRefs)
if (!candidateInsideInput && !inputInsideCandidate) return false
const matchedKeys = candidateEntries
.map(([key]) => key)
.filter((key) => inputRefs[key] !== undefined && String(inputRefs[key]) === String(candidateRefs[key]))
const hasStrongRef = ['issue_id', 'workflow_id', 'runtime_workflow_id', 'node_id', 'grant_id'].some((key) => matchedKeys.includes(key))
return hasStrongRef || matchedKeys.length >= 2
}
function contextScore(context, input) {
let score = 0
if (input.contextId && input.contextId === context.id) score += 100
if (refMatches(context.refs, input.refs)) score += 40
if (score === 0) return 0
if (input.surface && input.surface === context.surface) score += 10
if (input.entityId && input.entityId === context.entityId) score += 10
return score
}
function findMatchingContexts(catalog, input) {
return catalog.contextBindings.contexts
.map((context) => ({ context, score: contextScore(context, input) }))
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score || a.context.id.localeCompare(b.context.id))
}
function bindingTouchesContext(binding, contextIds) {
return contextIds.has(binding.fromContextId) || contextIds.has(binding.toContextId)
}
function findBindingsForContexts(catalog, contexts) {
const contextIds = new Set(contexts.map(({ context }) => context.id))
return catalog.contextBindings.bindings
.filter((binding) => bindingTouchesContext(binding, contextIds))
.map((binding) => ({
...binding,
type: catalog.bindingTypeById.get(binding.typeId) || null,
fromContext: catalog.contextById.get(binding.fromContextId) || null,
toContext: catalog.contextById.get(binding.toContextId) || null,
}))
}
function findRequiredBindingTypes(catalog, selectedRule) {
if (!selectedRule) return []
return catalog.contextBindings.bindingTypes.filter((bindingType) => bindingType.relationId === selectedRule.relationId)
}
export async function resolveContext(input = {}) {
const catalog = await loadCatalog()
const entityId = canonicalizeEntityId(input.entityId || input.alias || input.term, catalog)
const normalizedInput = {
...input,
entityId,
}
const matchedContexts = findMatchingContexts(catalog, normalizedInput)
if (!normalizedInput.entityId && matchedContexts[0]?.context?.entityId) {
normalizedInput.entityId = matchedContexts[0].context.entityId
}
const candidates = catalog.resolverRules.rules
.map((rule) => ({ rule, score: scoreRule(rule, normalizedInput) }))
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score || a.rule.id.localeCompare(b.rule.id))
const selected = candidates[0]?.rule || null
const bindings = findBindingsForContexts(catalog, matchedContexts)
const requiredBindingTypes = findRequiredBindingTypes(catalog, selected)
const existingBindingTypeIds = new Set(bindings.map((binding) => binding.typeId))
const missingBindingTypes = requiredBindingTypes.filter((bindingType) => !existingBindingTypeIds.has(bindingType.id))
return {
input: normalizedInput,
canonicalEntity: catalog.entityById.get(normalizedInput.entityId) || null,
matchedContexts: matchedContexts.map(({ context, score }) => ({
id: context.id,
score,
surface: context.surface,
entityId: context.entityId,
label: context.label,
sourceSystem: context.sourceSystem,
refs: context.refs,
status: context.status,
})),
bindings: bindings.map((binding) => ({
id: binding.id,
typeId: binding.typeId,
status: binding.status,
fromContextId: binding.fromContextId,
toContextId: binding.toContextId,
relationId: binding.type?.relationId || null,
summary: binding.summary || binding.type?.summary || '',
})),
missingBindingTypes: missingBindingTypes.map((bindingType) => ({
id: bindingType.id,
relationId: bindingType.relationId,
requiredFromRefs: bindingType.requiredFromRefs || [],
requiredToRefs: bindingType.requiredToRefs || [],
summary: bindingType.summary,
})),
selectedRule: selected,
candidates: candidates.map(({ rule, score }) => ({
id: rule.id,
score,
outputSurface: rule.outputSurface,
outputEntityIds: rule.outputEntityIds,
requiredBindings: rule.requiredBindings,
summary: rule.summary,
})),
}
}
async function resolveFromCli() {
const inputIndex = process.argv.indexOf('--input')
const inputJsonIndex = process.argv.indexOf('--input-json')
let input = null
if (inputIndex >= 0) {
input = JSON.parse(process.argv[inputIndex + 1] || '{}')
} else if (inputJsonIndex >= 0) {
const raw = await fs.readFile(process.argv[inputJsonIndex + 1], 'utf8')
input = JSON.parse(raw)
}
if (!input) {
console.error('Usage: node src/resolver.mjs --input-json examples/ops-card-to-engine-request.json')
process.exit(2)
}
const out = await resolveContext(input)
console.log(JSON.stringify(out, null, 2))
}
async function runSmoke() {
const validation = await validateCatalog()
if (!validation.ok) {
console.error(JSON.stringify(validation, null, 2))
process.exit(1)
}
const samples = [
{ surface: 'ops', alias: 'issue', intent: 'inspect engine workflow for this card' },
{
surface: 'ops',
alias: 'issue',
intent: 'inspect engine workflow for this ontology card',
refs: {
workspace_slug: 'nodedc',
project_id: '86629a11-eaff-4ad2-9f89-e5245a344fcc',
issue_id: '1312519a-0eda-4ea0-9e34-2113c3724ecf',
},
},
{ surface: 'engine', entityId: 'engine.workflow_l1', intent: 'создай карточку в ops по текущему workflow' },
{ surface: 'assistant', entityId: 'assistant.bridge', intent: 'select mcp gateway grant and tool' },
{ term: 'n8n workflow id', intent: 'runtime workflow id lookup' },
]
const results = []
for (const sample of samples) {
const out = await resolveContext(sample)
results.push({
sample,
canonicalEntityId: out.canonicalEntity?.id || out.input.entityId || null,
selectedRuleId: out.selectedRule?.id || null,
outputSurface: out.selectedRule?.outputSurface || null,
outputEntityIds: out.selectedRule?.outputEntityIds || [],
matchedContextIds: out.matchedContexts.map((context) => context.id),
missingBindingTypeIds: out.missingBindingTypes.map((bindingType) => bindingType.id),
})
}
console.log(JSON.stringify({ ok: true, validation: validation.counts, results }, null, 2))
}
if (import.meta.url === `file://${process.argv[1]}` && process.argv.includes('--smoke')) {
await runSmoke()
} else if (import.meta.url === `file://${process.argv[1]}`) {
await resolveFromCli()
}

View File

@ -0,0 +1,363 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { loadCatalog, serviceRoot } from './catalog.mjs'
const ID_RE = /^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9_]*)+$/
const RULE_ID_RE = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/
const ROLE_ID_RE = /^[a-z][a-z0-9_]*$/
const ALLOWED_RELATION_STATUSES = new Set([
'source-confirmed',
'source-evidenced',
'owner-confirmed',
'product-required',
'future-concept',
'tech-debt-noncanonical',
'pending',
])
function assert(condition, message, errors) {
if (!condition) errors.push(message)
}
function assertUnique(ids, label, errors) {
const seen = new Set()
for (const id of ids) {
assert(!seen.has(id), `${label} duplicate id: ${id}`, errors)
seen.add(id)
}
}
function assertEntityRefs(refs, entityById, label, errors) {
for (const ref of refs || []) {
assert(entityById.has(ref), `${label} references unknown entity: ${ref}`, errors)
}
}
function assertRelationRef(ref, relationById, label, errors) {
assert(relationById.has(ref), `${label} references unknown relation: ${ref}`, errors)
}
function assertContextRefs(refs, contextById, label, errors) {
for (const ref of refs || []) {
assert(contextById.has(ref), `${label} references unknown context: ${ref}`, errors)
}
}
function assertRefsObject(refs, label, errors) {
assert(refs && typeof refs === 'object' && !Array.isArray(refs), `${label} refs must be object`, errors)
for (const [key, value] of Object.entries(refs || {})) {
assert(/^[a-z][a-z0-9_]*$/i.test(key), `${label} refs has invalid key: ${key}`, errors)
assert(value !== null && value !== undefined && String(value).trim() !== '', `${label} refs.${key} is empty`, errors)
}
}
async function pathExists(relativePath) {
try {
await fs.access(path.join(serviceRoot, relativePath))
return true
} catch {
return false
}
}
export async function validateCatalog() {
const catalog = await loadCatalog()
const errors = []
const warnings = []
const {
entities,
relations,
aliases,
guardrails,
evidence,
resolverRules,
assistantAccessPolicy,
assistantActions,
assistantRiskPolicy,
contextBindings,
entityById,
relationById,
contextById,
bindingTypeById,
} = catalog
const statusVocabulary = new Set(entities.statusVocabulary || [])
assertUnique(entities.entities.map((entity) => entity.id), 'entity', errors)
for (const entity of entities.entities) {
assert(ID_RE.test(entity.id), `entity id has invalid format: ${entity.id}`, errors)
assert(entity.name, `entity ${entity.id} missing name`, errors)
assert(entity.surface, `entity ${entity.id} missing surface`, errors)
assert(Array.isArray(entity.status) && entity.status.length > 0, `entity ${entity.id} missing status`, errors)
for (const status of entity.status || []) {
assert(statusVocabulary.has(status), `entity ${entity.id} uses unknown status: ${status}`, errors)
}
}
assertUnique(relations.relations.map((relation) => relation.id), 'relation', errors)
for (const relation of relations.relations) {
assert(RULE_ID_RE.test(relation.id), `relation id has invalid format: ${relation.id}`, errors)
assert(ALLOWED_RELATION_STATUSES.has(relation.status), `relation ${relation.id} uses unknown status: ${relation.status}`, errors)
assertEntityRefs(relation.from, entityById, `relation ${relation.id}.from`, errors)
assertEntityRefs(relation.to, entityById, `relation ${relation.id}.to`, errors)
}
assertUnique(aliases.aliases.map((alias) => alias.alias.toLowerCase()), 'alias', errors)
for (const alias of aliases.aliases) {
assert(alias.alias, 'alias missing alias text', errors)
assert(entityById.has(alias.canonicalId), `alias ${alias.alias} points to unknown entity: ${alias.canonicalId}`, errors)
}
assertUnique(guardrails.rules.map((rule) => rule.id), 'guardrail', errors)
for (const rule of guardrails.rules) {
assert(RULE_ID_RE.test(rule.id), `guardrail id has invalid format: ${rule.id}`, errors)
assert(['error', 'warning'].includes(rule.severity), `guardrail ${rule.id} invalid severity`, errors)
assertEntityRefs(rule.entityIds, entityById, `guardrail ${rule.id}`, errors)
}
for (const pair of guardrails.blockedConflations || []) {
assert(Array.isArray(pair) && pair.length === 2, `blocked conflation must be a pair: ${JSON.stringify(pair)}`, errors)
assertEntityRefs(pair, entityById, 'blocked conflation', errors)
}
for (const ledger of evidence.ledgers || []) {
assert(await pathExists(ledger.path), `evidence ledger path missing: ${ledger.path}`, errors)
assertEntityRefs(ledger.entityIds, entityById, `ledger ${ledger.id}`, errors)
}
for (const baselineDoc of evidence.baselineDocs || []) {
assert(await pathExists(baselineDoc), `baseline doc path missing: ${baselineDoc}`, errors)
}
assertUnique(resolverRules.rules.map((rule) => rule.id), 'resolver rule', errors)
for (const rule of resolverRules.rules) {
assert(RULE_ID_RE.test(rule.id), `resolver rule id has invalid format: ${rule.id}`, errors)
assert(rule.fromSurface, `resolver rule ${rule.id} missing fromSurface`, errors)
assert(rule.outputSurface, `resolver rule ${rule.id} missing outputSurface`, errors)
assertRelationRef(rule.relationId, relationById, `resolver rule ${rule.id}`, errors)
assertEntityRefs(rule.inputEntityIds, entityById, `resolver rule ${rule.id}.inputEntityIds`, errors)
assertEntityRefs(rule.outputEntityIds, entityById, `resolver rule ${rule.id}.outputEntityIds`, errors)
if (rule.status !== 'product-required') {
warnings.push(`resolver rule ${rule.id} has non-product-required status: ${rule.status}`)
}
}
const assistantRoleIds = new Set((assistantAccessPolicy.roleVocabulary || []).map((role) => role.id))
const assistantCapabilityIds = new Set((assistantAccessPolicy.capabilities || []).map((capability) => capability.id))
const assistantOperationIds = new Set((assistantAccessPolicy.operations || []).map((operation) => operation.id))
const assistantHardDenyIds = new Set((assistantAccessPolicy.hardDenies || []).map((deny) => deny.id))
const assistantRiskLevelIds = new Set((assistantRiskPolicy.riskLevels || []).map((riskLevel) => riskLevel.id))
const assistantConfirmationModeIds = new Set((assistantRiskPolicy.confirmationModes || []).map((mode) => mode.id))
const assistantSelfActionPolicyIds = new Set((assistantRiskPolicy.selfActionPolicies || []).map((policy) => policy.id))
const assistantActionHardDenyIds = new Set((assistantRiskPolicy.hardDenies || []).map((deny) => deny.id))
const assistantAppIds = new Set((assistantActions.apps || []).map((app) => app.id))
const assistantAdapterStatuses = new Set(assistantActions.adapterStatuses || [])
const assistantActionIds = new Set((assistantActions.actions || []).map((action) => action.id))
assertUnique([...assistantRoleIds], 'assistant role', errors)
for (const role of assistantAccessPolicy.roleVocabulary || []) {
assert(ROLE_ID_RE.test(role.id), `assistant role id has invalid format: ${role.id}`, errors)
assert(role.name, `assistant role ${role.id} missing name`, errors)
assert(role.summary, `assistant role ${role.id} missing summary`, errors)
}
assertUnique((assistantAccessPolicy.roleAliases || []).map((alias) => alias.alias.toLowerCase()), 'assistant role alias', errors)
for (const alias of assistantAccessPolicy.roleAliases || []) {
assert(alias.alias, 'assistant role alias missing alias text', errors)
assert(assistantRoleIds.has(alias.roleId), `assistant role alias ${alias.alias} references unknown role: ${alias.roleId}`, errors)
}
for (const [key, roleId] of Object.entries(assistantAccessPolicy.defaultResolution || {})) {
if (key.endsWith('Role')) {
assert(assistantRoleIds.has(roleId), `assistant defaultResolution.${key} references unknown role: ${roleId}`, errors)
}
}
for (const sourceAuthority of assistantAccessPolicy.sourceAuthorities || []) {
assert(RULE_ID_RE.test(sourceAuthority.id), `assistant source authority id has invalid format: ${sourceAuthority.id}`, errors)
assertEntityRefs(sourceAuthority.entityIds, entityById, `assistant source authority ${sourceAuthority.id}`, errors)
}
assertUnique([...assistantCapabilityIds], 'assistant capability', errors)
for (const capability of assistantAccessPolicy.capabilities || []) {
assert(RULE_ID_RE.test(capability.id), `assistant capability id has invalid format: ${capability.id}`, errors)
assert(entityById.has(capability.entityId), `assistant capability ${capability.id} references unknown entity: ${capability.entityId}`, errors)
assert(capability.summary, `assistant capability ${capability.id} missing summary`, errors)
}
assertUnique((assistantAccessPolicy.roleMatrix || []).map((matrix) => matrix.roleId), 'assistant role matrix', errors)
for (const matrix of assistantAccessPolicy.roleMatrix || []) {
assert(assistantRoleIds.has(matrix.roleId), `assistant matrix references unknown role: ${matrix.roleId}`, errors)
for (const capabilityId of matrix.allowedCapabilities || []) {
assert(assistantCapabilityIds.has(capabilityId), `assistant matrix ${matrix.roleId} allows unknown capability: ${capabilityId}`, errors)
}
for (const capabilityId of matrix.deniedCapabilities || []) {
if (capabilityId === '*') continue
assert(assistantCapabilityIds.has(capabilityId), `assistant matrix ${matrix.roleId} denies unknown capability: ${capabilityId}`, errors)
}
for (const capabilityId of matrix.deniedWhenNoAdminScope || []) {
assert(assistantCapabilityIds.has(capabilityId), `assistant matrix ${matrix.roleId} no-scope denies unknown capability: ${capabilityId}`, errors)
}
for (const conditional of matrix.conditionalCapabilities || []) {
assert(assistantCapabilityIds.has(conditional.capabilityId), `assistant matrix ${matrix.roleId} conditional unknown capability: ${conditional.capabilityId}`, errors)
assert(conditional.requires, `assistant matrix ${matrix.roleId} conditional ${conditional.capabilityId} missing requires`, errors)
}
}
assertUnique([...assistantOperationIds], 'assistant operation', errors)
for (const operation of assistantAccessPolicy.operations || []) {
assert(RULE_ID_RE.test(operation.id), `assistant operation id has invalid format: ${operation.id}`, errors)
assert(assistantCapabilityIds.has(operation.capabilityId), `assistant operation ${operation.id} references unknown capability: ${operation.capabilityId}`, errors)
for (const denyId of operation.hardDenies || []) {
assert(assistantHardDenyIds.has(denyId), `assistant operation ${operation.id} references unknown hard deny: ${denyId}`, errors)
}
assert(operation.summary, `assistant operation ${operation.id} missing summary`, errors)
}
assertUnique((assistantAccessPolicy.hardDenies || []).map((deny) => deny.id), 'assistant hard deny', errors)
for (const deny of assistantAccessPolicy.hardDenies || []) {
assert(RULE_ID_RE.test(deny.id), `assistant hard deny id has invalid format: ${deny.id}`, errors)
assertEntityRefs(deny.entityIds, entityById, `assistant hard deny ${deny.id}`, errors)
}
assertUnique([...assistantRiskLevelIds], 'assistant risk level', errors)
for (const riskLevel of assistantRiskPolicy.riskLevels || []) {
assert(ROLE_ID_RE.test(riskLevel.id), `assistant risk level id has invalid format: ${riskLevel.id}`, errors)
assert(riskLevel.summary, `assistant risk level ${riskLevel.id} missing summary`, errors)
}
assertUnique([...assistantConfirmationModeIds], 'assistant confirmation mode', errors)
for (const mode of assistantRiskPolicy.confirmationModes || []) {
assert(ROLE_ID_RE.test(mode.id), `assistant confirmation mode id has invalid format: ${mode.id}`, errors)
assert(mode.summary, `assistant confirmation mode ${mode.id} missing summary`, errors)
}
assertUnique([...assistantSelfActionPolicyIds], 'assistant self action policy', errors)
for (const policy of assistantRiskPolicy.selfActionPolicies || []) {
assert(ROLE_ID_RE.test(policy.id), `assistant self action policy id has invalid format: ${policy.id}`, errors)
assert(policy.summary, `assistant self action policy ${policy.id} missing summary`, errors)
}
assertUnique([...assistantActionHardDenyIds], 'assistant action hard deny', errors)
for (const deny of assistantRiskPolicy.hardDenies || []) {
assert(RULE_ID_RE.test(deny.id), `assistant action hard deny id has invalid format: ${deny.id}`, errors)
for (const actionId of deny.safeAlternatives || []) {
assert(assistantActionIds.has(actionId), `assistant action hard deny ${deny.id} references unknown safe alternative: ${actionId}`, errors)
}
}
assertUnique((assistantRiskPolicy.globalRules || []).map((rule) => rule.id), 'assistant risk global rule', errors)
for (const rule of assistantRiskPolicy.globalRules || []) {
assert(RULE_ID_RE.test(rule.id), `assistant risk global rule id has invalid format: ${rule.id}`, errors)
assert(['error', 'warning'].includes(rule.severity), `assistant risk global rule ${rule.id} invalid severity`, errors)
assert(rule.summary, `assistant risk global rule ${rule.id} missing summary`, errors)
}
assertUnique([...assistantAppIds], 'assistant app', errors)
for (const app of assistantActions.apps || []) {
assert(ROLE_ID_RE.test(app.id), `assistant app id has invalid format: ${app.id}`, errors)
assert(app.name, `assistant app ${app.id} missing name`, errors)
assert(app.adapterId, `assistant app ${app.id} missing adapterId`, errors)
assertEntityRefs(app.authorityEntityIds, entityById, `assistant app ${app.id}.authorityEntityIds`, errors)
}
assertUnique([...assistantAdapterStatuses], 'assistant adapter status', errors)
for (const status of assistantActions.adapterStatuses || []) {
assert(ROLE_ID_RE.test(status), `assistant adapter status has invalid format: ${status}`, errors)
}
assertUnique([...assistantActionIds], 'assistant action', errors)
for (const action of assistantActions.actions || []) {
assert(RULE_ID_RE.test(action.id), `assistant action id has invalid format: ${action.id}`, errors)
assert(assistantAppIds.has(action.app), `assistant action ${action.id} references unknown app: ${action.app}`, errors)
assert(action.domain, `assistant action ${action.id} missing domain`, errors)
assert(Array.isArray(action.intentAliases), `assistant action ${action.id} intentAliases must be array`, errors)
assertEntityRefs(action.entityIds, entityById, `assistant action ${action.id}.entityIds`, errors)
for (const capabilityId of action.capabilityIds || []) {
assert(assistantCapabilityIds.has(capabilityId), `assistant action ${action.id} references unknown capability: ${capabilityId}`, errors)
}
assert(assistantRiskLevelIds.has(action.riskLevel), `assistant action ${action.id} references unknown riskLevel: ${action.riskLevel}`, errors)
assert(assistantConfirmationModeIds.has(action.confirmationMode), `assistant action ${action.id} references unknown confirmationMode: ${action.confirmationMode}`, errors)
assert(assistantSelfActionPolicyIds.has(action.selfActionPolicy), `assistant action ${action.id} references unknown selfActionPolicy: ${action.selfActionPolicy}`, errors)
assert(action.adapterId, `assistant action ${action.id} missing adapterId`, errors)
assert(assistantAdapterStatuses.has(action.adapterStatus), `assistant action ${action.id} references unknown adapterStatus: ${action.adapterStatus}`, errors)
for (const denyId of action.hardDenyIds || []) {
assert(assistantActionHardDenyIds.has(denyId), `assistant action ${action.id} references unknown hard deny: ${denyId}`, errors)
}
for (const actionId of action.safeAlternativeActionIds || []) {
assert(assistantActionIds.has(actionId), `assistant action ${action.id} references unknown safe alternative action: ${actionId}`, errors)
}
if (action.riskLevel === 'destructive') {
assert(action.confirmationMode === 'forbidden', `assistant action ${action.id} destructive action must use confirmationMode=forbidden`, errors)
assert(action.adapterStatus === 'forbidden', `assistant action ${action.id} destructive action must use adapterStatus=forbidden`, errors)
}
if (action.confirmationMode === 'forbidden') {
assert(action.refusal, `assistant action ${action.id} forbidden action missing refusal`, errors)
}
if (action.riskLevel === 'privileged') {
assert(Array.isArray(action.requiredScopes) && action.requiredScopes.length > 0, `assistant action ${action.id} privileged action missing requiredScopes`, errors)
}
}
const contextStatuses = new Set(contextBindings.contextStatuses || [])
const bindingStatuses = new Set(contextBindings.bindingStatuses || [])
assertUnique(contextBindings.contexts.map((context) => context.id), 'context', errors)
for (const context of contextBindings.contexts) {
assert(RULE_ID_RE.test(context.id), `context id has invalid format: ${context.id}`, errors)
assert(entityById.has(context.entityId), `context ${context.id} references unknown entity: ${context.entityId}`, errors)
assert(contextStatuses.has(context.status), `context ${context.id} has invalid status: ${context.status}`, errors)
assert(context.surface, `context ${context.id} missing surface`, errors)
assert(context.sourceSystem, `context ${context.id} missing sourceSystem`, errors)
assertRefsObject(context.refs, `context ${context.id}`, errors)
if (context.parentContextId) {
assertContextRefs([context.parentContextId], contextById, `context ${context.id}`, errors)
}
}
assertUnique(contextBindings.bindingTypes.map((bindingType) => bindingType.id), 'binding type', errors)
for (const bindingType of contextBindings.bindingTypes) {
assert(RULE_ID_RE.test(bindingType.id), `binding type id has invalid format: ${bindingType.id}`, errors)
assertRelationRef(bindingType.relationId, relationById, `binding type ${bindingType.id}`, errors)
assertEntityRefs(bindingType.fromEntityIds, entityById, `binding type ${bindingType.id}.fromEntityIds`, errors)
assertEntityRefs(bindingType.toEntityIds, entityById, `binding type ${bindingType.id}.toEntityIds`, errors)
}
assertUnique(contextBindings.bindings.map((binding) => binding.id), 'binding', errors)
for (const binding of contextBindings.bindings) {
assert(RULE_ID_RE.test(binding.id), `binding id has invalid format: ${binding.id}`, errors)
assert(bindingStatuses.has(binding.status), `binding ${binding.id} has invalid status: ${binding.status}`, errors)
assert(bindingTypeById.has(binding.typeId), `binding ${binding.id} references unknown binding type: ${binding.typeId}`, errors)
assertContextRefs([binding.fromContextId, binding.toContextId], contextById, `binding ${binding.id}`, errors)
}
return {
ok: errors.length === 0,
errors,
warnings,
counts: {
entities: entities.entities.length,
relations: relations.relations.length,
aliases: aliases.aliases.length,
guardrails: guardrails.rules.length,
evidenceLedgers: evidence.ledgers.length,
resolverRules: resolverRules.rules.length,
assistantRoles: assistantAccessPolicy.roleVocabulary.length,
assistantCapabilities: assistantAccessPolicy.capabilities.length,
assistantOperations: assistantAccessPolicy.operations.length,
assistantActions: assistantActions.actions.length,
assistantApps: assistantActions.apps.length,
assistantRiskRules: assistantRiskPolicy.globalRules.length,
contexts: contextBindings.contexts.length,
bindingTypes: contextBindings.bindingTypes.length,
bindings: contextBindings.bindings.length,
},
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
const result = await validateCatalog()
if (!result.ok) {
console.error(JSON.stringify(result, null, 2))
process.exit(1)
}
console.log(JSON.stringify(result, null, 2))
}